From fb1af75d4ce89921ca20c4fa2301db58e9ebf4bb Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Sun, 21 Jun 2026 15:32:08 +0100 Subject: [PATCH] feat: emit ScheduledNotificationCancelled event on notification cancellation Added a dedicated on-chain event that fires every time a scheduled notification is cancelled. Off-chain consumers can now subscribe to the new Notification category (discriminant 3) and receive the exact notification identifier in the event payload without needing to poll or cross-reference any secondary data source. Changes: - NotificationCategory gets a new Notification = 3 variant for all scheduled notification operations - ScheduledNotificationCancelled event struct defined in events.rs with caller address and notification_id (BytesN<32>) in topics and data - cancel_notification function added to autoshare_logic.rs; requires caller auth and respects the contract paused flag - cancel_notification exposed as a public contract method in lib.rs - Six new tests in notification_test.rs covering event emission, correct category, complete event data, topic shape, pause guard, and sequential cancellations with distinct identifiers - contract/README.md updated with full ABI documentation for the new event and function including topic layout, data fields, and error codes --- contract/README.md | 71 +++++++- .../hello-world/src/autoshare_logic.rs | 36 +++- .../contracts/hello-world/src/base/events.rs | 17 ++ contract/contracts/hello-world/src/lib.rs | 13 ++ .../src/tests/notification_test.rs | 170 ++++++++++++++++++ 5 files changed, 305 insertions(+), 2 deletions(-) diff --git a/contract/README.md b/contract/README.md index 17f59e2..557ea3d 100644 --- a/contract/README.md +++ b/contract/README.md @@ -18,4 +18,73 @@ This repository uses the recommended structure for a Soroban project: - New Soroban contracts can be put in `contracts`, each in their own directory. There is already a `hello-world` contract in there to get you started. - If you initialized this project with any other example contracts via `--with-example`, those contracts will be in the `contracts` directory as well. - Contracts should have their own `Cargo.toml` files that rely on the top-level `Cargo.toml` workspace for their dependencies. -- Frontend libraries can be added to the top-level directory as well. If you initialized this project with a frontend template via `--frontend-template` you will have those files already included. \ No newline at end of file +- Frontend libraries can be added to the top-level directory as well. If you initialized this project with a frontend template via `--frontend-template` you will have those files already included. +--- + +## ABI Reference — AutoShareContract + +### Notification Category + +Every event carries a `NotificationCategory` as its last indexed topic so off-chain consumers can subscribe to or filter whole categories without decoding the payload. + +| Variant | Value | Description | +|----------------|-------|---------------------------------------------------------| +| `Group` | 0 | AutoShare group lifecycle (created, updated, toggled) | +| `Admin` | 1 | Administrative actions (pause, unpause, admin transfer) | +| `Financial` | 2 | Fund movements (withdrawals) | +| `Notification` | 3 | Scheduled notification operations (cancellation) | + +--- + +### Events + +#### `scheduled_notification_cancelled` + +Emitted whenever `cancel_notification` is called successfully. + +**Topics** (in order): + +| Index | Type | Description | +|-------|------------------------|----------------------------------------------------| +| 0 | `Symbol` | Event name: `"scheduled_notification_cancelled"` | +| 1 | `Address` | Address of the caller that triggered cancellation | +| 2 | `NotificationCategory` | Always `Notification` (discriminant value `3`) | + +**Data** (`data_format = "single-value"`): + +| Field | Type | Description | +|--------------------|--------------|---------------------------------------------------| +| `notification_id` | `BytesN<32>` | Unique identifier of the cancelled notification | + +**Example** (XDR topics decoded): +``` +topics[0] = Symbol("scheduled_notification_cancelled") +topics[1] = Address("G...") // caller +topics[2] = u32(3) // NotificationCategory::Notification +data = Bytes(32) // notification_id +``` + +--- + +### Functions + +#### `cancel_notification(notification_id: BytesN<32>, caller: Address)` + +Cancels a scheduled notification and emits a `ScheduledNotificationCancelled` event on-chain. + +- **Authentication**: `caller` must authorize the invocation (`caller.require_auth()`). +- **Paused check**: returns `ContractPaused` (error code 11) when the contract is paused. +- **State**: the contract does not maintain an internal registry of scheduled notifications; the `notification_id` is recorded solely in the emitted event. + +**Parameters:** + +| Name | Type | Description | +|--------------------|--------------|------------------------------------------| +| `notification_id` | `BytesN<32>` | Identifier of the notification to cancel | +| `caller` | `Address` | Address authorizing the cancellation | + +**Errors:** + +| Code | Variant | Condition | +|------|------------------|------------------------------------| +| 11 | `ContractPaused` | Contract is currently paused | diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index d392945..3bfb528 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,7 +1,8 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, Withdrawal, + ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, + ScheduledNotificationCancelled, Withdrawal, }; use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory}; use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; @@ -801,6 +802,39 @@ pub fn withdraw( Ok(()) } +// ============================================================================ +// Scheduled Notification Cancellation +// ============================================================================ + +/// 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. +/// +/// 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( + env: Env, + notification_id: BytesN<32>, + caller: Address, +) -> Result<(), Error> { + caller.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + ScheduledNotificationCancelled { + caller, + category: NotificationCategory::Notification, + notification_id, + } + .publish(&env); + + Ok(()) +} + fn validate_members(members: &Vec) -> Result<(), Error> { if members.is_empty() { return Err(Error::EmptyMembers); diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 9c97285..c359f60 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -23,6 +23,8 @@ pub enum NotificationCategory { Admin = 1, /// Movement of funds: withdrawals. Financial = 2, + /// Scheduled notification operations: cancellation. + Notification = 3, } /// Emitted when a new AutoShare group is created. @@ -119,3 +121,18 @@ pub struct AuthorizationFailure { pub category: NotificationCategory, pub action: String, } + +/// Emitted when a scheduled notification is cancelled. +/// +/// The `notification_id` field carries the unique identifier of the notification +/// that was cancelled, allowing off-chain consumers to correlate the on-chain +/// event back to the corresponding scheduled notification record. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct ScheduledNotificationCancelled { + #[topic] + pub caller: Address, + #[topic] + pub category: NotificationCategory, + pub notification_id: BytesN<32>, +} diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 6742f3c..b8e7783 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -240,6 +240,19 @@ impl AutoShareContract { pub fn reduce_usage(env: Env, id: BytesN<32>) { autoshare_logic::reduce_usage(env, id).unwrap(); } + + // ============================================================================ + // Scheduled Notification Management + // ============================================================================ + + /// Cancels a scheduled notification and emits a ScheduledNotificationCancelled event. + /// + /// The `notification_id` uniquely identifies the notification being cancelled. + /// Callers must authenticate. The contract is paused-aware: cancellations are + /// rejected while the contract is paused. + pub fn cancel_notification(env: Env, notification_id: BytesN<32>, caller: Address) { + autoshare_logic::cancel_notification(env, notification_id, caller).unwrap(); + } } #[cfg(test)] diff --git a/contract/contracts/hello-world/src/tests/notification_test.rs b/contract/contracts/hello-world/src/tests/notification_test.rs index 607ce0c..038ebb0 100644 --- a/contract/contracts/hello-world/src/tests/notification_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_test.rs @@ -249,6 +249,176 @@ fn test_events_can_be_filtered_by_category() { 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.