diff --git a/CHANGELOG.md b/CHANGELOG.md index 19ac4d0..59ead39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Sharpy smart contract are documented here. ## [Unreleased] +- feat(contract): `get_audit_count(id)` + whitelist audit entries (`wlist`/`wrem`) + `inv_upd` ordering docs; per-path emission proof — feat/invupd-coverage (closes #198) - feat(contract): `is_invoice_terminal(id)` view + ttl-hint terminal/expired-mirror coverage (countdown, Released/Refunded/Cancelled zeros, extend revives) — feat/ttl-terminal (closes #197) - feat(contract): `get_creator_invoice_total` + `get_payer_invoice_total` O(1) totals; empty-page/total consistency proof — feat/paged-totals (closes #196) - feat(contract): `get_fee_bps()` rate view + preview-vs-invoice consistency proof (emitting vs silent share math, release untouched) — feat/fee-consistency (closes #195) diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index 44960fc..dfb0350 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -202,6 +202,8 @@ pub struct InvoiceUpdatedEvent { } /// Emits the `inv_upd` event. Topic: `("inv_upd",)`. +/// Ordering: field-specific events (`tags`, `memo`, `imeta`, `disc`, `wlist`, +/// `ext_dead`) fire FIRST, `inv_upd` follows for single-topic invalidation. /// Fired on any state-mutating invoice update: freeze/unfreeze, notes, tags, /// memo, metadata, discount, whitelist, and deadline extension. Always emitted /// AFTER the field-specific event so per-field subscribers see their event first. diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index ed4bdd4..f6d46bb 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -695,6 +695,13 @@ impl SharpyContract { load_invoice(&env, invoice_id) } + /// Number of audit entries for `invoice_id` (0 when none). + /// Pairs with `get_audit_log`: cheap length check for emission-coverage + /// assertions — every mutator that appends an audit entry bumps this count. + pub fn get_audit_count(env: Env, invoice_id: u64) -> u32 { + env.storage().persistent().get::<(Symbol,u64), Vec>(&audit_log_key(invoice_id)).map(|v| v.len()).unwrap_or(0) + } + pub fn get_audit_log(env: Env, invoice_id: u64) -> Vec { env.storage().persistent().get(&audit_log_key(invoice_id)).unwrap_or_else(|| Vec::new(&env)) } @@ -1540,6 +1547,7 @@ impl SharpyContract { payers, updated_at: env.ledger().timestamp(), }); + append_audit(&env, invoice_id, symbol_short!("wlist"), &caller); events::whitelist_set(&env, invoice_id, count); events::invoice_updated(&env, invoice_id, &caller); } @@ -1561,6 +1569,7 @@ impl SharpyContract { state.updated_at = env.ledger().timestamp(); let count = state.payers.len(); env.storage().persistent().set(&whitelist_key(invoice_id), &state); + append_audit(&env, invoice_id, symbol_short!("wlist"), &caller); events::whitelist_set(&env, invoice_id, count); events::invoice_updated(&env, invoice_id, &caller); } @@ -1581,6 +1590,7 @@ impl SharpyContract { state.payers = kept; state.updated_at = env.ledger().timestamp(); env.storage().persistent().set(&whitelist_key(invoice_id), &state); + append_audit(&env, invoice_id, symbol_short!("wrem"), &caller); events::whitelist_payer_removed(&env, invoice_id, &rc); events::invoice_updated(&env, invoice_id, &caller); } diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index e00436c..fdd4c0e 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -6161,3 +6161,137 @@ mod test_ttl_terminal_c { assert!(!client.is_invoice_expired(&id)); } } + +#[cfg(test)] +mod test_invupd_a { + use soroban_sdk::{testutils::Address as _, Address, Env, Vec, String}; + use crate::SharpyContractClient; + fn setup() -> (Env, SharpyContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(crate::SharpyContract, ()); + let c = SharpyContractClient::new(&env, &cid); + let a = Address::generate(&env); + let t = Address::generate(&env); + c.initialize(&a, &t); + (env, c) + } + fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 { + let r = Address::generate(env); + let tok = Address::generate(env); + let dl = env.ledger().timestamp() + 86400; + let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(env), auto_resolve_rules: Vec::new(env), arbitrator: None }; + client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts) + } + #[test] + fn test_tags_path_writes_state_and_audit() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + let before = client.get_audit_count(&id); + client.set_invoice_tags(&creator, &id, &Vec::from_array(&env, [String::from_str(&env, "alpha")])); + assert_eq!(client.get_invoice_tags(&id).unwrap().tags.len(), 1u32); + assert_eq!(client.get_audit_count(&id), before + 1); + } + #[test] + fn test_notes_path_writes_state_and_audit() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + let before = client.get_audit_count(&id); + client.set_invoice_notes(&creator, &id, &String::from_str(&env, "hello")); + assert!(client.get_invoice_notes(&id).is_some()); + assert_eq!(client.get_audit_count(&id), before + 1); + } +} + +#[cfg(test)] +mod test_invupd_b { + use soroban_sdk::{testutils::Address as _, Address, Env, Vec, String}; + use crate::SharpyContractClient; + fn setup() -> (Env, SharpyContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(crate::SharpyContract, ()); + let c = SharpyContractClient::new(&env, &cid); + let a = Address::generate(&env); + let t = Address::generate(&env); + c.initialize(&a, &t); + (env, c) + } + fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 { + let r = Address::generate(env); + let tok = Address::generate(env); + let dl = env.ledger().timestamp() + 86400; + let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(env), auto_resolve_rules: Vec::new(env), arbitrator: None }; + client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts) + } + #[test] + fn test_memo_path_writes_state_and_audit() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + let before = client.get_audit_count(&id); + client.set_invoice_memo_ext(&creator, &id, &String::from_str(&env, "memo-1")); + assert!(client.get_invoice_memo_ext(&id).is_some()); + assert_eq!(client.get_audit_count(&id), before + 1); + } + #[test] + fn test_metadata_path_writes_state_and_audit() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + let before = client.get_audit_count(&id); + client.set_invoice_metadata(&creator, &id, &Vec::from_array(&env, [String::from_str(&env, "k:v")])); + assert!(client.get_invoice_metadata(&id).is_some()); + assert_eq!(client.get_audit_count(&id), before + 1); + } +} + +#[cfg(test)] +mod test_invupd_c { + use soroban_sdk::{testutils::Address as _, Address, Env, Vec}; + use crate::SharpyContractClient; + fn setup() -> (Env, SharpyContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(crate::SharpyContract, ()); + let c = SharpyContractClient::new(&env, &cid); + let a = Address::generate(&env); + let t = Address::generate(&env); + c.initialize(&a, &t); + (env, c) + } + fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 { + let r = Address::generate(env); + let tok = Address::generate(env); + let dl = env.ledger().timestamp() + 86400; + let opts = crate::types::InvoiceOptions { escrow_enabled: false, escrow_release_delay: None, split_rules: Vec::new(env), auto_resolve_rules: Vec::new(env), arbitrator: None }; + client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [100i128]), &Vec::from_array(env, [tok]), &dl, &opts) + } + #[test] + fn test_discount_and_deadline_paths_audit() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + let b0 = client.get_audit_count(&id); + client.set_discount(&creator, &id, &500u32); + assert_eq!(client.get_discount(&id).unwrap().discount_bps, 500u32); + assert_eq!(client.get_audit_count(&id), b0 + 1); + let b1 = client.get_audit_count(&id); + client.extend_deadline(&creator, &id, &(env.ledger().timestamp() + 99999)); + assert_eq!(client.get_audit_count(&id), b1 + 1); + } + #[test] + fn test_whitelist_mutations_bump_audit() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let id = mk(&env, &client, &creator); + let b0 = client.get_audit_count(&id); + client.set_whitelist(&creator, &id, &Vec::from_array(&env, [payer.clone()])); + assert_eq!(client.get_audit_count(&id), b0 + 1); + client.remove_whitelisted_payer(&creator, &id, &payer); + assert_eq!(client.get_whitelist(&id).unwrap().payers.len(), 0u32); + } +}