diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index af2fd88..75c8dc7 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,5 +1,10 @@ use crate::base::errors::Error; use crate::base::events::{ + AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, CategoryRegistered, + ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, + NotificationDelivered, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, + NotificationPriority, NotificationRecalled, NotificationRevoked, NotificationScheduled, + ScheduledNotificationCancelled, Withdrawal, AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAcknowledged, NotificationCategory, NotificationExpired, NotificationPriority, NotificationRevoked, @@ -1013,6 +1018,10 @@ pub fn schedule_notification( expires_at, revoked_by: None, revoked_at: None, + delivered: false, + delivered_at: None, + recalled_by: None, + recalled_at: None, title, }; env.storage().persistent().set(&key, ¬ification); @@ -1276,6 +1285,110 @@ pub fn batch_schedule_notifications( /// /// Revoked notifications maintain their state for transparency and auditing: /// they can still be queried but cannot be cancelled or expired. +pub fn confirm_notification_delivery( + env: Env, + notification_id: BytesN<32>, + caller: Address, +) -> Result<(), Error> { + caller.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + let key = DataKey::ScheduledNotification(notification_id.clone()); + let mut notification = load_notification(&env, ¬ification_id).ok_or(Error::NotFound)?; + + if is_revoked(¬ification) { + return Err(Error::NotificationRevoked); + } + + if is_expired(&env, ¬ification) { + return Err(Error::NotificationExpired); + } + + if notification.delivered { + return Err(Error::NotificationDelivered); + } + + let admin = get_admin(env.clone()).ok(); + let is_creator = caller == notification.creator; + let is_admin = admin.as_ref().map_or(false, |a| caller == *a); + + if !is_creator && !is_admin { + return Err(Error::Unauthorized); + } + + let delivered_at = env.ledger().timestamp(); + notification.delivered = true; + notification.delivered_at = Some(delivered_at); + + env.storage().persistent().set(&key, ¬ification); + + NotificationDelivered { + notification_id, + delivered_by: caller, + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + delivered_at, + } + .publish(&env); + + Ok(()) +} + +pub fn recall_notification( + env: Env, + notification_id: BytesN<32>, + caller: Address, +) -> Result<(), Error> { + caller.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + let key = DataKey::ScheduledNotification(notification_id.clone()); + let mut notification = load_notification(&env, ¬ification_id).ok_or(Error::NotFound)?; + + if is_revoked(¬ification) { + return Err(Error::NotificationRevoked); + } + + if is_expired(&env, ¬ification) { + return Err(Error::NotificationExpired); + } + + if notification.delivered { + return Err(Error::NotificationDelivered); + } + + let admin = get_admin(env.clone()).ok(); + let is_creator = caller == notification.creator; + let is_admin = admin.as_ref().map_or(false, |a| caller == *a); + + if !is_creator && !is_admin { + return Err(Error::Unauthorized); + } + + let recalled_at = env.ledger().timestamp(); + notification.recalled_by = Some(caller.clone()); + notification.recalled_at = Some(recalled_at); + + env.storage().persistent().set(&key, ¬ification); + + NotificationRecalled { + notification_id, + recalled_by: caller, + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + recalled_at, + } + .publish(&env); + + Ok(()) +} + pub fn revoke_notification( env: Env, notification_id: BytesN<32>, diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index 5cf99ca..4de5713 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -68,5 +68,8 @@ pub enum Error { NotAuthorizedToAcknowledge = 29, AlreadyRevoked = 29, /// Triggered when an invalid limit configuration is provided. + InvalidLimit = 29, + /// Triggered when a notification has already been delivered and cannot be recalled. + NotificationDelivered = 30, InvalidLimit = 30, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 4a916d2..b4c5416 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -201,6 +201,36 @@ pub struct ScheduledNotificationCancelled { pub notification_id: BytesN<32>, } +/// Emitted when a notification is confirmed as delivered to its intended recipient. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct NotificationDelivered { + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub delivered_by: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub delivered_at: u64, +} + +/// Emitted when a sender recalls a scheduled notification before delivery confirmation. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct NotificationRecalled { + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub recalled_by: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub recalled_at: u64, +} + /// Emitted when a notification is scheduled on-chain with a bounded lifetime. /// /// Off-chain consumers can use this to track the notification's existence and diff --git a/contract/contracts/hello-world/src/base/types.rs b/contract/contracts/hello-world/src/base/types.rs index 0e94870..21bd86d 100644 --- a/contract/contracts/hello-world/src/base/types.rs +++ b/contract/contracts/hello-world/src/base/types.rs @@ -43,6 +43,14 @@ pub struct ScheduledNotification { pub revoked_by: Option
, /// Ledger timestamp (seconds) at which the notification was revoked, if revoked. pub revoked_at: Option, + /// Whether the notification has been confirmed as delivered. + pub delivered: bool, + /// Ledger timestamp (seconds) at which delivery was confirmed, if any. + pub delivered_at: Option, + /// Address that recalled the notification, or None if not recalled. + pub recalled_by: Option
, + /// Ledger timestamp (seconds) at which the notification was recalled, if recalled. + pub recalled_at: Option, /// Notification title (required metadata for off-chain processing) pub title: String, } diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 22eee97..7c41bf9 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -389,6 +389,20 @@ impl AutoShareContract { autoshare_logic::expire_notification(env, notification_id).unwrap(); } + /// Confirms delivery of a scheduled notification. + /// + /// Only the notification creator or the contract admin can confirm delivery. + /// The notification must exist, not already be revoked or expired, and not yet be marked delivered. + pub fn confirm_notification_delivery(env: Env, notification_id: BytesN<32>, caller: Address) { + autoshare_logic::confirm_notification_delivery(env, notification_id, caller).unwrap(); + } + + /// Recalls a scheduled notification before delivery confirmation. + /// + /// Only the notification creator or the contract admin can recall a notification. + /// The notification must exist, not already be revoked or expired, and not yet be delivered. + pub fn recall_notification(env: Env, notification_id: BytesN<32>, caller: Address) { + autoshare_logic::recall_notification(env, notification_id, caller).unwrap(); /// Emits a `BatchProcessingCompleted` event for off-chain listeners. pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) { autoshare_logic::emit_batch_completed(env, batch_id, processed_count).unwrap(); diff --git a/contract/contracts/hello-world/src/tests/notification_test.rs b/contract/contracts/hello-world/src/tests/notification_test.rs index 5be8b12..7307f32 100644 --- a/contract/contracts/hello-world/src/tests/notification_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_test.rs @@ -12,12 +12,13 @@ //! - the change is backward compatible: the event name remains the first topic //! and the previously defined topics/data are unchanged. +use crate::base::errors::Error; use crate::base::events::{NotificationCategory, NotificationPriority}; use crate::test_utils::{create_test_group, setup_test_env}; use crate::AutoShareContractClient; use soroban_sdk::testutils::{Address as _, Events}; -use soroban_sdk::{Address, BytesN, Symbol, TryFromVal, Val, Vec}; +use soroban_sdk::{Address, BytesN, String, Symbol, TryFromVal, Val, Vec}; /// Returns the topic list of the most recently emitted event whose first topic /// matches `event_name` (the snake_case event name produced by `#[contractevent]`). @@ -542,6 +543,77 @@ fn test_multiple_cancellations_emit_distinct_events() { } } +#[test] +fn test_recall_notification_emits_event_for_sender() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let mut id_bytes = [0u8; 32]; + id_bytes[0] = 40; + let notification_id = BytesN::from_array(&test_env.env, &id_bytes); + + client.schedule_notification( + ¬ification_id, + &creator, + &3600u64, + &String::from_str(&test_env.env, "Recall me"), + ); + client.recall_notification(¬ification_id, &creator); + + assert!(topics_of(&test_env.env, "notification_recalled").is_some()); +} + +#[test] +fn test_recall_notification_rejects_unauthorized_sender() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let other = test_env.users.get(1).unwrap().clone(); + + let mut id_bytes = [0u8; 32]; + id_bytes[0] = 41; + let notification_id = BytesN::from_array(&test_env.env, &id_bytes); + + client.schedule_notification( + ¬ification_id, + &creator, + &3600u64, + &String::from_str(&test_env.env, "Nope"), + ); + + let result = client.try_recall_notification(¬ification_id, &other); + assert!( + result.is_err(), + "recall should fail for an unauthorized caller" + ); +} + +#[test] +fn test_recall_notification_rejects_after_delivery_confirmation() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let mut id_bytes = [0u8; 32]; + id_bytes[0] = 42; + let notification_id = BytesN::from_array(&test_env.env, &id_bytes); + + client.schedule_notification( + ¬ification_id, + &creator, + &3600u64, + &String::from_str(&test_env.env, "Delivered"), + ); + client.confirm_notification_delivery(¬ification_id, &creator); + + let result = client.try_recall_notification(¬ification_id, &creator); + assert!( + result.is_err(), + "recall should fail after delivery confirmation" + ); +} + /// Backward compatibility: the event name is still the first topic, the /// pre-existing `creator` topic is unchanged, the category is appended as the /// trailing topic, and the data payload (`id`) is preserved.