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
58 changes: 51 additions & 7 deletions remittance_split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuditEntry>,
/// 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)]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -1025,22 +1042,49 @@ impl RemittanceSplit {
Ok(true)
}

pub fn get_audit_log(env: Env, from_index: u32, limit: u32) -> Vec<AuditEntry> {
/// 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<Vec<AuditEntry>> = 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(
Expand Down
207 changes: 207 additions & 0 deletions remittance_split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuditEntry> {
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);
}
Loading