From fe151b5b072ea188b70652ea1f99fcc4e1750b8b Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 24 Jun 2026 07:55:42 +0100 Subject: [PATCH] [Contracts] Add Batch Acknowledgment Support --- .../hello-world/src/autoshare_logic.rs | 52 ++++- .../contracts/hello-world/src/base/errors.rs | 2 + .../contracts/hello-world/src/base/events.rs | 15 ++ contract/contracts/hello-world/src/lib.rs | 8 + .../hello-world/src/tests/batch_ack_test.rs | 209 ++++++++++++++++++ .../hello-world/src/tests/revocation_test.rs | 18 +- 6 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 contract/contracts/hello-world/src/tests/batch_ack_test.rs diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index ad87d61..922109b 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,9 +1,9 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, - NotificationPriority, NotificationRevoked, NotificationScheduled, ScheduledNotificationCancelled, - Withdrawal, + ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAcknowledged, + NotificationCategory, NotificationExpired, NotificationPriority, NotificationRevoked, + NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, }; use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification}; use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; @@ -1083,3 +1083,49 @@ pub fn is_notification_revoked(env: Env, notification_id: BytesN<32>) -> Result< let notification = get_notification(env, notification_id)?; Ok(is_revoked(¬ification)) } + +/// Acknowledges multiple scheduled notifications in a single batch. +/// +/// Only the creator of the notification can acknowledge it. The notification +/// must exist, not be revoked, and not be expired. +/// Emits a [`NotificationAcknowledged`] event for each valid notification. +pub fn acknowledge_notifications( + env: Env, + caller: Address, + notification_ids: Vec>, +) -> Result<(), Error> { + caller.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + let timestamp = env.ledger().timestamp(); + + for id in notification_ids.iter() { + let notification = load_notification(&env, &id).ok_or(Error::NotFound)?; + + if notification.creator != caller { + return Err(Error::NotAuthorizedToAcknowledge); + } + + if is_revoked(¬ification) { + return Err(Error::NotificationRevoked); + } + + if is_expired(&env, ¬ification) { + return Err(Error::NotificationExpired); + } + + NotificationAcknowledged { + notification_id: id, + acknowledger: caller.clone(), + category: NotificationCategory::Notification, + priority: NOTIFICATION_PRIORITY, + timestamp, + } + .publish(&env); + } + + Ok(()) +} diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index 9bd2180..7845efb 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -62,4 +62,6 @@ pub enum Error { NotAuthorizedToRevoke = 27, /// Triggered when attempting to revoke a notification that is already revoked. AlreadyRevoked = 28, + /// Triggered when the caller is not authorized to acknowledge a notification. + NotAuthorizedToAcknowledge = 29, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index fbb5dcd..16c2e59 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -237,3 +237,18 @@ pub struct NotificationRevoked { pub priority: NotificationPriority, pub revoked_at: u64, } + +/// Emitted when a notification is acknowledged by an authorized user. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct NotificationAcknowledged { + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub acknowledger: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub timestamp: u64, +} diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 0fef756..c69ce55 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -302,6 +302,11 @@ impl AutoShareContract { pub fn is_notification_revoked(env: Env, notification_id: BytesN<32>) -> bool { autoshare_logic::is_notification_revoked(env, notification_id).unwrap() } + + /// Acknowledges multiple scheduled notifications in a single batch. + pub fn acknowledge_notifications(env: Env, caller: Address, notification_ids: Vec>) { + autoshare_logic::acknowledge_notifications(env, caller, notification_ids).unwrap(); + } } #[cfg(test)] @@ -333,4 +338,7 @@ mod tests { #[path = "../tests/revocation_test.rs"] mod revocation_test; + + #[path = "../tests/batch_ack_test.rs"] + mod batch_ack_test; } diff --git a/contract/contracts/hello-world/src/tests/batch_ack_test.rs b/contract/contracts/hello-world/src/tests/batch_ack_test.rs new file mode 100644 index 0000000..aa56737 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/batch_ack_test.rs @@ -0,0 +1,209 @@ +//! Tests for batch acknowledgment of notifications. +//! +//! These tests verify: +//! - Multiple notifications can be acknowledged in a single transaction. +//! - Validates notification ownership (only creator can acknowledge). +//! - Correct `NotificationAcknowledged` events are emitted. +//! - Gas benchmarking to prove batching is more efficient than individual calls. + +use crate::base::events::{NotificationCategory, NotificationPriority}; +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +use soroban_sdk::testutils::{Address as _, Events, Ledger}; +use soroban_sdk::{Address, BytesN, Env, Symbol, TryFromVal, Val, Vec}; + +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) +} + +fn set_now(env: &Env, timestamp: u64) { + env.ledger().set_timestamp(timestamp); +} + +fn count_events(env: &Env, event_name: &str) -> usize { + let target = Symbol::new(env, event_name); + let mut count = 0; + 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 { + count += 1; + } + } + } + count +} + +#[test] +fn test_acknowledge_multiple_notifications() { + 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 id1 = make_id(&test_env.env, 1); + let id2 = make_id(&test_env.env, 2); + let id3 = make_id(&test_env.env, 3); + + client.schedule_notification(&id1, &creator, &ONE_HOUR); + client.schedule_notification(&id2, &creator, &ONE_HOUR); + client.schedule_notification(&id3, &creator, &ONE_HOUR); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + batch.push_back(id2.clone()); + batch.push_back(id3.clone()); + + set_now(&test_env.env, 2_000); + + client.acknowledge_notifications(&creator, &batch); + + // Verify exactly 3 events were emitted + assert_eq!(count_events(&test_env.env, "notification_acknowledged"), 3); +} + +#[test] +#[should_panic] +fn test_acknowledge_unauthorized_fails() { + 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 unauthorized = Address::generate(&test_env.env); + + set_now(&test_env.env, 1_000); + + let id1 = make_id(&test_env.env, 1); + client.schedule_notification(&id1, &creator, &ONE_HOUR); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + + // Fails because `unauthorized` does not own the notification + client.acknowledge_notifications(&unauthorized, &batch); +} + +#[test] +#[should_panic] +fn test_acknowledge_revoked_fails() { + 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 id1 = make_id(&test_env.env, 1); + client.schedule_notification(&id1, &creator, &ONE_HOUR); + + client.revoke_notification(&id1, &creator); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + + // Fails because notification is revoked + client.acknowledge_notifications(&creator, &batch); +} + +#[test] +#[should_panic] +fn test_acknowledge_expired_fails() { + 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 id1 = make_id(&test_env.env, 1); + client.schedule_notification(&id1, &creator, &ONE_HOUR); + + set_now(&test_env.env, 1_000 + ONE_HOUR + 1); + + let mut batch = Vec::new(&test_env.env); + batch.push_back(id1.clone()); + + // Fails because notification is expired + client.acknowledge_notifications(&creator, &batch); +} + +#[test] +fn benchmark_gas_usage() { + let env_single = Env::default(); + env_single.mock_all_auths(); + env_single.cost_estimate().budget().reset_unlimited(); + + let client_single = AutoShareContractClient::new( + &env_single, + &env_single.register_contract(None, crate::AutoShareContract), + ); + let creator_single = Address::generate(&env_single); + client_single.initialize_admin(&Address::generate(&env_single)); + + set_now(&env_single, 1_000); + + let mut ids_single = Vec::new(&env_single); + for i in 0..10u8 { + let id = make_id(&env_single, i); + client_single.schedule_notification(&id, &creator_single, &ONE_HOUR); + ids_single.push_back(id); + } + + let start_cpu_single = env_single + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + for id in ids_single.iter() { + let mut single_batch = Vec::new(&env_single); + single_batch.push_back(id); + client_single.acknowledge_notifications(&creator_single, &single_batch); + } + let end_cpu_single = env_single + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + let single_cost = end_cpu_single - start_cpu_single; + + let env_batch = Env::default(); + env_batch.mock_all_auths(); + env_batch.cost_estimate().budget().reset_unlimited(); + + let client_batch = AutoShareContractClient::new( + &env_batch, + &env_batch.register_contract(None, crate::AutoShareContract), + ); + let creator_batch = Address::generate(&env_batch); + client_batch.initialize_admin(&Address::generate(&env_batch)); + + set_now(&env_batch, 1_000); + + let mut ids_batch = Vec::new(&env_batch); + for i in 0..10u8 { + let id = make_id(&env_batch, i); + client_batch.schedule_notification(&id, &creator_batch, &ONE_HOUR); + ids_batch.push_back(id); + } + + let start_cpu_batch = env_batch + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + client_batch.acknowledge_notifications(&creator_batch, &ids_batch); + let end_cpu_batch = env_batch + .cost_estimate() + .budget() + .get_cpu_instruction_cost(); + let batch_cost = end_cpu_batch - start_cpu_batch; + + // Batch cost should be significantly less than running 10 separate transactions + assert!( + batch_cost < single_cost, + "Batch cost ({}) should be less than individual cost ({})", + batch_cost, + single_cost + ); +} diff --git a/contract/contracts/hello-world/src/tests/revocation_test.rs b/contract/contracts/hello-world/src/tests/revocation_test.rs index 8867b53..6d881b9 100644 --- a/contract/contracts/hello-world/src/tests/revocation_test.rs +++ b/contract/contracts/hello-world/src/tests/revocation_test.rs @@ -118,7 +118,8 @@ fn test_revoke_notification_emits_event() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // [0] name, [1] notification_id, [2] revoked_by, [3] category, [4] priority. assert_eq!(topics.len(), 5); @@ -327,12 +328,14 @@ fn test_revoke_event_has_high_priority() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // Last topic is priority let priority_topic = topics.last().unwrap(); - let priority = crate::base::events::NotificationPriority::try_from_val(&test_env.env, &priority_topic) - .expect("priority should be extractable"); - + let priority = + crate::base::events::NotificationPriority::try_from_val(&test_env.env, &priority_topic) + .expect("priority should be extractable"); + assert_eq!(priority, crate::base::events::NotificationPriority::High); } @@ -349,12 +352,13 @@ fn test_revoke_event_has_notification_category() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // Second to last topic is category let n = topics.len(); let category_topic = topics.get(n - 2).unwrap(); let category = NotificationCategory::try_from_val(&test_env.env, &category_topic) .expect("category should be extractable"); - + assert_eq!(category, NotificationCategory::Notification); }