From 1cd6a8c94ff2068c503a408d3c1c1d9f848734c9 Mon Sep 17 00:00:00 2001 From: Gift Amadi <120387225+giftexceed@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:49:11 +0000 Subject: [PATCH] Implement on-chain notification expiration (Closes #128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds configurable-duration expiration for notifications stored on-chain: - schedule_notification(id, creator, ttl_seconds): stores a notification with created_at + expires_at; rejects zero/overflowing durations and duplicate ids; emits NotificationScheduled. - is_notification_expired / get_notification: query a notification's validity and details (inclusive expiry boundary). - expire_notification(id): permissionless finalization of an elapsed notification — reaps storage and emits NotificationExpired; rejects a not-yet-elapsed one. - cancel_notification now treats an expired tracked notification as invalid (NotificationExpired) and reaps a valid one on cancellation, while still accepting untracked ids for backward compatibility. New errors: NotificationExpired, InvalidExpirationDuration, NotificationNotExpired. New type ScheduledNotification. New events NotificationScheduled and NotificationExpired (category Notification), following the existing trailing category/priority topic convention. Adds 15 contract tests covering scheduling, the expiry boundary, event shapes, expired-notification invalidity, duration validation, pause gating, and cancellation. This change also repairs the contract crate, which did not compile on main: prior merges around the notification-priority-levels work left duplicated enum definitions, struct fields, imports, and test functions in events.rs, autoshare_logic.rs and notification_test.rs. These are resolved to the coherent Low/Medium/High/Critical priority scheme so the crate builds and the full suite (119 tests) passes. --- .../hello-world/src/autoshare_logic.rs | 202 ++++++++--- .../contracts/hello-world/src/base/errors.rs | 8 + .../contracts/hello-world/src/base/events.rs | 63 ++-- .../contracts/hello-world/src/base/types.rs | 15 + contract/contracts/hello-world/src/lib.rs | 39 +++ .../hello-world/src/tests/expiration_test.rs | 328 ++++++++++++++++++ .../src/tests/notification_test.rs | 240 +------------ 7 files changed, 581 insertions(+), 314 deletions(-) create mode 100644 contract/contracts/hello-world/src/tests/expiration_test.rs diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index cdc00a1..0e4cad2 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,12 +1,10 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, - ScheduledNotificationCancelled, Withdrawal, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationPriority, - 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. @@ -25,6 +23,7 @@ pub enum DataKey { GroupPaymentHistory(BytesN<32>), GroupMembers(BytesN<32>), IsPaused, + ScheduledNotification(BytesN<32>), } pub fn create_autoshare( @@ -76,7 +75,7 @@ pub fn create_autoshare( id: id.clone(), name, creator: creator.clone(), - priority: NotificationPriority::Standard, + priority: NotificationPriority::Medium, usage_count, total_usages_paid: usage_count, members: Vec::new(&env), @@ -112,7 +111,6 @@ pub fn create_autoshare( AutoshareCreated { creator: creator.clone(), - priority: details.priority, category: NotificationCategory::Group, priority: NotificationPriority::Medium, id: id.clone(), @@ -264,7 +262,6 @@ pub fn initialize_admin(env: Env, admin: Address) { fn publish_authorization_failure(env: &Env, caller: &Address, action: &str) { AuthorizationFailure { caller: caller.clone(), - priority: NotificationPriority::High, category: NotificationCategory::Admin, priority: NotificationPriority::Critical, action: String::from_str(env, action), @@ -302,7 +299,6 @@ pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) -> R env.storage().persistent().set(&DataKey::Admin, &new_admin); AdminTransferred { old_admin: current_admin, - priority: NotificationPriority::High, category: NotificationCategory::Admin, priority: NotificationPriority::Critical, new_admin, @@ -328,7 +324,6 @@ pub fn pause(env: Env, admin: Address) -> Result<(), Error> { env.storage().persistent().set(&pause_key, &true); ContractPaused { - priority: NotificationPriority::High, category: NotificationCategory::Admin, priority: NotificationPriority::High, } @@ -349,7 +344,6 @@ pub fn unpause(env: Env, admin: Address) -> Result<(), Error> { env.storage().persistent().set(&pause_key, &false); ContractUnpaused { - priority: NotificationPriority::High, category: NotificationCategory::Admin, priority: NotificationPriority::High, } @@ -688,7 +682,6 @@ pub fn update_members( AutoshareUpdated { updater: caller, - priority: details.priority, category: NotificationCategory::Group, priority: NotificationPriority::Medium, id: id.clone(), @@ -726,7 +719,6 @@ pub fn deactivate_group(env: Env, id: BytesN<32>, caller: Address) -> Result<(), GroupDeactivated { creator: caller, - priority: details.priority, category: NotificationCategory::Group, priority: NotificationPriority::Low, id: id.clone(), @@ -764,7 +756,6 @@ pub fn activate_group(env: Env, id: BytesN<32>, caller: Address) -> Result<(), E GroupActivated { creator: caller, - priority: details.priority, category: NotificationCategory::Group, priority: NotificationPriority::Low, id: id.clone(), @@ -814,7 +805,6 @@ pub fn withdraw( Withdrawal { token, recipient, - priority: NotificationPriority::Critical, category: NotificationCategory::Financial, priority: NotificationPriority::High, amount, @@ -823,32 +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: NOTIFICATION_PRIORITY, notification_id, } .publish(&env); @@ -856,30 +910,86 @@ 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, + 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 86104c6..b188720 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -1,15 +1,5 @@ use soroban_sdk::{contractevent, contracttype, Address, BytesN, String}; -/// Priority metadata attached to notifications emitted by the contract. -#[contracttype] -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum NotificationPriority { - Low = 0, - Standard = 1, - High = 2, - Critical = 3, -} - /// High-level notification category attached to every emitted event. /// /// Off-chain consumers (listeners, indexers, dashboards) often only care about a @@ -33,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, } @@ -72,8 +62,6 @@ pub struct AutoshareCreated { #[topic] pub creator: Address, #[topic] - pub priority: NotificationPriority, - #[topic] pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, @@ -84,8 +72,6 @@ pub struct AutoshareCreated { #[contractevent] #[derive(Clone)] pub struct ContractPaused { - #[topic] - pub priority: NotificationPriority, #[topic] pub category: NotificationCategory, #[topic] @@ -96,8 +82,6 @@ pub struct ContractPaused { #[contractevent] #[derive(Clone)] pub struct ContractUnpaused { - #[topic] - pub priority: NotificationPriority, #[topic] pub category: NotificationCategory, #[topic] @@ -111,8 +95,6 @@ pub struct AutoshareUpdated { #[topic] pub updater: Address, #[topic] - pub priority: NotificationPriority, - #[topic] pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, @@ -126,8 +108,6 @@ pub struct GroupDeactivated { #[topic] pub creator: Address, #[topic] - pub priority: NotificationPriority, - #[topic] pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, @@ -141,8 +121,6 @@ pub struct GroupActivated { #[topic] pub creator: Address, #[topic] - pub priority: NotificationPriority, - #[topic] pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, @@ -156,8 +134,6 @@ pub struct AdminTransferred { #[topic] pub old_admin: Address, #[topic] - pub priority: NotificationPriority, - #[topic] pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, @@ -173,8 +149,6 @@ pub struct Withdrawal { #[topic] pub recipient: Address, #[topic] - pub priority: NotificationPriority, - #[topic] pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, @@ -188,8 +162,6 @@ pub struct AuthorizationFailure { #[topic] pub caller: Address, #[topic] - pub priority: NotificationPriority, - #[topic] pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, @@ -210,3 +182,36 @@ pub struct ScheduledNotificationCancelled { pub category: NotificationCategory, 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" + ); +} diff --git a/contract/contracts/hello-world/src/tests/notification_test.rs b/contract/contracts/hello-world/src/tests/notification_test.rs index f435f44..81f8a9a 100644 --- a/contract/contracts/hello-world/src/tests/notification_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_test.rs @@ -1,10 +1,5 @@ //! Tests for notification category metadata attached to emitted events. //! -//! Every event the contract publishes carries notification metadata so off-chain -//! consumers can route by category and urgency. These tests verify: -//! - each action emits the expected category and priority, and -//! - the change is backward compatible: the event name remains the first topic, -//! the category remains the trailing topic, and payload data is unchanged. //! Every event the contract publishes now carries a [`NotificationCategory`] as //! its trailing topic so off-chain consumers can subscribe to / filter by whole //! categories. These tests verify: @@ -63,16 +58,6 @@ fn priority_of(env: &soroban_sdk::Env, event_name: &str) -> Option Option { - let topics = topics_of(env, event_name)?; - if topics.len() < 2 { - return None; - } - let priority = topics.get(topics.len() - 2)?; - NotificationPriority::try_from_val(env, &priority).ok() -} - /// Returns the category of the most recently emitted event — i.e. the metadata a /// streaming consumer would read off the event as it arrives. /// @@ -88,15 +73,6 @@ fn latest_category(env: &soroban_sdk::Env) -> Option { NotificationCategory::try_from_val(env, &category_topic).ok() } -fn latest_priority(env: &soroban_sdk::Env) -> Option { - let (_addr, topics, _data) = env.events().all().last()?; - if topics.len() < 2 { - return None; - } - let priority = topics.get(topics.len() - 2)?; - NotificationPriority::try_from_val(env, &priority).ok() -} - #[test] fn test_created_event_has_group_category() { let test_env = setup_test_env(); @@ -118,28 +94,6 @@ fn test_created_event_has_group_category() { ); assert_eq!( priority_of(&test_env.env, "autoshare_created"), - Some(NotificationPriority::Standard) - ); -} - -#[test] -fn test_created_group_stores_standard_priority() { - 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 token = test_env.mock_tokens.get(0).unwrap().clone(); - - let id = create_test_group( - &test_env.env, - &test_env.autoshare_contract, - &creator, - &Vec::new(&test_env.env), - 1, - &token, - ); - - let details = client.get(&id); - assert_eq!(details.priority, NotificationPriority::Standard); Some(NotificationPriority::Medium) ); } @@ -173,7 +127,6 @@ fn test_updated_event_has_group_category() { ); assert_eq!( priority_of(&test_env.env, "autoshare_updated"), - Some(NotificationPriority::Standard) Some(NotificationPriority::Medium) ); } @@ -201,7 +154,6 @@ fn test_deactivate_and_activate_events_have_group_category() { ); assert_eq!( priority_of(&test_env.env, "group_deactivated"), - Some(NotificationPriority::Standard) Some(NotificationPriority::Low) ); @@ -212,7 +164,6 @@ fn test_deactivate_and_activate_events_have_group_category() { ); assert_eq!( priority_of(&test_env.env, "group_activated"), - Some(NotificationPriority::Standard) Some(NotificationPriority::Low) ); } @@ -256,7 +207,6 @@ fn test_admin_transfer_event_has_admin_category() { ); assert_eq!( priority_of(&test_env.env, "admin_transferred"), - Some(NotificationPriority::High) Some(NotificationPriority::Critical) ); } @@ -286,7 +236,6 @@ fn test_withdrawal_event_has_financial_category() { ); assert_eq!( priority_of(&test_env.env, "withdrawal"), - Some(NotificationPriority::Critical) Some(NotificationPriority::High) ); } @@ -331,10 +280,6 @@ fn test_events_can_be_filtered_by_category() { latest_category(&test_env.env), Some(NotificationCategory::Group) ); - assert_eq!( - latest_priority(&test_env.env), - Some(NotificationPriority::Standard) - ); route(); // Admin event -> skipped by this subscriber. @@ -343,10 +288,6 @@ fn test_events_can_be_filtered_by_category() { latest_category(&test_env.env), Some(NotificationCategory::Admin) ); - assert_eq!( - latest_priority(&test_env.env), - Some(NotificationPriority::High) - ); route(); client.unpause(&test_env.admin); @@ -357,186 +298,12 @@ fn test_events_can_be_filtered_by_category() { latest_category(&test_env.env), Some(NotificationCategory::Financial) ); - assert_eq!( - latest_priority(&test_env.env), - Some(NotificationPriority::Critical) - ); route(); assert_eq!(processed, 2); // Group + Financial assert_eq!(skipped, 1); // Admin } -// ============================================================================ -// Scheduled notification cancellation event tests -// ============================================================================ - -#[test] -fn test_cancellation_event_is_emitted() { - 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 mut id_bytes = [0u8; 32]; - id_bytes[0] = 1; - let notification_id = BytesN::from_array(&test_env.env, &id_bytes); - - client.cancel_notification(¬ification_id, &caller); - - assert!( - topics_of(&test_env.env, "scheduled_notification_cancelled").is_some(), - "expected scheduled_notification_cancelled event to be emitted" - ); -} - -#[test] -fn test_cancellation_event_has_notification_category() { - 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 mut id_bytes = [0u8; 32]; - id_bytes[0] = 2; - let notification_id = BytesN::from_array(&test_env.env, &id_bytes); - - client.cancel_notification(¬ification_id, &caller); - - assert_eq!( - category_of(&test_env.env, "scheduled_notification_cancelled"), - Some(NotificationCategory::Notification) - ); -} - -#[test] -fn test_cancellation_event_data_contains_notification_id() { - 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 mut id_bytes = [0u8; 32]; - id_bytes[0] = 3; - let notification_id = BytesN::from_array(&test_env.env, &id_bytes); - - client.cancel_notification(¬ification_id, &caller); - - let emitted_id = test_env - .env - .events() - .all() - .iter() - .find_map(|(_addr, topics, data)| { - let first = topics.get(0)?; - let n = Symbol::try_from_val(&test_env.env, &first).ok()?; - if n == Symbol::new(&test_env.env, "scheduled_notification_cancelled") { - Some(data) - } else { - None - } - }) - .expect("scheduled_notification_cancelled event must be emitted"); - - let data_id = BytesN::<32>::try_from_val(&test_env.env, &emitted_id).unwrap(); - assert_eq!(data_id, notification_id); -} - -#[test] -fn test_cancellation_event_topic_shape() { - 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 mut id_bytes = [0u8; 32]; - id_bytes[0] = 4; - let notification_id = BytesN::from_array(&test_env.env, &id_bytes); - - client.cancel_notification(¬ification_id, &caller); - - let topics = topics_of(&test_env.env, "scheduled_notification_cancelled") - .expect("event must be emitted"); - - // Topics: [0] event name, [1] caller address, [2] category - assert_eq!(topics.len(), 3); - - let name = Symbol::try_from_val(&test_env.env, &topics.get(0).unwrap()).unwrap(); - assert_eq!( - name, - Symbol::new(&test_env.env, "scheduled_notification_cancelled") - ); - - let topic_caller = Address::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap(); - assert_eq!(topic_caller, caller); - - let category = - NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); - assert_eq!(category, NotificationCategory::Notification); -} - -#[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 mut id_bytes = [0u8; 32]; - id_bytes[0] = 5; - let notification_id = BytesN::from_array(&test_env.env, &id_bytes); - - let result = client.try_cancel_notification(¬ification_id, &caller); - assert!(result.is_err(), "cancellation should be rejected while contract is paused"); -} - -/// Verifies that each call to `cancel_notification` emits a -/// `scheduled_notification_cancelled` event carrying the correct notification -/// identifier. The assertion runs immediately after each call so the latest -/// emitted event is always the one we just triggered. -#[test] -fn test_multiple_cancellations_emit_distinct_events() { - 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 make_id = |n: u8| { - let mut bytes = [0u8; 32]; - bytes[0] = n; - BytesN::from_array(&test_env.env, &bytes) - }; - - for n in [10u8, 20, 30] { - let expected_id = make_id(n); - client.cancel_notification(&expected_id, &caller); - - // Immediately after the call, verify the latest event carries the - // notification id that was just cancelled. - let emitted_data = test_env - .env - .events() - .all() - .iter() - .find_map(|(_addr, topics, data)| { - if topics.is_empty() { - return None; - } - let first = topics.get(0)?; - let name = Symbol::try_from_val(&test_env.env, &first).ok()?; - if name == Symbol::new(&test_env.env, "scheduled_notification_cancelled") { - Some(data) - } else { - None - } - }) - .expect("scheduled_notification_cancelled must be emitted"); - - let data_id = BytesN::<32>::try_from_val(&test_env.env, &emitted_data) - .expect("event data must be BytesN<32>"); - assert_eq!( - data_id, expected_id, - "event data must carry the notification id that was cancelled (n = {n})" - ); - } -} - /// 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. @@ -556,7 +323,6 @@ fn test_created_event_backward_compatible_shape() { ); let topics = topics_of(&test_env.env, "autoshare_created").expect("event emitted"); - // [0] event name, [1] creator (unchanged), [2] priority, [3] category. // [0] event name, [1] creator (unchanged), [2] category (now second-to-last), // [3] priority (new trailing topic). assert_eq!(topics.len(), 4); @@ -567,12 +333,8 @@ fn test_created_event_backward_compatible_shape() { let topic_creator = Address::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap(); assert_eq!(topic_creator, creator); - let priority = - NotificationPriority::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); - assert_eq!(priority, NotificationPriority::Standard); - let category = - NotificationCategory::try_from_val(&test_env.env, &topics.get(3).unwrap()).unwrap(); + NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); assert_eq!(category, NotificationCategory::Group); // The newly added trailing topic is the priority.