diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index b56c8a6..a4a2449 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,10 +1,10 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationPriority, - ScheduledNotificationCancelled, Withdrawal, + ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, + NotificationPriority, NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, }; -use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory}; +use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification}; use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; /// Maximum allowed length for AutoShare group names. @@ -23,6 +23,7 @@ pub enum DataKey { GroupPaymentHistory(BytesN<32>), GroupMembers(BytesN<32>), IsPaused, + ScheduledNotification(BytesN<32>), } pub fn create_autoshare( @@ -812,33 +813,96 @@ pub fn withdraw( Ok(()) } +fn validate_members(members: &Vec) -> Result<(), Error> { + if members.is_empty() { + return Err(Error::EmptyMembers); + } + // Validate member count limit + if members.len() > MAX_MEMBERS { + return Err(Error::TooManyMembers); + } + let env = members.env(); + let mut total_percentage: u32 = 0; + let mut seen_addresses = Vec::new(env); + + for member in members.iter() { + total_percentage += member.percentage; + for seen in seen_addresses.iter() { + if seen == member.address { + return Err(Error::DuplicateMember); + } + } + seen_addresses.push_back(member.address.clone()); + } + + if total_percentage != 100 { + return Err(Error::InvalidTotalPercentage); + } + Ok(()) +} + // ============================================================================ -// Scheduled Notification Cancellation +// Notification Scheduling & Expiration // ============================================================================ -/// Cancels a scheduled notification identified by `notification_id` and emits -/// a [`ScheduledNotificationCancelled`] event so off-chain consumers can track -/// the lifecycle of every scheduled notification in real time. +/// Default priority attached to notification lifecycle events. +const NOTIFICATION_PRIORITY: NotificationPriority = NotificationPriority::Medium; + +/// Reads a scheduled notification from storage, if one is tracked for `id`. +fn load_notification(env: &Env, id: &BytesN<32>) -> Option { + env.storage() + .persistent() + .get(&DataKey::ScheduledNotification(id.clone())) +} + +/// Returns true if `notification` has reached or passed its expiry instant. +fn is_expired(env: &Env, notification: &ScheduledNotification) -> bool { + env.ledger().timestamp() >= notification.expires_at +} + +/// Schedules a notification on-chain that becomes invalid after `ttl_seconds`. /// -/// The contract does not keep a registry of scheduled notifications; callers are -/// responsible for submitting the correct identifier. Any authenticated address -/// may cancel a notification — access control beyond authentication is left to -/// the application layer. -pub fn cancel_notification( +/// The notification is stored with an `expires_at` of `now + ttl_seconds`. A +/// zero duration (or one that overflows the ledger clock) is rejected, as is a +/// duplicate identifier. Emits [`NotificationScheduled`]. +pub fn schedule_notification( env: Env, notification_id: BytesN<32>, - caller: Address, + creator: Address, + ttl_seconds: u64, ) -> Result<(), Error> { - caller.require_auth(); + creator.require_auth(); if get_paused_status(&env) { return Err(Error::ContractPaused); } - ScheduledNotificationCancelled { - caller, + if ttl_seconds == 0 { + return Err(Error::InvalidExpirationDuration); + } + + let key = DataKey::ScheduledNotification(notification_id.clone()); + if env.storage().persistent().has(&key) { + return Err(Error::AlreadyExists); + } + + let created_at = env.ledger().timestamp(); + let expires_at = created_at + .checked_add(ttl_seconds) + .ok_or(Error::InvalidExpirationDuration)?; + + let notification = ScheduledNotification { + id: notification_id.clone(), + creator: creator.clone(), + created_at, + expires_at, + }; + env.storage().persistent().set(&key, ¬ification); + + NotificationScheduled { + creator, category: NotificationCategory::Notification, - priority: NotificationPriority::Low, + priority: NOTIFICATION_PRIORITY, notification_id, } .publish(&env); @@ -846,30 +910,87 @@ pub fn cancel_notification( Ok(()) } -fn validate_members(members: &Vec) -> Result<(), Error> { - if members.is_empty() { - return Err(Error::EmptyMembers); +/// Retrieves a scheduled notification. Returns [`Error::NotFound`] if no +/// notification is tracked for `notification_id` (including one already expired +/// and reaped via [`expire_notification`]). +pub fn get_notification( + env: Env, + notification_id: BytesN<32>, +) -> Result { + load_notification(&env, ¬ification_id).ok_or(Error::NotFound) +} + +/// Returns whether a tracked notification has expired. Errors with +/// [`Error::NotFound`] if the notification is not tracked. +pub fn is_notification_expired(env: Env, notification_id: BytesN<32>) -> Result { + let notification = get_notification(env.clone(), notification_id)?; + Ok(is_expired(&env, ¬ification)) +} + +/// Expires a notification whose lifetime has elapsed: removes it from storage +/// and emits [`NotificationExpired`]. +/// +/// Permissionless by design — any party (e.g. an off-chain keeper) may finalize +/// the expiry of an elapsed notification. A notification that has not yet +/// reached its expiry is rejected with [`Error::NotificationNotExpired`]; an +/// unknown one with [`Error::NotFound`]. +pub fn expire_notification(env: Env, notification_id: BytesN<32>) -> Result<(), Error> { + let key = DataKey::ScheduledNotification(notification_id.clone()); + let notification = load_notification(&env, ¬ification_id).ok_or(Error::NotFound)?; + + if !is_expired(&env, ¬ification) { + return Err(Error::NotificationNotExpired); } - // Validate member count limit - if members.len() > MAX_MEMBERS { - return Err(Error::TooManyMembers); + + env.storage().persistent().remove(&key); + + NotificationExpired { + notification_id, + category: NotificationCategory::Notification, + priority: NOTIFICATION_PRIORITY, + expires_at: notification.expires_at, } - let env = members.env(); - let mut total_percentage: u32 = 0; - let mut seen_addresses = Vec::new(env); + .publish(&env); - for member in members.iter() { - total_percentage += member.percentage; - for seen in seen_addresses.iter() { - if seen == member.address { - return Err(Error::DuplicateMember); - } + Ok(()) +} + +/// Cancels a scheduled notification identified by `notification_id` and emits a +/// [`ScheduledNotificationCancelled`] event so off-chain consumers can track the +/// lifecycle of every scheduled notification in real time. +/// +/// If the notification is tracked on-chain, cancelling reaps its storage entry — +/// but an **expired** notification is invalid and cannot be cancelled; such an +/// attempt is rejected with [`Error::NotificationExpired`]. Identifiers that are +/// not tracked on-chain are accepted (and simply emit the event) so callers can +/// signal cancellation of notifications managed entirely off-chain. +pub fn cancel_notification( + env: Env, + notification_id: BytesN<32>, + caller: Address, +) -> Result<(), Error> { + caller.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + if let Some(notification) = load_notification(&env, ¬ification_id) { + if is_expired(&env, ¬ification) { + return Err(Error::NotificationExpired); } - seen_addresses.push_back(member.address.clone()); + env.storage() + .persistent() + .remove(&DataKey::ScheduledNotification(notification_id.clone())); } - if total_percentage != 100 { - return Err(Error::InvalidTotalPercentage); + ScheduledNotificationCancelled { + caller, + category: NotificationCategory::Notification, + priority: NotificationPriority::Low, + notification_id, } + .publish(&env); + Ok(()) } diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index 25763bc..af1d286 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -48,4 +48,12 @@ pub enum Error { NameTooLong = 21, /// Triggered when the number of members exceeds the maximum allowed. TooManyMembers = 22, + /// Triggered when interacting with a notification that has already expired. + NotificationExpired = 23, + /// Triggered when an invalid expiration duration is provided (e.g., zero or + /// one that overflows the ledger clock). + InvalidExpirationDuration = 24, + /// Triggered when attempting to expire a notification whose lifetime has not + /// yet elapsed. + NotificationNotExpired = 25, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 7feeac9..675a937 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -4,7 +4,7 @@ use soroban_sdk::{contractevent, contracttype, Address, BytesN, String}; /// /// Off-chain consumers (listeners, indexers, dashboards) often only care about a /// subset of the events the contract emits. Each event carries its category as a -/// trailing, indexed event topic so consumers can subscribe to or filter out +/// trailing, indexed event topic so consumers can subscribe to or filter out /// whole categories without having to decode the event payload first. /// /// # Backward compatibility @@ -23,7 +23,7 @@ pub enum NotificationCategory { Admin = 1, /// Movement of funds: withdrawals. Financial = 2, - /// Scheduled notification operations: cancellation. + /// Scheduled notification operations: scheduling, expiry, cancellation. Notification = 3, } @@ -31,7 +31,7 @@ pub enum NotificationCategory { /// /// Off-chain consumers (alerting, dashboards, paging) often route notifications /// by priority rather than (or in addition to) category. Each event carries its -/// priority as a trailing, indexed event topic so consumers can subscribe to +/// priority as a trailing, indexed event topic so consumers can subscribe to /// or page on high-priority notifications without decoding the payload. /// /// # Backward compatibility @@ -184,3 +184,36 @@ pub struct ScheduledNotificationCancelled { pub priority: NotificationPriority, pub notification_id: BytesN<32>, } + +/// 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 +/// know when to expect an accompanying [`NotificationExpired`] event. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct NotificationScheduled { + #[topic] + pub creator: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub notification_id: BytesN<32>, +} + +/// Emitted when a scheduled notification's lifetime elapses and it is expired. +/// +/// The `notification_id` is published as an indexed topic so consumers can +/// subscribe to the expiry of a specific notification; the `expires_at` +/// timestamp at which it became invalid is carried as the event data. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct NotificationExpired { + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub expires_at: u64, +} diff --git a/contract/contracts/hello-world/src/base/types.rs b/contract/contracts/hello-world/src/base/types.rs index 73abe7a..6567595 100644 --- a/contract/contracts/hello-world/src/base/types.rs +++ b/contract/contracts/hello-world/src/base/types.rs @@ -21,6 +21,21 @@ pub struct GroupMember { pub percentage: u32, } +/// A notification stored on-chain with a bounded lifetime. +/// +/// The notification is considered **expired** — and therefore invalid for any +/// further interaction — once the ledger timestamp reaches `expires_at`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScheduledNotification { + pub id: BytesN<32>, + pub creator: Address, + /// Ledger timestamp (seconds) at which the notification was scheduled. + pub created_at: u64, + /// Ledger timestamp (seconds) at or after which the notification is expired. + pub expires_at: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PaymentHistory { diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index b8e7783..bb7aa24 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -253,6 +253,42 @@ impl AutoShareContract { pub fn cancel_notification(env: Env, notification_id: BytesN<32>, caller: Address) { autoshare_logic::cancel_notification(env, notification_id, caller).unwrap(); } + + // ============================================================================ + // Notification Expiration + // ============================================================================ + + /// Schedules a notification on-chain that expires after `ttl_seconds`. + /// + /// The notification becomes invalid once the ledger timestamp reaches + /// `created_at + ttl_seconds`. Emits a `NotificationScheduled` event. + pub fn schedule_notification( + env: Env, + notification_id: BytesN<32>, + creator: Address, + ttl_seconds: u64, + ) { + autoshare_logic::schedule_notification(env, notification_id, creator, ttl_seconds).unwrap(); + } + + /// Returns the stored details for a scheduled notification. + pub fn get_notification( + env: Env, + notification_id: BytesN<32>, + ) -> base::types::ScheduledNotification { + autoshare_logic::get_notification(env, notification_id).unwrap() + } + + /// Returns whether a scheduled notification has expired. + pub fn is_notification_expired(env: Env, notification_id: BytesN<32>) -> bool { + autoshare_logic::is_notification_expired(env, notification_id).unwrap() + } + + /// Finalizes the expiry of a notification whose lifetime has elapsed, + /// emitting a `NotificationExpired` event. Callable by anyone. + pub fn expire_notification(env: Env, notification_id: BytesN<32>) { + autoshare_logic::expire_notification(env, notification_id).unwrap(); + } } #[cfg(test)] @@ -278,4 +314,7 @@ mod tests { #[path = "../tests/notification_test.rs"] mod notification_test; + + #[path = "../tests/expiration_test.rs"] + mod expiration_test; } diff --git a/contract/contracts/hello-world/src/tests/expiration_test.rs b/contract/contracts/hello-world/src/tests/expiration_test.rs new file mode 100644 index 0000000..ce84c88 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/expiration_test.rs @@ -0,0 +1,328 @@ +//! Tests for on-chain notification expiration (issue #128). +//! +//! These cover the full lifecycle of a scheduled notification: +//! - scheduling stores a bounded lifetime and emits `NotificationScheduled`, +//! - an elapsed notification is *invalid* — it can't be cancelled and reports as +//! expired, +//! - finalizing expiry removes the record and emits `NotificationExpired`, +//! - the configurable duration is validated, and pausing blocks scheduling. + +use crate::base::events::NotificationCategory; +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +use soroban_sdk::testutils::{Events, Ledger}; +use soroban_sdk::{Address, BytesN, Env, Symbol, TryFromVal, Val, Vec}; + +/// One hour, in seconds — a representative configurable duration. +const ONE_HOUR: u64 = 3_600; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +/// Sets the ledger clock to an absolute timestamp (seconds). +fn set_now(env: &Env, timestamp: u64) { + env.ledger().set_timestamp(timestamp); +} + +/// Returns the topic list of the most recently emitted event whose first topic +/// matches `event_name` (the snake_case name produced by `#[contractevent]`). +fn topics_of(env: &Env, event_name: &str) -> Option> { + let target = Symbol::new(env, event_name); + let mut found: Option> = None; + for (_addr, topics, _data) in env.events().all().iter() { + if topics.is_empty() { + continue; + } + let first = topics.get(0).unwrap(); + if let Ok(name) = Symbol::try_from_val(env, &first) { + if name == target { + found = Some(topics); + } + } + } + found +} + +/// Returns the data payload of the latest event named `event_name`. +fn data_of(env: &Env, event_name: &str) -> Option { + let target = Symbol::new(env, event_name); + let mut found: Option = None; + for (_addr, topics, data) in env.events().all().iter() { + if topics.is_empty() { + continue; + } + let first = topics.get(0).unwrap(); + if let Ok(name) = Symbol::try_from_val(env, &first) { + if name == target { + found = Some(data); + } + } + } + found +} + +#[test] +fn test_schedule_stores_created_and_expiry() { + 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(); + + set_now(&test_env.env, 1_000); + let id = make_id(&test_env.env, 1); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + let stored = client.get_notification(&id); + assert_eq!(stored.id, id); + assert_eq!(stored.creator, creator); + assert_eq!(stored.created_at, 1_000); + assert_eq!(stored.expires_at, 1_000 + ONE_HOUR); +} + +#[test] +fn test_schedule_emits_notification_scheduled_event() { + 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 id = make_id(&test_env.env, 2); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + let topics = topics_of(&test_env.env, "notification_scheduled").expect("event must be emitted"); + // [0] name, [1] creator, [2] category, [3] priority. + assert_eq!(topics.len(), 4); + + let topic_creator = Address::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap(); + assert_eq!(topic_creator, creator); + + let category = + NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); + assert_eq!(category, NotificationCategory::Notification); + + // Data payload carries the notification id. + let data = data_of(&test_env.env, "notification_scheduled").unwrap(); + let data_id = BytesN::<32>::try_from_val(&test_env.env, &data).unwrap(); + assert_eq!(data_id, id); +} + +#[test] +fn test_not_expired_before_deadline_and_expired_after() { + 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(); + + set_now(&test_env.env, 1_000); + let id = make_id(&test_env.env, 3); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + // Just before the deadline: still valid. + set_now(&test_env.env, 1_000 + ONE_HOUR - 1); + assert!(!client.is_notification_expired(&id)); + + // At the deadline: expired (boundary is inclusive). + set_now(&test_env.env, 1_000 + ONE_HOUR); + assert!(client.is_notification_expired(&id)); + + // Well past the deadline: still expired. + set_now(&test_env.env, 1_000 + ONE_HOUR + 10_000); + assert!(client.is_notification_expired(&id)); +} + +#[test] +fn test_zero_duration_is_rejected() { + 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 id = make_id(&test_env.env, 4); + let result = client.try_schedule_notification(&id, &creator, &0); + assert!( + result.is_err(), + "a zero expiration duration must be rejected" + ); +} + +#[test] +fn test_duplicate_schedule_is_rejected() { + 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 id = make_id(&test_env.env, 5); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + let result = client.try_schedule_notification(&id, &creator, &ONE_HOUR); + assert!( + result.is_err(), + "scheduling a duplicate id must be rejected" + ); +} + +#[test] +fn test_get_unknown_notification_fails() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let id = make_id(&test_env.env, 6); + assert!(client.try_get_notification(&id).is_err()); + assert!(client.try_is_notification_expired(&id).is_err()); +} + +#[test] +fn test_expired_notification_cannot_be_cancelled() { + 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(); + + set_now(&test_env.env, 500); + let id = make_id(&test_env.env, 7); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + // Before expiry, cancellation succeeds for a fresh (different) notification. + let fresh = make_id(&test_env.env, 8); + client.schedule_notification(&fresh, &creator, &ONE_HOUR); + client.cancel_notification(&fresh, &creator); + // Cancelling reaps the record. + assert!(client.try_get_notification(&fresh).is_err()); + + // After expiry, the original notification is invalid and cannot be cancelled. + set_now(&test_env.env, 500 + ONE_HOUR + 1); + let result = client.try_cancel_notification(&id, &creator); + assert!( + result.is_err(), + "an expired notification must not be cancellable" + ); +} + +#[test] +fn test_expire_before_deadline_is_rejected() { + 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(); + + set_now(&test_env.env, 1_000); + let id = make_id(&test_env.env, 9); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + // Not yet elapsed — finalizing expiry must be rejected. + set_now(&test_env.env, 1_000 + ONE_HOUR - 1); + assert!(client.try_expire_notification(&id).is_err()); + + // The notification is still present and valid. + assert!(!client.is_notification_expired(&id)); +} + +#[test] +fn test_expire_after_deadline_emits_event_and_reaps_storage() { + 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(); + + set_now(&test_env.env, 2_000); + let id = make_id(&test_env.env, 10); + let expected_expiry = 2_000 + ONE_HOUR; + client.schedule_notification(&id, &creator, &ONE_HOUR); + + set_now(&test_env.env, expected_expiry); + client.expire_notification(&id); + + // Event shape: [0] name, [1] notification_id, [2] category, [3] priority. + let topics = topics_of(&test_env.env, "notification_expired").expect("event must be emitted"); + assert_eq!(topics.len(), 4); + + let topic_id = BytesN::<32>::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap(); + assert_eq!(topic_id, id); + + let category = + NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); + assert_eq!(category, NotificationCategory::Notification); + + // Data payload is the expiry timestamp. + let data = data_of(&test_env.env, "notification_expired").unwrap(); + let data_expiry = u64::try_from_val(&test_env.env, &data).unwrap(); + assert_eq!(data_expiry, expected_expiry); + + // Storage was reaped: the notification no longer exists. + assert!(client.try_get_notification(&id).is_err()); +} + +#[test] +fn test_expire_unknown_notification_fails() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let id = make_id(&test_env.env, 11); + assert!(client.try_expire_notification(&id).is_err()); +} + +#[test] +fn test_schedule_blocked_when_contract_paused() { + 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(); + + client.pause(&test_env.admin); + + let id = make_id(&test_env.env, 12); + let result = client.try_schedule_notification(&id, &creator, &ONE_HOUR); + assert!( + result.is_err(), + "scheduling must be rejected while the contract is paused" + ); +} + +#[test] +fn test_valid_notification_can_be_cancelled_and_emits_event() { + 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(); + + set_now(&test_env.env, 100); + let id = make_id(&test_env.env, 13); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + client.cancel_notification(&id, &creator); + + assert!( + topics_of(&test_env.env, "scheduled_notification_cancelled").is_some(), + "cancellation event must be emitted" + ); + // The record is reaped on cancellation. + assert!(client.try_get_notification(&id).is_err()); +} + +#[test] +fn test_cancelling_untracked_id_still_emits_event() { + // Backward compatibility: ids that were never scheduled on-chain can still be + // cancelled (signalling cancellation of an off-chain-managed notification). + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let caller = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 14); + client.cancel_notification(&id, &caller); + + assert!( + topics_of(&test_env.env, "scheduled_notification_cancelled").is_some(), + "cancelling an untracked id must still emit the event" + ); +} + +#[test] +fn test_cancellation_blocked_when_contract_paused() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let caller = test_env.users.get(0).unwrap().clone(); + + client.pause(&test_env.admin); + + let id = make_id(&test_env.env, 15); + let result = client.try_cancel_notification(&id, &caller); + assert!( + result.is_err(), + "cancellation must be rejected while the contract is paused" + ); +}