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);