From 6ed35e5b98c1dbbd4627bce6255af54438948592 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:16:02 +0100 Subject: [PATCH 1/6] feat(contract): add get_funding_remaining invariant view --- contracts/sharpy/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) 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. From ca109eebb2951a5608458f5c0425aa0907442ecd Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:16:02 +0100 Subject: [PATCH 2/6] docs(arch): add auth matrix and CEI checklist --- ARCHITECTURE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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. From 61d9c3e878ab89766fc731ec31a7d61af8460e78 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:16:08 +0100 Subject: [PATCH 3/6] test(contract): add funding invariant and overpay guard --- contracts/sharpy/src/test.rs | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index fdd4c0e..0dd0418 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -6295,3 +6295,52 @@ 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); + } +} From 5ef1761e53b6be6fd109d6d0b2cd545c5726e3e7 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:16:08 +0100 Subject: [PATCH 4/6] test(contract): add double-release and double-claim guards --- contracts/sharpy/src/test.rs | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 0dd0418..869d9c9 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -6344,3 +6344,43 @@ mod test_invariant_a { 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); + } +} From 6509c54d80fcb5dac1ef31c24c44b66cb020ab5f Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:16:13 +0100 Subject: [PATCH 5/6] test(contract): add double-refund and sequential-pay invariants --- contracts/sharpy/src/test.rs | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 869d9c9..5562c9a 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -6384,3 +6384,50 @@ mod test_invariant_b { 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)); + } +} From da34b3190bf695ea98e642f71231ffa29962d1d8 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:16:40 +0100 Subject: [PATCH 6/6] docs(changelog): record audit harness for #199 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)