diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index ffcfe85..613d2a6 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -2,8 +2,9 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, CategoryRegistered, ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, - NotificationExpired, NotificationExtended, NotificationLimitsConfigured, NotificationPriority, - NotificationRevoked, NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, + NotificationDelivered, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, + NotificationPriority, NotificationRecalled, NotificationRevoked, NotificationScheduled, + ScheduledNotificationCancelled, Withdrawal, }; use crate::base::types::{ AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory, ScheduledNotification, @@ -996,6 +997,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); @@ -1114,6 +1119,110 @@ pub fn cancel_notification( /// /// 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 948bd07..a8f74be 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -64,4 +64,6 @@ pub enum Error { AlreadyRevoked = 28, /// Triggered when an invalid limit configuration is provided. InvalidLimit = 29, + /// Triggered when a notification has already been delivered and cannot be recalled. + NotificationDelivered = 30, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 6cc3ab5..961d561 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 42b0846..532314f 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 dc69223..a28232b 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -387,6 +387,22 @@ 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(); + } + /// Revokes a scheduled notification, preventing any further interaction with it. /// /// Only the notification creator or the contract admin can revoke a notification. 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. diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index cf25acd..c69f122 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -860,21 +860,6 @@ } } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -7772,21 +7757,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -12904,21 +12874,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", diff --git a/dashboard/src/components/ActivityFeed.tsx b/dashboard/src/components/ActivityFeed.tsx index 405a0d4..0ea9a01 100644 --- a/dashboard/src/components/ActivityFeed.tsx +++ b/dashboard/src/components/ActivityFeed.tsx @@ -151,7 +151,7 @@ export function ActivityFeed() { // Clear stale activity and re-fetch from page 1 whenever the connected // wallet address changes (switch or disconnect). This is the fix for issue #175. - useWalletAccountSync((_nextAddress) => { + useWalletAccountSync(() => { setEvents([]); setLiveEvents([]); setTotal(0); diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index bcf08db..39163a4 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -95,7 +95,7 @@ export function EventExplorerPage() { // Clear stale events and re-fetch whenever the connected wallet address // changes (switch or disconnect). This is the fix for issue #175. - useWalletAccountSync((_nextAddress) => { + useWalletAccountSync(() => { setEvents([]); setError(null); setPage(1);