Skip to content
Open
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
52 changes: 49 additions & 3 deletions contract/contracts/hello-world/src/autoshare_logic.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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(&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>>,
) -> 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(&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);
}

Ok(())
}
2 changes: 2 additions & 0 deletions contract/contracts/hello-world/src/base/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
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 @@ -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,
}
8 changes: 8 additions & 0 deletions contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BytesN<32>>) {
autoshare_logic::acknowledge_notifications(env, caller, notification_ids).unwrap();
}
}

#[cfg(test)]
Expand Down Expand Up @@ -333,4 +338,7 @@ mod tests {

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

#[path = "../tests/batch_ack_test.rs"]
mod batch_ack_test;
}
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
);
}
18 changes: 11 additions & 7 deletions contract/contracts/hello-world/src/tests/revocation_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
Loading
Loading