diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fc806e0..0dc433c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -153,3 +153,24 @@ All payout math uses `checked_mul`/`checked_div`/`checked_add`/`checked_sub` to - `contracts/sharpy/src/events.rs` — typed event helpers - `contracts/sharpy/src/types.rs` — `Invoice`, `SplitRule`, `DisputeState`, etc. - `contracts/sharpy/src/test.rs` — 184 unit/integration tests + +## Auth Matrix (audit harness, closes #199) + +| Entry | Auth | Rationale | +|-------|------|-----------| +| `initialize` | once, no auth | sets admin/treasury singletons | +| `pause`/`unpause` | admin | circuit breaker | +| `create_*` | creator | creator-indexed | +| `pay`/`pool_pay`/`pay_with_tip` | payer + whitelist | sequential guard prevents double-spend | +| `release`/`refund`/`refund_batch` | permissionless | deadline/status gates; payouts fixed to stored parties | +| `release_escrow`/`dispute`/`resolve` | creator/arbitrator | escrow state gates | +| `cancel`/`freeze`/`set_*`/`extend`/`archive` | creator (freeze admin) | mutators append audit + `inv_upd` | +| `claim` | permissionless | CEI: remove before transfer | +| `create_stream`/`withdraw`/`top_up`/`cancel_stream` | permissionless (documented) | scratch state keyed by id; future major may gate | +| `set_route` | any authed caller | repoint allowed; self/2-cycle panic; bounded resolve | + +## CEI Checklist + +- `claim`: checks balance>0, effects remove, interactions transfer last. +- `_release`: status assert first, distribution via try_transfer+fallback, status write after. +- `_refund_payers`: aggregation then transfers; status transition makes retry panic. diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ead39..c32796e 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_funding_remaining(id)` invariant view + auth-matrix/CEI docs; double-release/refund/claim + remaining guards — feat/audit-harness (closes #199) - 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) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index f6d46bb..d411d6e 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -695,6 +695,15 @@ impl SharpyContract { load_invoice(&env, invoice_id) } + /// Remaining fundable amount (`total - funded`), floored at 0. Pure view. + /// Invariant harness: `funded + remaining == total` and `funded <= total` + /// always; sequential pays never exceed the remainder (see `pay` guard). + pub fn get_funding_remaining(env: Env, invoice_id: u64) -> i128 { + let inv = load_invoice(&env, invoice_id); + let total: i128 = inv.amounts.iter().sum(); + total.checked_sub(inv.funded).expect("remaining: underflow").max(0i128) + } + /// 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. diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index fdd4c0e..5562c9a 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -6295,3 +6295,139 @@ mod test_invupd_c { assert_eq!(client.get_whitelist(&id).unwrap().payers.len(), 0u32); } } + +#[cfg(test)] +mod test_invariant_a { + use soroban_sdk::{testutils::Address as _, token, 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) + } + #[test] + fn test_funded_plus_remaining_equals_total() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract(admin); + token::StellarAssetClient::new(&env, &tok).mint(&payer, &5000i128); + let r = 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 }; + let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok]), &dl, &opts); + assert_eq!(client.get_funding_remaining(&id), 1000i128); + client.pay(&payer, &id, &400i128); + assert_eq!(client.get_funding_remaining(&id), 600i128); + assert_eq!(client.get_invoice(&id).funded + client.get_funding_remaining(&id), 1000i128); + } + #[test] + #[should_panic(expected = "payment exceeds remaining balance")] + fn test_overpay_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract(admin); + token::StellarAssetClient::new(&env, &tok).mint(&payer, &5000i128); + let r = 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 }; + let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [500i128]), &Vec::from_array(&env, [tok]), &dl, &opts); + client.pay(&payer, &id, &400i128); + client.pay(&payer, &id, &200i128); + } +} + +#[cfg(test)] +mod test_invariant_b { + use soroban_sdk::{testutils::Address as _, token, 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) + } + #[test] + #[should_panic(expected = "invoice is not pending")] + fn test_double_release_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract(admin); + token::StellarAssetClient::new(&env, &tok).mint(&payer, &5000i128); + let r = 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 }; + let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [500i128]), &Vec::from_array(&env, [tok]), &dl, &opts); + client.pay(&payer, &id, &500i128); + client.release(&id); + } + #[test] + #[should_panic(expected = "no claimable balance")] + fn test_double_claim_panics() { + let (env, client) = setup(); + let who = Address::generate(&env); + let tok = Address::generate(&env); + client.claim(&who, &tok); + } +} + +#[cfg(test)] +mod test_invariant_c { + use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, token, 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) + } + #[test] + #[should_panic(expected = "invoice is not pending")] + fn test_double_refund_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let r = Address::generate(&env); + let tok = Address::generate(&env); + let now = env.ledger().timestamp(); + 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 }; + let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [100i128]), &Vec::from_array(&env, [tok]), &(now + 10), &opts); + env.ledger().set_timestamp(now + 11); + client.refund(&id); + client.refund(&id); + } + #[test] + fn test_sequential_pays_respect_remaining() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract(admin); + token::StellarAssetClient::new(&env, &tok).mint(&payer, &5000i128); + let r = 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 }; + let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [1000i128]), &Vec::from_array(&env, [tok]), &dl, &opts); + client.pay(&payer, &id, &600i128); + client.pay(&payer, &id, &400i128); + assert_eq!(client.get_funding_remaining(&id), 0i128); + assert!(client.is_invoice_terminal(&id)); + } +}