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
21 changes: 21 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
136 changes: 136 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading