From 259f0555c3ace60140e7a44ad44341b29c632515 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:05:26 +0100 Subject: [PATCH 1/6] feat(contract): add get_tranche_remaining_bps view --- contracts/sharpy/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 51464fb..03befb5 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1436,6 +1436,14 @@ impl SharpyContract { cumulative } + /// Remaining releasable basis points (`10000 - released_bps`); 10000 when untouched. + /// Pure view: `released + remaining == 10000` always. Poll before submitting + /// `release_tranche` to avoid over-release panics. + pub fn get_tranche_remaining_bps(env: Env, invoice_id: u64) -> u32 { + let released: u32 = env.storage().persistent().get::<(Symbol,u64), TrancheState>(&tranche_key(invoice_id)).map(|s| s.released_bps).unwrap_or(0); + 10_000u32.checked_sub(released).expect("tranche: underflow in remaining") + } + /// Cumulative released basis points for `invoice_id` (0 when untouched). pub fn get_released_bps(env: Env, invoice_id: u64) -> u32 { env.storage().persistent().get::<(Symbol,u64), TrancheState>(&tranche_key(invoice_id)).map(|s| s.released_bps).unwrap_or(0) From 9fac89455d48100388731b0c1f64ba669ee2e37e Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:05:26 +0100 Subject: [PATCH 2/6] docs(contract): document tranche released+remaining invariant --- contracts/sharpy/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 03befb5..76cf63d 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1445,6 +1445,7 @@ impl SharpyContract { } /// Cumulative released basis points for `invoice_id` (0 when untouched). + /// Invariant: `get_released_bps + get_tranche_remaining_bps == 10000`. pub fn get_released_bps(env: Env, invoice_id: u64) -> u32 { env.storage().persistent().get::<(Symbol,u64), TrancheState>(&tranche_key(invoice_id)).map(|s| s.released_bps).unwrap_or(0) } From 655e61f0f9bb820e42c8fc30c5ed6f8ddce8f833 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:05:34 +0100 Subject: [PATCH 3/6] test(contract): add tranche remaining start and partial-sum cases --- contracts/sharpy/src/test.rs | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 5818657..9e8437d 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5492,3 +5492,44 @@ mod test_route_chain_c { assert_eq!(client.resolve_route_chain(&a, &5u32), c); } } + +#[cfg(test)] +mod test_tranche_invariant_a { + 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_remaining_starts_full() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + assert_eq!(client.get_released_bps(&id), 0u32); + assert_eq!(client.get_tranche_remaining_bps(&id), 10_000u32); + } + #[test] + fn test_partial_sums_to_full() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + assert_eq!(client.release_tranche(&creator, &id, &3000u32), 3000u32); + assert_eq!(client.get_tranche_remaining_bps(&id), 7000u32); + assert_eq!(client.release_tranche(&creator, &id, &7000u32), 10_000u32); + assert_eq!(client.get_tranche_remaining_bps(&id), 0u32); + } +} From ae0308c350c1ca22a148b1f8bef5ff06f12a62b0 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:05:34 +0100 Subject: [PATCH 4/6] test(contract): add tranche over-release and small-step accumulation --- contracts/sharpy/src/test.rs | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 9e8437d..74d5981 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5533,3 +5533,47 @@ mod test_tranche_invariant_a { assert_eq!(client.get_tranche_remaining_bps(&id), 0u32); } } + +#[cfg(test)] +mod test_tranche_invariant_b { + 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] + #[should_panic(expected = "tranches exceed 100%")] + fn test_over_release_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + client.release_tranche(&creator, &id, &6000u32); + client.release_tranche(&creator, &id, &5000u32); + } + #[test] + fn test_small_steps_accumulate_exactly() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + let mut cum = 0u32; + for _ in 0..10 { + cum = client.release_tranche(&creator, &id, &1000u32); + } + assert_eq!(cum, 10_000u32); + assert_eq!(client.get_released_bps(&id) + client.get_tranche_remaining_bps(&id), 10_000u32); + } +} From 30a5471e0c6ae49fa38f8fad333033f85edee0bf Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:05:38 +0100 Subject: [PATCH 5/6] test(contract): add tranche isolation and creator-auth cases --- contracts/sharpy/src/test.rs | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 74d5981..faae5c2 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5577,3 +5577,47 @@ mod test_tranche_invariant_b { assert_eq!(client.get_released_bps(&id) + client.get_tranche_remaining_bps(&id), 10_000u32); } } + +#[cfg(test)] +mod test_tranche_invariant_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_tranche_isolated_per_invoice() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let a = mk(&env, &client, &creator); + let b = mk(&env, &client, &creator); + client.release_tranche(&creator, &a, &2500u32); + assert_eq!(client.get_released_bps(&a), 2500u32); + assert_eq!(client.get_tranche_remaining_bps(&a), 7500u32); + assert_eq!(client.get_released_bps(&b), 0u32); + assert_eq!(client.get_tranche_remaining_bps(&b), 10_000u32); + } + #[test] + #[should_panic(expected = "only creator can release tranches")] + fn test_non_creator_tranche_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let other = Address::generate(&env); + let id = mk(&env, &client, &creator); + client.release_tranche(&other, &id, &100u32); + } +} From 1b1f5f5e8b080f40ca9dd6a440c99d330a140488 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:06:08 +0100 Subject: [PATCH 6/6] docs(changelog): record tranche remaining view for #193 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e2c0a..353c6fb 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_tranche_remaining_bps(id)` view + cumulative-bps invariant (`released + remaining == 10000`) — feat/tranche-remaining (closes #193) - feat(contract): `resolve_route_chain(id, max_depth)` bounded follower + `get_route_chain_len(id)` cycle-safe depth view — feat/route-chain-depth (closes #192) - feat(contract): `get_stream_state(id)` + `preview_vested(id)` pure views; `withdraw_vested` repeat-withdraw idempotency fix (`total - vested`), cancel edge docs — feat/stream-vest-preview (closes #191) - feat(events): invoice_updated (`inv_upd`) now emitted on `set_discount` and all whitelist mutations (`set/add/remove`), after the field-specific event; `is_invoice_expired(id)` read-only helper mirrors the `refund`/`refund_batch` expiry trigger — feat/event-taxonomy (closes #183)