From 73565743cb9d86b128a7bc5ac570e9d731e2ec80 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:30:56 +0100 Subject: [PATCH 01/10] feat(types): add TrancheState for cumulative bps tracking --- contracts/sharpy/src/types.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contracts/sharpy/src/types.rs b/contracts/sharpy/src/types.rs index 167fb68..4275a72 100644 --- a/contracts/sharpy/src/types.rs +++ b/contracts/sharpy/src/types.rs @@ -335,3 +335,11 @@ pub struct ComposableRoute { pub target_invoice: u64, pub updated_at: u64, } + +/// Cumulative tranche-release accounting per invoice (basis points of 10_000). +#[contracttype] +#[derive(Clone, Debug)] +pub struct TrancheState { + pub released_bps: u32, + pub updated_at: u64, +} From b95da00ba5188d1b8d1fd04c11c5a72b055b5fae Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:31:05 +0100 Subject: [PATCH 02/10] feat(events): add tranche_released event --- contracts/sharpy/src/events.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index 2970673..3519d1a 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -349,3 +349,10 @@ pub struct RouteResolvedEvent { pub invoice_id: u64, pub target_invoice: u64 } pub fn route_resolved(env: &Env, invoice_id: u64, target_invoice: u64) { env.events().publish((symbol_short!("rslv"),), RouteResolvedEvent { invoice_id, target_invoice }); } + +#[contracttype] +#[derive(Clone)] +pub struct TrancheReleasedEvent { pub invoice_id: u64, pub bps: u32, pub cumulative_bps: u32 } +pub fn tranche_released(env: &Env, invoice_id: u64, bps: u32, cumulative_bps: u32) { + env.events().publish((symbol_short!("tranch"),), TrancheReleasedEvent { invoice_id, bps, cumulative_bps }); +} From 48f7c5d81909ff27b3e60e1de9e04461b90dd8bd Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:32:08 +0100 Subject: [PATCH 03/10] feat(contract): add release_tranche with cumulative bps accounting --- contracts/sharpy/src/lib.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 102b1f0..b331290 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -22,7 +22,7 @@ mod test; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Bytes, Env, Map, String, Symbol, Vec}; use types::{ AuditEntry, CreateInvoiceParams, DisputeState, Invoice, InvoiceNotes, InvoiceOptions, - InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, SplitRule, StreamingState, ComposableRoute, + InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, SplitRule, StreamingState, ComposableRoute, TrancheState, SubscriptionParams, }; @@ -51,6 +51,7 @@ fn invoice_metadata_key(id: u64) -> (Symbol, u64) { (symbol_short!("imeta"), id) fn invoice_memo_ext_key(id: u64) -> (Symbol, u64) { (symbol_short!("imemo"), id) } fn streaming_key(id: u64) -> (Symbol, u64) { (symbol_short!("strm"), id) } fn route_key(id: u64) -> (Symbol, u64) { (symbol_short!("route"), id) } +fn tranche_key(id: u64) -> (Symbol, u64) { (symbol_short!("tranche"), id) } fn is_paused(env: &Env) -> bool { env.storage().persistent().get(&paused_key()).unwrap_or(false) @@ -1267,6 +1268,19 @@ impl SharpyContract { invoice_id } } + + /// Release a tranche of `bps` basis points; returns cumulative released bps. + pub fn release_tranche(env: Env, caller: Address, invoice_id: u64, bps: u32) -> u32 { + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + assert!(invoice.creator == caller, "only creator can release tranches"); + let key = tranche_key(invoice_id); + let prior: u32 = env.storage().persistent().get::<(Symbol,u64), TrancheState>(&key).map(|s| s.released_bps).unwrap_or(0); + let cumulative = prior + bps; + env.storage().persistent().set(&key, &TrancheState { released_bps: cumulative, updated_at: env.ledger().timestamp() }); + events::tranche_released(&env, invoice_id, bps, cumulative); + cumulative + } } /// Validates that a token address is not the zero address. From 3defa79064692a3bd312ad1658ecd868a25a2643 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:32:39 +0100 Subject: [PATCH 04/10] feat(contract): add get_released_bps query --- contracts/sharpy/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index b331290..ac99f7a 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1281,6 +1281,11 @@ impl SharpyContract { events::tranche_released(&env, invoice_id, bps, cumulative); cumulative } + + /// 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) + } } /// Validates that a token address is not the zero address. From 7a2db016194f570576a988e684f5a749f4efabbf Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:33:10 +0100 Subject: [PATCH 05/10] feat(contract): guard tranche bps range and 100% cumulative cap --- contracts/sharpy/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index ac99f7a..8fe7afe 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1274,9 +1274,11 @@ impl SharpyContract { caller.require_auth(); let invoice = load_invoice(&env, invoice_id); assert!(invoice.creator == caller, "only creator can release tranches"); + assert!(bps > 0 && bps <= 10_000, "bps out of range"); let key = tranche_key(invoice_id); let prior: u32 = env.storage().persistent().get::<(Symbol,u64), TrancheState>(&key).map(|s| s.released_bps).unwrap_or(0); let cumulative = prior + bps; + assert!(cumulative <= 10_000, "tranches exceed 100%"); env.storage().persistent().set(&key, &TrancheState { released_bps: cumulative, updated_at: env.ledger().timestamp() }); events::tranche_released(&env, invoice_id, bps, cumulative); cumulative From 62c92d11e257d37dea75353ea4eaf921e24ba968 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:33:30 +0100 Subject: [PATCH 06/10] test: add tranche partial-then-full release test --- 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 5b99ffb..850ff7e 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -3976,3 +3976,52 @@ mod test_routing { } } +#[cfg(test)] +mod test_tranche { + 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 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 r = Address::generate(env); + let tok = Address::generate(env); + let dl = env.ledger().timestamp() + 86400; + client.create_invoice( + creator, + &Vec::from_array(env, [r]), + &Vec::from_array(env, [1000i128]), + &Vec::from_array(env, [tok]), + &dl, + &opts, + ) + } + + #[test] + fn test_tranche_partial_then_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.release_tranche(&creator, &id, &2500u32), 2500u32); + assert_eq!(client.release_tranche(&creator, &id, &7500u32), 10_000u32); + assert_eq!(client.get_released_bps(&id), 10_000u32); + } +} + From 383ff6941246076d75d2ba8c9ef1c44da77f5447 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:33:50 +0100 Subject: [PATCH 07/10] test: add tranche over-release and zero-bps panic tests --- contracts/sharpy/src/test.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 850ff7e..75fc349 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -4023,5 +4023,24 @@ mod test_tranche { assert_eq!(client.release_tranche(&creator, &id, &7500u32), 10_000u32); assert_eq!(client.get_released_bps(&id), 10_000u32); } + + #[test] + #[should_panic(expected = "tranches exceed 100%")] + fn test_tranche_over_release_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + client.release_tranche(&creator, &id, &9000u32); + client.release_tranche(&creator, &id, &2000u32); + } + + #[test] + #[should_panic(expected = "bps out of range")] + fn test_tranche_zero_bps_panics() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id = mk(&env, &client, &creator); + client.release_tranche(&creator, &id, &0u32); + } } From 09ca668ed2b8f096442c22ef82c995efe31f8848 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:34:29 +0100 Subject: [PATCH 08/10] test: add tranche isolation per invoice test --- contracts/sharpy/src/test.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 75fc349..742b8c3 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -4042,5 +4042,16 @@ mod test_tranche { let id = mk(&env, &client, &creator); client.release_tranche(&creator, &id, &0u32); } + + #[test] + fn test_tranche_isolated_per_invoice() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let id1 = mk(&env, &client, &creator); + let id2 = mk(&env, &client, &creator); + client.release_tranche(&creator, &id1, &3000u32); + assert_eq!(client.get_released_bps(&id1), 3000u32); + assert_eq!(client.get_released_bps(&id2), 0u32); + } } From bff0c95cc12dccb17d3f24dcc9d674a04f6a913b Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:34:54 +0100 Subject: [PATCH 09/10] docs: document tranche release in README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f1a45a0..a6ef936 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Soroban](https://img.shields.io/badge/Soroban-Protocol%2027-6C63FF?logo=stellar) ![Rust](https://img.shields.io/badge/Rust-stable-orange?logo=rust) -![Tests](https://img.shields.io/badge/tests-174%20passing-00D4AA) +![Tests](https://img.shields.io/badge/tests-178%20passing-00D4AA) ![License](https://img.shields.io/badge/license-MIT-green) ![Version](https://img.shields.io/badge/version-0.2.0-6C63FF) [![Demo](https://img.shields.io/badge/Demo-Watch%20on%20Loom-00D4AA?logo=loom)](https://www.loom.com/share/09aa4a78e0c944dcab866a7036fde24d) @@ -88,6 +88,7 @@ graph TD - **Archival** — `archive_invoice`/`unarchive_invoice`/`is_archived` terminal invoice archiving - **Streaming payments** — `create_stream`/`withdraw_vested`/`cancel_stream`/`top_up_stream` cliff-gated linear vesting `StreamingState` - **Composable routing** — `set_route`/`get_route`/`resolve_route` pass-through hop to another invoice `ComposableRoute` (self-route and 2-cycle rejected) +- **Tranche release** — `release_tranche`/`get_released_bps` partial release in basis points `TrancheState` (capped at 100%) - **Approval flow** — `set_approval_config`/`approve_invoice`/`get_approval_state` multi-sig prep - **Invoice templates** — `create_template`/`get_template` reusable configs `InvoiceTemplate` - **Recurring pause** — `pause_recurring`/`resume_recurring`/`is_recurring_paused` @@ -149,6 +150,7 @@ graph TD | `set_invoice_tags(caller, id, tags)` / `get_invoice_tags(id)` | Creator tags `InvoiceTags { tags, updated_at }` (10 max) | | `create_stream(id, recipient, amount, start, end, cliff)` / `withdraw_vested(id, recipient)` / `cancel_stream(id, recipient)` / `top_up_stream(id, recipient, additional)` | Cliff-gated linear vesting schedule per invoice | | `set_route(caller, id, target)` / `get_route(id)` / `resolve_route(id)` | Pass-through hop to another invoice (one level) | +| `release_tranche(caller, id, bps)` / `get_released_bps(id)` | Partial release accounting, cumulative cap 10_000 bps | | `pause` / `unpause` | Admin circuit breaker | --- From 635b4ffbda0011947ab3201304512309f8525100 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:35:13 +0100 Subject: [PATCH 10/10] chore: add tranche release CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca1d9d..4b576e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Sharpy smart contract are documented here. ## [Unreleased] +- feat: tranche release — `release_tranche`/`get_released_bps` partial release in bps (`TrancheState`, 178 tests) - feat: composable routing — `set_route`/`get_route`/`resolve_route` pass-through hop (`ComposableRoute`, 174 tests) - feat: streaming payments — `create_stream`/`withdraw_vested`/`cancel_stream`/`top_up_stream` cliff-gated linear vesting (`StreamingState`, 170 tests) - feat: archival — archive and restore invoices — Adds archive_invoice and is_archived query, creator-only, 3