diff --git a/remittance_split/src/lib.rs b/remittance_split/src/lib.rs index f7eaf653..00e5ad0e 100644 --- a/remittance_split/src/lib.rs +++ b/remittance_split/src/lib.rs @@ -145,6 +145,21 @@ pub struct AuditEntry { pub success: bool, } +/// Paginated result for audit log queries. +/// +/// Provides stable cursor-based pagination so consumers can replay the log +/// without gaps or duplicates across page boundaries. +#[contracttype] +#[derive(Clone)] +pub struct AuditPage { + /// Audit entries for this page, ordered oldest-to-newest. + pub items: Vec, + /// Index to pass as `from_index` for the next page. 0 means no more pages. + pub next_cursor: u32, + /// Number of items returned in this page. + pub count: u32, +} + /// Schedule for automatic remittance splits #[contracttype] #[derive(Clone)] @@ -177,6 +192,8 @@ const SCHEMA_VERSION: u32 = 2; /// Oldest snapshot schema version this contract can import. Enables backward compat. const MIN_SUPPORTED_SCHEMA_VERSION: u32 = 2; const MAX_AUDIT_ENTRIES: u32 = 100; +const DEFAULT_PAGE_LIMIT: u32 = 20; +const MAX_PAGE_LIMIT: u32 = 50; const CONTRACT_VERSION: u32 = 1; #[contracttype] @@ -1025,22 +1042,49 @@ impl RemittanceSplit { Ok(true) } - pub fn get_audit_log(env: Env, from_index: u32, limit: u32) -> Vec { + /// Return a page of audit log entries with a stable cursor. + /// + /// # Parameters + /// - `from_index`: zero-based starting index (pass 0 for the first page, + /// then use the returned `next_cursor` for subsequent pages). + /// - `limit`: maximum entries to return; clamped to `[DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT]`. + /// + /// # Pagination contract + /// - Entries are returned oldest-to-newest within the rotating log window. + /// - `next_cursor == 0` signals no more pages. + /// - Uses saturating arithmetic so a caller cannot trigger overflow panics. + /// - Deterministic: identical `(from_index, limit)` on identical state always + /// returns the same page, enabling reliable replay by audit consumers. + pub fn get_audit_log(env: Env, from_index: u32, limit: u32) -> AuditPage { let log: Option> = env.storage().instance().get(&symbol_short!("AUDIT")); let log = log.unwrap_or_else(|| Vec::new(&env)); let len = log.len(); - let cap = MAX_AUDIT_ENTRIES.min(limit); - let mut out = Vec::new(&env); + let cap = clamp_limit(limit); + if from_index >= len { - return out; + return AuditPage { + items: Vec::new(&env), + next_cursor: 0, + count: 0, + }; } - let end = (from_index + cap).min(len); + + let end = from_index.saturating_add(cap).min(len); + let mut items = Vec::new(&env); for i in from_index..end { if let Some(entry) = log.get(i) { - out.push_back(entry); + items.push_back(entry); } } - out + + let count = items.len(); + let next_cursor = if end < len { end } else { 0 }; + + AuditPage { + items, + next_cursor, + count, + } } fn require_nonce( diff --git a/remittance_split/src/test.rs b/remittance_split/src/test.rs index f5a69751..d90cb491 100644 --- a/remittance_split/src/test.rs +++ b/remittance_split/src/test.rs @@ -1293,3 +1293,210 @@ fn test_import_snapshot_unauthorized_caller_rejected() { "non-owner must not import snapshot" ); } + +// --------------------------------------------------------------------------- +// Audit log pagination +// --------------------------------------------------------------------------- + +/// Helper: initialize + update N times to seed the audit log with entries. +/// Each initialize produces 1 entry, each update produces 1 entry. +/// Returns (client, owner) for further assertions. +fn seed_audit_log( + env: &Env, + count: u32, +) -> (RemittanceSplitClient<'_>, Address) { + let contract_id = env.register_contract(None, RemittanceSplit); + let client = RemittanceSplitClient::new(env, &contract_id); + let owner = Address::generate(env); + let token_admin = Address::generate(env); + let token_id = setup_token(env, &token_admin, &owner, 0); + + // initialize_split appends 1 audit entry on success (nonce 0 → 1) + client.initialize_split(&owner, &0, &token_id, &25, &25, &25, &25); + + // import_snapshot appends 1 audit entry on success and increments nonce. + // Use repeated import_snapshot calls to seed additional entries. + for nonce in 1..count as u64 { + let snapshot = client.export_snapshot(&owner).unwrap(); + client.import_snapshot(&owner, &nonce, &snapshot); + } + + (client, owner) +} + +/// Collect every audit entry by following next_cursor until it returns 0. +fn collect_all_pages(client: &RemittanceSplitClient, page_size: u32) -> soroban_sdk::Vec { + let env = client.env.clone(); + let mut all = soroban_sdk::Vec::new(&env); + let mut cursor: u32 = 0; + let mut first = true; + loop { + let page = client.get_audit_log(&cursor, &page_size); + if page.count == 0 { + break; + } + for i in 0..page.items.len() { + if let Some(entry) = page.items.get(i) { + all.push_back(entry); + } + } + if page.next_cursor == 0 { + break; + } + if !first && cursor == page.next_cursor { + panic!("cursor did not advance — infinite loop detected"); + } + first = false; + cursor = page.next_cursor; + } + all +} + +#[test] +fn test_get_audit_log_empty_returns_zero_cursor() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, RemittanceSplit); + let client = RemittanceSplitClient::new(&env, &contract_id); + + let page = client.get_audit_log(&0, &10); + assert_eq!(page.count, 0); + assert_eq!(page.next_cursor, 0); + assert_eq!(page.items.len(), 0); +} + +#[test] +fn test_get_audit_log_single_page() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _owner) = seed_audit_log(&env, 3); + + // Request all 3 with a large limit + let page = client.get_audit_log(&0, &50); + assert_eq!(page.count, 3); + assert_eq!(page.next_cursor, 0, "no more pages"); +} + +#[test] +fn test_get_audit_log_multi_page_no_gaps_no_duplicates() { + let env = Env::default(); + env.mock_all_auths(); + let entry_count: u32 = 15; + let (client, _owner) = seed_audit_log(&env, entry_count); + + // Paginate with page_size = 4 → expect 4 pages (4+4+4+3) + let all = collect_all_pages(&client, 4); + assert_eq!( + all.len(), + entry_count, + "total entries collected must equal entries seeded" + ); + + // Verify strict timestamp ordering (no duplicates, no gaps) + for i in 1..all.len() { + let prev = all.get(i - 1).unwrap(); + let curr = all.get(i).unwrap(); + assert!( + curr.timestamp >= prev.timestamp, + "entries must be ordered by timestamp" + ); + } +} + +#[test] +fn test_get_audit_log_cursor_boundaries_and_limits() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _owner) = seed_audit_log(&env, 10); + + // First page: 5 items + let p1 = client.get_audit_log(&0, &5); + assert_eq!(p1.count, 5); + assert_eq!(p1.next_cursor, 5); + + // Second page: 5 items + let p2 = client.get_audit_log(&p1.next_cursor, &5); + assert_eq!(p2.count, 5); + assert_eq!(p2.next_cursor, 0, "exactly at end → no more pages"); + + // Out-of-range cursor + let p3 = client.get_audit_log(&100, &5); + assert_eq!(p3.count, 0); + assert_eq!(p3.next_cursor, 0); +} + +#[test] +fn test_get_audit_log_limit_zero_uses_default() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _owner) = seed_audit_log(&env, 5); + + // limit=0 should clamp to DEFAULT_PAGE_LIMIT (20), returning all 5 + let page = client.get_audit_log(&0, &0); + assert_eq!(page.count, 5); + assert_eq!(page.next_cursor, 0); +} + +#[test] +fn test_get_audit_log_large_cursor_does_not_overflow_or_duplicate() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _owner) = seed_audit_log(&env, 5); + + // u32::MAX cursor must not panic from overflow + let page = client.get_audit_log(&u32::MAX, &50); + assert_eq!(page.count, 0); + assert_eq!(page.next_cursor, 0); +} + +#[test] +fn test_get_audit_log_limit_clamped_to_max_page_limit() { + let env = Env::default(); + env.mock_all_auths(); + // Seed 30 entries; request with limit > MAX_PAGE_LIMIT (50) + let (client, _owner) = seed_audit_log(&env, 30); + + // limit=200 should clamp to MAX_PAGE_LIMIT=50, but we only have 30 + let page = client.get_audit_log(&0, &200); + assert_eq!(page.count, 30); + assert_eq!(page.next_cursor, 0, "all entries fit in one clamped page"); + + // Verify clamping with a smaller set: request 5, get 5, more remain + let p1 = client.get_audit_log(&0, &5); + assert_eq!(p1.count, 5); + assert!(p1.next_cursor > 0, "more pages remain"); +} + +#[test] +fn test_get_audit_log_deterministic_replay() { + let env = Env::default(); + env.mock_all_auths(); + let entry_count: u32 = 10; + let (client, _owner) = seed_audit_log(&env, entry_count); + + let all = collect_all_pages(&client, 3); + assert_eq!(all.len(), entry_count); + + // Verify deterministic replay: same query returns same results + let replay = collect_all_pages(&client, 3); + assert_eq!(all.len(), replay.len()); + for i in 0..all.len() { + let a = all.get(i).unwrap(); + let b = replay.get(i).unwrap(); + assert_eq!(a.timestamp, b.timestamp); + assert_eq!(a.operation, b.operation); + assert_eq!(a.caller, b.caller); + assert_eq!(a.success, b.success); + } +} + +#[test] +fn test_get_audit_log_page_size_one_walks_entire_log() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _owner) = seed_audit_log(&env, 8); + + // Walk with page_size=1 to stress cursor advancement + let all = collect_all_pages(&client, 1); + assert_eq!(all.len(), 8); +}