Skip to content
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: 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)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 |

---
Expand Down
14 changes: 14 additions & 0 deletions contracts/sharpy/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
33 changes: 32 additions & 1 deletion contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -111,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::<Symbol, FeeConfig>(&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<u64> = env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));
Expand Down Expand Up @@ -1300,6 +1310,27 @@ 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<FeeConfig> {
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<Address>) {
caller.require_auth();
Expand Down
53 changes: 53 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4143,3 +4143,56 @@ 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);
}

#[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);
}

#[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);
}
}

9 changes: 9 additions & 0 deletions contracts/sharpy/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,12 @@ pub struct WhitelistState {
pub payers: soroban_sdk::Vec<Address>,
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,
}
Loading