From 20d8e5b6486c7ae078775ef58516239ed6f54742 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:42:03 +0100 Subject: [PATCH 01/10] feat(types): add FeeConfig for protocol fee in bps --- contracts/sharpy/src/types.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/contracts/sharpy/src/types.rs b/contracts/sharpy/src/types.rs index 6a4f0ea..06d5c54 100644 --- a/contracts/sharpy/src/types.rs +++ b/contracts/sharpy/src/types.rs @@ -351,3 +351,12 @@ pub struct WhitelistState { pub payers: soroban_sdk::Vec
, pub updated_at: u64, } + +/// Protocol fee taken on release, in basis points of 10_000. +#[contracttype] +#[derive(Clone, Debug)] +pub struct FeeConfig { + pub fee_bps: u32, + pub collector: Address, + pub updated_at: u64, +} From 49cda73707626219742c02fcdb6fd3a72a1086ce Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:42:11 +0100 Subject: [PATCH 02/10] feat(events): add fee_configured and fee_previewed events --- contracts/sharpy/src/events.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/contracts/sharpy/src/events.rs b/contracts/sharpy/src/events.rs index b0b48ef..85e9b31 100644 --- a/contracts/sharpy/src/events.rs +++ b/contracts/sharpy/src/events.rs @@ -370,3 +370,17 @@ pub struct WhitelistPayerRemovedEvent { pub invoice_id: u64, pub payer: Address pub fn whitelist_payer_removed(env: &Env, invoice_id: u64, payer: &Address) { env.events().publish((symbol_short!("wrem"),), WhitelistPayerRemovedEvent { invoice_id, payer: payer.clone() }); } + +#[contracttype] +#[derive(Clone)] +pub struct FeeConfiguredEvent { pub fee_bps: u32, pub collector: Address } +pub fn fee_configured(env: &Env, fee_bps: u32, collector: &Address) { + env.events().publish((symbol_short!("fee"),), FeeConfiguredEvent { fee_bps, collector: collector.clone() }); +} + +#[contracttype] +#[derive(Clone)] +pub struct FeePreviewedEvent { pub amount: i128, pub fee: i128 } +pub fn fee_previewed(env: &Env, amount: i128, fee: i128) { + env.events().publish((symbol_short!("fprev"),), FeePreviewedEvent { amount, fee }); +} From dc679fd1226734872dea12ab895693b2f8440058 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:42:36 +0100 Subject: [PATCH 03/10] feat(contract): add set/get_protocol_fee fns --- contracts/sharpy/src/lib.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 56acb75..3682e5b 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, TrancheState, WhitelistState, + InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTags, InvoiceExtraMemo, Payment, InvoiceMetadata, DiscountConfig, RecurringPauseState, InvoiceTemplate, ApprovalState, ArchivalState, SplitRule, StreamingState, ComposableRoute, TrancheState, WhitelistState, FeeConfig, SubscriptionParams, }; @@ -53,6 +53,7 @@ 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 whitelist_key(id: u64) -> (Symbol, u64) { (symbol_short!("wlist"), id) } +fn fee_key() -> Symbol { symbol_short!("fee") } fn is_paused(env: &Env) -> bool { env.storage().persistent().get(&paused_key()).unwrap_or(false) @@ -1300,6 +1301,20 @@ impl SharpyContract { env.storage().persistent().get::<(Symbol,u64), TrancheState>(&tranche_key(invoice_id)).map(|s| s.released_bps).unwrap_or(0) } + /// Set the protocol fee in bps (admin-only, capped at 100%). + pub fn set_protocol_fee(env: Env, fee_bps: u32, collector: Address) { + require_admin(&env); + assert!(fee_bps <= 10_000, "fee bps out of range"); + let rc = collector.clone(); + env.storage().instance().set(&fee_key(), &FeeConfig { fee_bps, collector: rc.clone(), updated_at: env.ledger().timestamp() }); + events::fee_configured(&env, fee_bps, &rc); + } + + /// Return the protocol fee config, if any. + pub fn get_protocol_fee(env: Env) -> Option { + env.storage().instance().get(&fee_key()) + } + /// Set the payer whitelist for `invoice_id` (creator-only; empty = open). pub fn set_whitelist(env: Env, caller: Address, invoice_id: u64, payers: Vec
) { caller.require_auth(); From 6727cdec4c70e9945a9e76fb168d7bc9b7ae82ee Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:44:55 +0100 Subject: [PATCH 04/10] feat(contract): add calc_protocol_fee helper --- 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 3682e5b..023b966 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -112,6 +112,15 @@ fn require_whitelisted(env: &Env, invoice_id: u64, payer: &Address) { } } +/// Fee math: `amount * fee_bps / 10_000`, zero when no fee is configured. +fn calc_protocol_fee(env: &Env, amount: i128) -> i128 { + let bps: u32 = env.storage().instance().get::(&fee_key()).map(|c| c.fee_bps).unwrap_or(0); + if bps == 0 || amount <= 0 { + return 0; + } + (amount * (bps as i128) / 10_000i128).max(0i128) +} + fn index_invoice_for_creator(env: &Env, creator: &Address, invoice_id: u64) { let key = creator_index_key(creator); let mut ids: Vec = env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env)); From 60a045a4282e0de6ccb0d82fe8312826fe3b1e5d Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:45:16 +0100 Subject: [PATCH 05/10] feat(contract): add preview_fee standalone query --- contracts/sharpy/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 023b966..430ac16 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1324,6 +1324,13 @@ impl SharpyContract { env.storage().instance().get(&fee_key()) } + /// Preview the protocol fee owed on `amount` (pure query, no state change). + pub fn preview_fee(env: Env, amount: i128) -> i128 { + let fee = calc_protocol_fee(&env, amount); + events::fee_previewed(&env, amount, fee); + fee + } + /// Set the payer whitelist for `invoice_id` (creator-only; empty = open). pub fn set_whitelist(env: Env, caller: Address, invoice_id: u64, payers: Vec
) { caller.require_auth(); From 054e29ef91f4f02b626c7ec2b1d693ecce047552 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:46:47 +0100 Subject: [PATCH 06/10] test: add protocol fee math test --- contracts/sharpy/src/test.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index b1091e7..f43712e 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -4143,3 +4143,31 @@ mod test_whitelist { } } +#[cfg(test)] +mod test_fee { + use soroban_sdk::{testutils::Address as _, Address, Env}; + 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_fee_math_takes_bps_cut() { + let (env, client) = setup(); + let collector = Address::generate(&env); + client.set_protocol_fee(&250u32, &collector); + let cfg = client.get_protocol_fee().unwrap(); + assert_eq!(cfg.fee_bps, 250u32); + assert_eq!(client.preview_fee(&10_000i128), 250i128); + assert_eq!(client.preview_fee(&1_000i128), 25i128); + } +} + From 00eea61ff965cc38949fcd375e84de31206397ec Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:47:15 +0100 Subject: [PATCH 07/10] test: add zero-fee default test --- contracts/sharpy/src/test.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index f43712e..c76b58e 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -4169,5 +4169,15 @@ mod test_fee { assert_eq!(client.preview_fee(&10_000i128), 250i128); assert_eq!(client.preview_fee(&1_000i128), 25i128); } + + #[test] + fn test_fee_zero_when_unset_or_zero_bps() { + let (env, client) = setup(); + assert!(client.get_protocol_fee().is_none()); + assert_eq!(client.preview_fee(&10_000i128), 0i128); + let collector = Address::generate(&env); + client.set_protocol_fee(&0u32, &collector); + assert_eq!(client.preview_fee(&10_000i128), 0i128); + } } From 70c00a3697b0a3648c82e4ca152b4b6bc621c2cf Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:48:00 +0100 Subject: [PATCH 08/10] test: add fee preview purity and replacement test --- contracts/sharpy/src/test.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index c76b58e..9bd577e 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -4179,5 +4179,20 @@ mod test_fee { client.set_protocol_fee(&0u32, &collector); assert_eq!(client.preview_fee(&10_000i128), 0i128); } + + #[test] + fn test_fee_preview_is_pure_and_replaceable() { + let (env, client) = setup(); + let collector = Address::generate(&env); + client.set_protocol_fee(&100u32, &collector); + assert_eq!(client.preview_fee(&10_000i128), 100i128); + // Preview must not mutate stored config + assert_eq!(client.get_protocol_fee().unwrap().fee_bps, 100u32); + // Larger amounts scale independently + assert_eq!(client.preview_fee(&50_000i128), 500i128); + // Admin can replace the config; new rate applies immediately + client.set_protocol_fee(&500u32, &collector); + assert_eq!(client.preview_fee(&10_000i128), 500i128); + } } From 24d35f9efafcf02c654f9ff5975a7fe1b78c33a3 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:48:24 +0100 Subject: [PATCH 09/10] docs: document protocol fee module in README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e41078b..4848e71 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-181%20passing-00D4AA) +![Tests](https://img.shields.io/badge/tests-184%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) @@ -90,6 +90,7 @@ graph TD - **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%) - **Whitelist gating** — `set_whitelist`/`get_whitelist`/`add_whitelisted_payer`/`remove_whitelisted_payer` creator-managed payer allowlist enforced in `pay` +- **Protocol fee** — `set_protocol_fee`/`get_protocol_fee`/`preview_fee` admin-set bps cut `FeeConfig` (pure preview, no release-path change) - **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` @@ -153,6 +154,7 @@ graph TD | `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 | | `set_whitelist(caller, id, payers)` / `get_whitelist(id)` / `add_whitelisted_payer` / `remove_whitelisted_payer` | Payer allowlist enforced by `pay` (absent = open) | +| `set_protocol_fee(bps, collector)` / `get_protocol_fee()` / `preview_fee(amount)` | Admin bps fee with pure preview query | | `pause` / `unpause` | Admin circuit breaker | --- From 1f4b98846fff923a3c4d39937d1af844c35b4dd4 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 3 Sep 2026 23:48:46 +0100 Subject: [PATCH 10/10] chore: add protocol fee CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b5709..b2f990a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Sharpy smart contract are documented here. ## [Unreleased] +- feat: protocol fee — `set/get_protocol_fee` + `preview_fee` bps cut (`FeeConfig`, 184 tests) - feat: whitelist gating — `set/get_whitelist` + add/remove enforced in `pay` (`WhitelistState`, 181 tests) - 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)