diff --git a/app/onchain/contracts/aid_escrow/README.md b/app/onchain/contracts/aid_escrow/README.md index 615acb10..271ecd19 100644 --- a/app/onchain/contracts/aid_escrow/README.md +++ b/app/onchain/contracts/aid_escrow/README.md @@ -124,15 +124,27 @@ pub struct Aggregates { ## Events -All state-changing operations emit events with stable topics for indexer consumption: - -- `EscrowFunded` — pool funded -- `PackageCreated` — package created -- `PackageClaimed` — recipient claimed -- `PackageDisbursed` — admin disbursed -- `PackageRevoked` — admin revoked -- `PackageRefunded` — admin refunded -- `BatchCreatedEvent` — batch creation +All state-changing operations emit events with stable topics and payloads optimized for off-chain indexers. Payloads are compact, exclude any dynamic maps/PII, and are structured under `EVENT_SCHEMA_VERSION = 2`. + +### Schema Versioning +`EVENT_SCHEMA_VERSION` (currently `2`) is exported by the contract and should be tracked by the backend indexer to detect breaking schema updates. + +### Event Definitions + +- **`EscrowFunded`**: `from: Address`, `token: Address`, `amount: i128`, `timestamp: u64` +- **`PackageCreated`**: `package_id: u64`, `recipient: Address`, `amount: i128`, `token: Address`, `actor: Address`, `timestamp: u64` +- **`PackageClaimed`**: `package_id: u64`, `recipient: Address`, `amount: i128`, `token: Address`, `actor: Address`, `timestamp: u64` +- **`PackageDisbursed`**: `package_id: u64`, `recipient: Address`, `amount: i128`, `token: Address`, `actor: Address`, `timestamp: u64` +- **`PackageRevoked`**: `package_id: u64`, `recipient: Address`, `amount: i128`, `token: Address`, `actor: Address`, `timestamp: u64` +- **`PackageRefunded`**: `package_id: u64`, `recipient: Address`, `amount: i128`, `token: Address`, `actor: Address`, `timestamp: u64` +- **`BatchCreatedEvent`**: `ids: Vec`, `admin: Address`, `token: Address`, `total_amount: i128`, `timestamp: u64` +- **`ExtendedEvent`**: `id: u64`, `admin: Address`, `old_expires_at: u64`, `new_expires_at: u64`, `timestamp: u64` +- **`SurplusWithdrawnEvent`**: `to: Address`, `token: Address`, `amount: i128`, `timestamp: u64` +- **`ContractPausedEvent`**: `admin: Address`, `timestamp: u64` +- **`ContractUnpausedEvent`**: `admin: Address`, `timestamp: u64` +- **`ActionPausedEvent`**: `admin: Address`, `action: Symbol`, `timestamp: u64` +- **`ActionUnpausedEvent`**: `admin: Address`, `action: Symbol`, `timestamp: u64` + ## Testing diff --git a/app/onchain/contracts/aid_escrow/src/lib.rs b/app/onchain/contracts/aid_escrow/src/lib.rs index e691d90e..d745a837 100644 --- a/app/onchain/contracts/aid_escrow/src/lib.rs +++ b/app/onchain/contracts/aid_escrow/src/lib.rs @@ -40,6 +40,10 @@ const KEY_PAUSE_WITHDRAW: Symbol = symbol_short!("p_wdrw"); const KEY_TOTAL_CLAIMED: Symbol = symbol_short!("claimed"); // Map const META_MERKLE_ROOT_KEY: &str = "merkle_root"; +/// Semantic version for the event schema. Bump whenever event topics or +/// payload shapes change so off-chain indexers can detect incompatibilities. +pub const EVENT_SCHEMA_VERSION: u32 = 2; + // --- Data Types --- #[contracttype] @@ -109,6 +113,8 @@ pub enum Error { // --- Contract Events (indexer-friendly; stable topics & payloads) --- // Topic = struct name in snake_case (e.g. package_created). Do not rename without versioning. +// EVENT_SCHEMA_VERSION must be bumped whenever event fields or topics change. +// No PII or opaque metadata maps are included — payloads are compact and safe. /// Emitted when the escrow pool is funded. Actor = funder. #[contractevent] @@ -124,6 +130,7 @@ pub struct PackageCreated { pub package_id: u64, pub recipient: Address, pub amount: i128, + pub token: Address, pub actor: Address, pub timestamp: u64, } @@ -133,6 +140,7 @@ pub struct PackageClaimed { pub package_id: u64, pub recipient: Address, pub amount: i128, + pub token: Address, pub actor: Address, pub timestamp: u64, } @@ -142,6 +150,7 @@ pub struct PackageDisbursed { pub package_id: u64, pub recipient: Address, pub amount: i128, + pub token: Address, pub actor: Address, pub timestamp: u64, } @@ -151,6 +160,7 @@ pub struct PackageRevoked { pub package_id: u64, pub recipient: Address, pub amount: i128, + pub token: Address, pub actor: Address, pub timestamp: u64, } @@ -160,6 +170,7 @@ pub struct PackageRefunded { pub package_id: u64, pub recipient: Address, pub amount: i128, + pub token: Address, pub actor: Address, pub timestamp: u64, } @@ -168,7 +179,9 @@ pub struct PackageRefunded { pub struct BatchCreatedEvent { pub ids: Vec, pub admin: Address, + pub token: Address, pub total_amount: i128, + pub timestamp: u64, } #[contractevent] @@ -177,6 +190,7 @@ pub struct ExtendedEvent { pub admin: Address, pub old_expires_at: u64, pub new_expires_at: u64, + pub timestamp: u64, } #[contractevent] @@ -184,28 +198,33 @@ pub struct SurplusWithdrawnEvent { pub to: Address, pub token: Address, pub amount: i128, + pub timestamp: u64, } #[contractevent] pub struct ContractPausedEvent { pub admin: Address, + pub timestamp: u64, } #[contractevent] pub struct ContractUnpausedEvent { pub admin: Address, + pub timestamp: u64, } #[contractevent] pub struct ActionPausedEvent { pub admin: Address, pub action: Symbol, + pub timestamp: u64, } #[contractevent] pub struct ActionUnpausedEvent { pub admin: Address, pub action: Symbol, + pub timestamp: u64, } #[contract] @@ -364,7 +383,8 @@ impl AidEscrow { let admin = Self::get_admin(env.clone())?; admin.require_auth(); env.storage().instance().set(&KEY_PAUSED, &true); - ContractPausedEvent { admin }.publish(&env); + let timestamp = env.ledger().timestamp(); + ContractPausedEvent { admin, timestamp }.publish(&env); Ok(()) } @@ -377,7 +397,8 @@ impl AidEscrow { let admin = Self::get_admin(env.clone())?; admin.require_auth(); env.storage().instance().set(&KEY_PAUSED, &false); - ContractUnpausedEvent { admin }.publish(&env); + let timestamp = env.ledger().timestamp(); + ContractUnpausedEvent { admin, timestamp }.publish(&env); Ok(()) } @@ -390,7 +411,13 @@ impl AidEscrow { let key = Self::get_pause_key(action.clone())?; env.storage().instance().set(&key, &true); - ActionPausedEvent { admin, action }.publish(&env); + let timestamp = env.ledger().timestamp(); + ActionPausedEvent { + admin, + action, + timestamp, + } + .publish(&env); Ok(()) } @@ -403,7 +430,13 @@ impl AidEscrow { let key = Self::get_pause_key(action.clone())?; env.storage().instance().set(&key, &false); - ActionUnpausedEvent { admin, action }.publish(&env); + let timestamp = env.ledger().timestamp(); + ActionUnpausedEvent { + admin, + action, + timestamp, + } + .publish(&env); Ok(()) } @@ -602,6 +635,7 @@ impl AidEscrow { package_id: id, recipient: recipient.clone(), amount, + token, actor: operator, timestamp: created_at, } @@ -727,6 +761,7 @@ impl AidEscrow { package_id: id, recipient: recipient.clone(), amount, + token: token.clone(), actor: operator.clone(), timestamp: created_at, } @@ -742,10 +777,13 @@ impl AidEscrow { env.storage().instance().set(&KEY_PKG_IDX, &idx); // Emit batch event + let batch_timestamp = env.ledger().timestamp(); BatchCreatedEvent { ids: created_ids.clone(), admin: operator, + token, total_amount, + timestamp: batch_timestamp, } .publish(&env); @@ -882,6 +920,7 @@ impl AidEscrow { package_id: id, recipient: package.recipient.clone(), amount: package.amount, + token: package.token.clone(), actor: admin.clone(), timestamp, } @@ -918,6 +957,7 @@ impl AidEscrow { package_id: id, recipient: package.recipient.clone(), amount: package.amount, + token: package.token.clone(), actor: admin.clone(), timestamp, } @@ -981,6 +1021,7 @@ impl AidEscrow { package_id: id, recipient: package.recipient.clone(), amount: package.amount, + token: package.token.clone(), actor: admin.clone(), timestamp, } @@ -1026,6 +1067,7 @@ impl AidEscrow { package_id, recipient: package.recipient.clone(), amount: package.amount, + token: package.token.clone(), actor: admin.clone(), timestamp, } @@ -1093,11 +1135,13 @@ impl AidEscrow { package.expires_at = new_expires_at; env.storage().persistent().set(&key, &package); + let ext_timestamp = env.ledger().timestamp(); ExtendedEvent { id, admin, old_expires_at, new_expires_at, + timestamp: ext_timestamp, } .publish(&env); @@ -1145,10 +1189,12 @@ impl AidEscrow { Self::transfer_token(&env, &token, &env.current_contract_address(), &to, &amount)?; // 7. Emit event + let surplus_timestamp = env.ledger().timestamp(); SurplusWithdrawnEvent { to: to.clone(), token: token.clone(), amount, + timestamp: surplus_timestamp, } .publish(&env); @@ -1310,6 +1356,7 @@ impl AidEscrow { package_id, recipient: payout_recipient.clone(), amount: package.amount, + token: package.token.clone(), actor: payout_recipient.clone(), timestamp: now, } diff --git a/app/onchain/contracts/aid_escrow/tests/event_schema.rs b/app/onchain/contracts/aid_escrow/tests/event_schema.rs new file mode 100644 index 00000000..7d816296 --- /dev/null +++ b/app/onchain/contracts/aid_escrow/tests/event_schema.rs @@ -0,0 +1,678 @@ +//! Snapshot-style tests for event schema consistency. +//! +//! These tests guarantee that: +//! 1. Every event has the expected topic name (snake_case struct name). +//! 2. Every event payload contains exactly the documented set of fields. +//! 3. No PII or opaque metadata maps leak into event payloads. +//! 4. EVENT_SCHEMA_VERSION is accessible and matches the expected value. +//! +//! If any of these tests break after a contract change, it means the event +//! schema has changed and EVENT_SCHEMA_VERSION must be bumped. + +#![cfg(test)] + +use aid_escrow::{AidEscrow, AidEscrowClient, EVENT_SCHEMA_VERSION}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events, Ledger}, + token::{StellarAssetClient, TokenClient}, + Address, Env, Map, Symbol, TryFromVal, Val, Vec, +}; + +const UNIT: i128 = 10_000_000; + +// ── Helpers ────────────────────────────────────────────────────────────── + +fn setup_token(env: &Env, admin: &Address) -> (TokenClient<'static>, StellarAssetClient<'static>) { + let token_contract = env.register_stellar_asset_contract_v2(admin.clone()); + let token_client = TokenClient::new(env, &token_contract.address()); + let token_admin_client = StellarAssetClient::new(env, &token_contract.address()); + (token_client, token_admin_client) +} + +fn sym(env: &Env, s: &str) -> Symbol { + Symbol::new(env, s) +} + +/// Returns all events emitted by the given contract. +fn contract_events(env: &Env, contract_id: &Address) -> std::vec::Vec<(Address, Vec, Val)> { + env.events() + .all() + .into_iter() + .filter(|(id, _, _)| id == contract_id) + .collect() +} + +/// Finds the last event with the given topic symbol and returns its data Val. +fn last_event_data(env: &Env, contract_id: &Address, topic: &str) -> Val { + let expected = sym(env, topic); + let events = contract_events(env, contract_id); + for (_, topics, data) in events.iter().rev() { + if let Some(first) = topics.first() { + if let Ok(s) = Symbol::try_from_val(env, &first) { + if s == expected { + return *data; + } + } + } + } + panic!( + "expected event with topic '{}', found {} contract events", + topic, + events.len() + ); +} + +/// Extracts field names from an event data map. +#[allow(dead_code)] +fn event_field_names(env: &Env, data: &Val) -> std::vec::Vec { + let map = soroban_sdk::Map::::try_from_val(env, data).unwrap(); + let keys = map.keys(); + let mut names = std::vec::Vec::new(); + for i in 0..keys.len() { + let key = keys.get(i).unwrap(); + // Convert Symbol to string by formatting with debug + names.push(format!("{:?}", key)); + } + names.sort(); + names +} + +/// Counts the number of fields in an event data map. +fn event_field_count(env: &Env, data: &Val) -> u32 { + let map = soroban_sdk::Map::::try_from_val(env, data).unwrap(); + map.len() +} + +fn assert_field_exists(env: &Env, data: &Val, field: &str) { + let map = soroban_sdk::Map::::try_from_val(env, data).unwrap(); + assert!( + map.get(sym(env, field)).is_some(), + "field '{}' missing from event data", + field + ); +} + +fn assert_field_absent(env: &Env, data: &Val, field: &str) { + let map = soroban_sdk::Map::::try_from_val(env, data).unwrap(); + assert!( + map.get(sym(env, field)).is_none(), + "field '{}' should NOT be in event data (potential metadata leak)", + field + ); +} + +// ── Schema Version ─────────────────────────────────────────────────────── + +#[test] +fn test_event_schema_version_is_current() { + // If this assertion fails, it means someone changed the schema version + // without updating the test — review all event changes and confirm + // backward-compat before adjusting. + assert_eq!(EVENT_SCHEMA_VERSION, 2); +} + +// ── Topic Name Stability ───────────────────────────────────────────────── + +#[test] +fn test_event_topics_include_package_id() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + client.create_package( + &admin, + &42u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + + // Verify the topic is "package_created" (snake_case) + let expected = sym(&env, "package_created"); + let events = contract_events(&env, &contract_id); + let found = events.iter().any(|(_, topics, _)| { + topics + .first() + .and_then(|v| Symbol::try_from_val(&env, &v).ok()) + .map(|s| s == expected) + .unwrap_or(false) + }); + assert!(found, "event topic 'package_created' not found"); +} + +// ── Payload Field Consistency ──────────────────────────────────────────── + +#[test] +fn test_escrow_funded_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + let data = last_event_data(&env, &contract_id, "escrow_funded"); + assert_eq!(event_field_count(&env, &data), 4); + assert_field_exists(&env, &data, "from"); + assert_field_exists(&env, &data, "token"); + assert_field_exists(&env, &data, "amount"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_package_created_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + client.create_package( + &admin, + &1u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + + let data = last_event_data(&env, &contract_id, "package_created"); + assert_eq!(event_field_count(&env, &data), 6); + assert_field_exists(&env, &data, "package_id"); + assert_field_exists(&env, &data, "recipient"); + assert_field_exists(&env, &data, "amount"); + assert_field_exists(&env, &data, "token"); + assert_field_exists(&env, &data, "actor"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_package_claimed_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + client.create_package( + &admin, + &0u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + client.claim(&0u64); + + let data = last_event_data(&env, &contract_id, "package_claimed"); + assert_eq!(event_field_count(&env, &data), 6); + assert_field_exists(&env, &data, "package_id"); + assert_field_exists(&env, &data, "recipient"); + assert_field_exists(&env, &data, "amount"); + assert_field_exists(&env, &data, "token"); + assert_field_exists(&env, &data, "actor"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_package_disbursed_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + client.create_package( + &admin, + &0u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + client.disburse(&0u64); + + let data = last_event_data(&env, &contract_id, "package_disbursed"); + assert_eq!(event_field_count(&env, &data), 6); + assert_field_exists(&env, &data, "package_id"); + assert_field_exists(&env, &data, "recipient"); + assert_field_exists(&env, &data, "amount"); + assert_field_exists(&env, &data, "token"); + assert_field_exists(&env, &data, "actor"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_package_revoked_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(5 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + client.create_package( + &admin, + &0u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + client.revoke(&0u64); + + let data = last_event_data(&env, &contract_id, "package_revoked"); + assert_eq!(event_field_count(&env, &data), 6); + assert_field_exists(&env, &data, "package_id"); + assert_field_exists(&env, &data, "recipient"); + assert_field_exists(&env, &data, "amount"); + assert_field_exists(&env, &data, "token"); + assert_field_exists(&env, &data, "actor"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_package_refunded_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(5 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + let expires_at = env.ledger().timestamp() + 100; + client.create_package( + &admin, + &0u64, + &recipient, + &UNIT, + &token_client.address, + &expires_at, + &Map::new(&env), + ); + env.ledger().set_timestamp(expires_at + 1); + client.refund(&0u64); + + let data = last_event_data(&env, &contract_id, "package_refunded"); + assert_eq!(event_field_count(&env, &data), 6); + assert_field_exists(&env, &data, "package_id"); + assert_field_exists(&env, &data, "recipient"); + assert_field_exists(&env, &data, "amount"); + assert_field_exists(&env, &data, "token"); + assert_field_exists(&env, &data, "actor"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_extended_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + let old_expires_at = env.ledger().timestamp() + 86400; + client.create_package( + &admin, + &42u64, + &recipient, + &UNIT, + &token_client.address, + &old_expires_at, + &Map::new(&env), + ); + client.extend_expiry(&42u64, &(old_expires_at + 600)); + + let data = last_event_data(&env, &contract_id, "extended_event"); + assert_eq!(event_field_count(&env, &data), 5); + assert_field_exists(&env, &data, "id"); + assert_field_exists(&env, &data, "admin"); + assert_field_exists(&env, &data, "old_expires_at"); + assert_field_exists(&env, &data, "new_expires_at"); + assert_field_exists(&env, &data, "timestamp"); +} + +// ── No Metadata Leaks ──────────────────────────────────────────────────── + +#[test] +fn test_no_metadata_leak_in_package_created() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + // Create a package WITH metadata to verify it doesn't leak + let mut metadata = Map::new(&env); + metadata.set( + symbol_short!("tag"), + soroban_sdk::String::from_str(&env, "sensitive-info"), + ); + + client.create_package( + &admin, + &1u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &metadata, + ); + + let data = last_event_data(&env, &contract_id, "package_created"); + + // Ensure no metadata-related fields leak into the event + assert_field_absent(&env, &data, "metadata"); + assert_field_absent(&env, &data, "tag"); + assert_field_absent(&env, &data, "merkle_root"); + assert_field_absent(&env, &data, "claim_starts_at"); +} + +#[test] +fn test_no_metadata_leak_in_package_claimed() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + let mut metadata = Map::new(&env); + metadata.set( + symbol_short!("tag"), + soroban_sdk::String::from_str(&env, "private-data"), + ); + + client.create_package( + &admin, + &0u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &metadata, + ); + client.claim(&0u64); + + let data = last_event_data(&env, &contract_id, "package_claimed"); + assert_field_absent(&env, &data, "metadata"); + assert_field_absent(&env, &data, "tag"); + assert_field_absent(&env, &data, "merkle_root"); +} + +// ── Multiple Events in Workflow ────────────────────────────────────────── + +#[test] +fn test_multiple_events_in_workflow() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + client.create_package( + &admin, + &0u64, + &recipient, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + client.claim(&0u64); + + let events = contract_events(&env, &contract_id); + let topics: std::vec::Vec = events + .iter() + .filter_map(|(_, topics, _)| { + topics + .first() + .and_then(|v| Symbol::try_from_val(&env, &v).ok()) + .map(|s| format!("{:?}", s)) + }) + .collect(); + + // Verify ordering: escrow_funded → package_created → package_claimed + assert!( + topics.len() >= 3, + "Expected at least 3 events, got {}", + topics.len() + ); +} + +// ── Multiple Packages Emit Separate Events ─────────────────────────────── + +#[test] +fn test_multiple_packages_separate_events() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + let (token_client, token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + token_admin_client.mint(&admin, &(10 * UNIT)); + client.fund(&token_client.address, &admin, &(5 * UNIT)); + + client.create_package( + &admin, + &0u64, + &r1, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + client.create_package( + &admin, + &1u64, + &r2, + &UNIT, + &token_client.address, + &(env.ledger().timestamp() + 86400), + &Map::new(&env), + ); + + let create_topic = sym(&env, "package_created"); + let create_events: std::vec::Vec<_> = contract_events(&env, &contract_id) + .into_iter() + .filter(|(_, topics, _)| { + topics + .first() + .and_then(|v| Symbol::try_from_val(&env, &v).ok()) + .map(|s| s == create_topic) + .unwrap_or(false) + }) + .collect(); + + assert_eq!(create_events.len(), 2, "Expected 2 package_created events"); +} + +// ── No Events on Failed Operations ─────────────────────────────────────── + +#[test] +fn test_no_events_on_failed_operations() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let (_, _token_admin_client) = setup_token(&env, &admin); + + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + + let events_before = contract_events(&env, &contract_id).len(); + + // Try to claim non-existent package — should fail + let _ = client.try_claim(&999u64); + + let events_after = contract_events(&env, &contract_id).len(); + assert_eq!( + events_before, events_after, + "Failed operations should not emit events" + ); +} + +// ── Pause/Unpause Events ───────────────────────────────────────────────── + +#[test] +fn test_contract_paused_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + + client.pause(); + + let data = last_event_data(&env, &contract_id, "contract_paused_event"); + assert_eq!(event_field_count(&env, &data), 2); + assert_field_exists(&env, &data, "admin"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_contract_unpaused_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + + client.pause(); + client.unpause(); + + let data = last_event_data(&env, &contract_id, "contract_unpaused_event"); + assert_eq!(event_field_count(&env, &data), 2); + assert_field_exists(&env, &data, "admin"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_action_paused_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + + client.pause_action(&symbol_short!("create")); + + let data = last_event_data(&env, &contract_id, "action_paused_event"); + assert_eq!(event_field_count(&env, &data), 3); + assert_field_exists(&env, &data, "admin"); + assert_field_exists(&env, &data, "action"); + assert_field_exists(&env, &data, "timestamp"); +} + +#[test] +fn test_action_unpaused_event_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(AidEscrow, ()); + let client = AidEscrowClient::new(&env, &contract_id); + client.init(&admin); + + client.pause_action(&symbol_short!("create")); + client.unpause_action(&symbol_short!("create")); + + let data = last_event_data(&env, &contract_id, "action_unpaused_event"); + assert_eq!(event_field_count(&env, &data), 3); + assert_field_exists(&env, &data, "admin"); + assert_field_exists(&env, &data, "action"); + assert_field_exists(&env, &data, "timestamp"); +} diff --git a/app/onchain/contracts/aid_escrow/tests/events.rs b/app/onchain/contracts/aid_escrow/tests/events.rs index 08d09ddb..162fb236 100644 --- a/app/onchain/contracts/aid_escrow/tests/events.rs +++ b/app/onchain/contracts/aid_escrow/tests/events.rs @@ -96,6 +96,7 @@ fn test_escrow_funded_event() { let data = last_event_data(&env, &contract_id, "escrow_funded"); assert_eq!(data_address(&env, &data, "from"), admin); assert_eq!(data_i128(&env, &data, "amount"), 5 * UNIT); + assert_field_exists(&env, &data, "token"); assert_field_exists(&env, &data, "timestamp"); } @@ -130,6 +131,7 @@ fn test_package_created_event() { assert_eq!(data_address(&env, &data, "recipient"), recipient); assert_eq!(data_i128(&env, &data, "amount"), UNIT); assert_eq!(data_address(&env, &data, "actor"), admin); + assert_eq!(data_address(&env, &data, "token"), token_client.address); } #[test] @@ -162,6 +164,7 @@ fn test_package_claimed_event() { assert_eq!(data_u64(&env, &data, "package_id"), 0); assert_eq!(data_address(&env, &data, "recipient"), recipient); assert_eq!(data_i128(&env, &data, "amount"), UNIT); + assert_field_exists(&env, &data, "token"); } #[test] @@ -194,6 +197,7 @@ fn test_package_disbursed_event() { assert_eq!(data_u64(&env, &data, "package_id"), 0); assert_eq!(data_address(&env, &data, "recipient"), recipient); assert_eq!(data_i128(&env, &data, "amount"), UNIT); + assert_field_exists(&env, &data, "token"); } #[test] @@ -232,6 +236,7 @@ fn test_package_revoked_event() { assert_eq!(data_address(&env, &data, "recipient"), recipient); assert_eq!(data_i128(&env, &data, "amount"), UNIT); assert_eq!(data_address(&env, &data, "actor"), admin); + assert_field_exists(&env, &data, "token"); } #[test] @@ -266,6 +271,7 @@ fn test_package_refunded_event() { let data = last_event_data(&env, &contract_id, "package_refunded"); assert_eq!(data_u64(&env, &data, "package_id"), 0); assert_eq!(data_i128(&env, &data, "amount"), UNIT); + assert_field_exists(&env, &data, "token"); } #[test] @@ -300,4 +306,5 @@ fn test_extended_event_records_old_and_new_expiry() { assert_eq!(data_u64(&env, &data, "id"), 42); assert_eq!(data_u64(&env, &data, "old_expires_at"), old_expires_at); assert_eq!(data_u64(&env, &data, "new_expires_at"), new_expires_at); + assert_field_exists(&env, &data, "timestamp"); }