Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
use crate::base::errors::Error;
use crate::base::events::{
AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused,
ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAcknowledged,
NotificationCategory, NotificationExpired, NotificationPriority, NotificationRevoked,
NotificationScheduled, ScheduledNotificationCancelled, Withdrawal,
AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated,
AutoshareUpdated, BatchNotificationsCreated, CategoryRegistered, ContractPaused,
ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired,
Expand Down Expand Up @@ -1462,6 +1466,15 @@ pub fn is_notification_revoked(env: Env, notification_id: BytesN<32>) -> Result<
Ok(is_revoked(&notification))
}

/// 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<BytesN<32>>,
/// Emits a `BatchProcessingCompleted` event for off-chain consumers.
pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) -> Result<(), Error> {
BatchProcessingCompleted {
Expand Down Expand Up @@ -1490,6 +1503,32 @@ pub fn extend_notification_expiry(
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(&notification) {
return Err(Error::NotificationRevoked);
}

if is_expired(&env, &notification) {
return Err(Error::NotificationExpired);
}

NotificationAcknowledged {
notification_id: id,
acknowledger: caller.clone(),
category: NotificationCategory::Notification,
priority: NOTIFICATION_PRIORITY,
timestamp,
}
.publish(&env);
}
if extension_seconds == 0 {
return Err(Error::InvalidExpirationDuration);
}
Expand Down
3 changes: 3 additions & 0 deletions contract/contracts/hello-world/src/base/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ pub enum Error {
/// Triggered when the caller is not authorized to revoke a notification.
NotAuthorizedToRevoke = 28,
/// 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,
AlreadyRevoked = 29,
/// Triggered when an invalid limit configuration is provided.
InvalidLimit = 30,
Expand Down
15 changes: 15 additions & 0 deletions contract/contracts/hello-world/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,3 +423,18 @@ pub struct NotificationAccessed {
/// Ledger timestamp (seconds) when the access occurred.
pub accessed_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,
}
5 changes: 5 additions & 0 deletions contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ impl AutoShareContract {
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<BytesN<32>>) {
autoshare_logic::acknowledge_notifications(env, caller, notification_ids).unwrap();
/// Extends the expiration period of a scheduled notification by `extension_seconds`.
///
/// Only the notification creator or the contract admin can extend it.
Expand Down Expand Up @@ -632,6 +635,8 @@ mod tests {
#[path = "../tests/revocation_test.rs"]
mod revocation_test;

#[path = "../tests/batch_ack_test.rs"]
mod batch_ack_test;
#[path = "../tests/fuzz_test.rs"]
mod fuzz_test;

Expand Down
209 changes: 209 additions & 0 deletions contract/contracts/hello-world/src/tests/batch_ack_test.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading