From d9b6d1c2e83ae9dba7e166e5e43637b8ab37e860 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:06:35 +0100 Subject: [PATCH 1/6] feat(contract): enforce whitelist in pay_with_tip and pool_pay --- contracts/sharpy/src/lib.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 76cf63d..8d29d5a 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -107,7 +107,7 @@ fn bump_counter(env: &Env) -> u64 { fn require_whitelisted(env: &Env, invoice_id: u64, payer: &Address) { if let Some(state) = env.storage().persistent().get::<(Symbol,u64), WhitelistState>(&whitelist_key(invoice_id)) { if !state.payers.is_empty() { - assert!(state.payers.contains(payer), "payer not whitelisted"); + require!(state.payers.contains(payer), "payer not whitelisted"); } } } @@ -402,6 +402,7 @@ impl SharpyContract { // Phase 1: Validate all invoices and group totals by token let mut token_totals: Map = Map::new(&env); for p in payments.iter() { + require_whitelisted(&env, p.invoice_id, &payer); let inv = load_invoice(&env, p.invoice_id); assert!(inv.status == InvoiceStatus::Pending, "invoice is not pending"); assert!(p.amount > 0, "payment amount must be positive"); @@ -981,6 +982,7 @@ impl SharpyContract { pub fn pay_with_tip(env: Env, payer: Address, invoice_id: u64, amount: i128, tip: i128) { require_not_paused(&env); payer.require_auth(); + require_whitelisted(&env, invoice_id, &payer); assert!(amount > 0, "payment amount must be positive"); assert!(tip >= 0, "tip must be non-negative"); @@ -1485,6 +1487,16 @@ impl SharpyContract { calc_protocol_fee(&env, total) } + /// True when `payer` may pay `invoice_id`: open when no whitelist exists + /// or the list is empty, else only when listed. Pure view mirroring + /// `require_whitelisted` enforcement across `pay`/`pay_with_tip`/`pool_pay`. + pub fn is_whitelisted_payer(env: Env, invoice_id: u64, payer: Address) -> bool { + match env.storage().persistent().get::<(Symbol,u64), WhitelistState>(&whitelist_key(invoice_id)) { + None => true, + Some(s) => s.payers.is_empty() || s.payers.contains(&payer), + } + } + /// 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 0588ed7f6a9614ec115d6a4775378e85b193e896 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:06:41 +0100 Subject: [PATCH 2/6] docs(contract): note uniform whitelist guard across pay paths --- 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 8d29d5a..8a2b6bb 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -104,6 +104,7 @@ fn bump_counter(env: &Env) -> u64 { } /// Pay-guard: when a whitelist exists and is non-empty, only listed payers pass. +/// Enforced identically in `pay`, `pay_with_tip`, and `pool_pay` (closes #194). fn require_whitelisted(env: &Env, invoice_id: u64, payer: &Address) { if let Some(state) = env.storage().persistent().get::<(Symbol,u64), WhitelistState>(&whitelist_key(invoice_id)) { if !state.payers.is_empty() { From 215484147b6107b8df8baf71961e361ac2012e42 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:07:35 +0100 Subject: [PATCH 3/6] test(contract): add whitelist view open and empty cases --- contracts/sharpy/src/test.rs | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index faae5c2..e0e5b48 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5621,3 +5621,45 @@ mod test_tranche_invariant_c { client.release_tranche(&other, &id, &100u32); } } + +#[cfg(test)] +mod test_wl_consistency_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) + } + fn mk(env: &Env, client: &SharpyContractClient<'_>, creator: &Address, payer: &Address, amt: i128) -> u64 { + let admin = Address::generate(env); + let tok = env.register_stellar_asset_contract(admin.clone()); + token::StellarAssetClient::new(env, &tok).mint(payer, &(amt * 2)); + 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 }; + client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [amt]), &Vec::from_array(env, [tok]), &dl, &opts) + } + #[test] + fn test_view_open_when_no_list() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let id = mk(&env, &client, &creator, &payer, 1000i128); + assert!(client.is_whitelisted_payer(&id, &payer)); + } + #[test] + fn test_view_empty_means_open() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let id = mk(&env, &client, &creator, &payer, 1000i128); + client.set_whitelist(&creator, &id, &Vec::new(&env)); + assert!(client.is_whitelisted_payer(&id, &payer)); + } +} From 566466587db11d346fa1446093781850c7d29eee Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:07:35 +0100 Subject: [PATCH 4/6] test(contract): add whitelist tip and pool bypass blocks --- 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 e0e5b48..243cc9b 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5663,3 +5663,52 @@ mod test_wl_consistency_a { assert!(client.is_whitelisted_payer(&id, &payer)); } } + +#[cfg(test)] +mod test_wl_consistency_b { + use soroban_sdk::{testutils::Address as _, token, Address, Env, Vec}; + use crate::{types::InvoicePayment, 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, payer: &Address, amt: i128) -> u64 { + let admin = Address::generate(env); + let tok = env.register_stellar_asset_contract(admin.clone()); + token::StellarAssetClient::new(env, &tok).mint(payer, &(amt * 3)); + 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 }; + client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [amt]), &Vec::from_array(env, [tok]), &dl, &opts) + } + #[test] + #[should_panic(expected = "payer not whitelisted")] + fn test_tip_path_blocked_for_stranger() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let allowed = Address::generate(&env); + let stranger = Address::generate(&env); + let id = mk(&env, &client, &creator, &stranger, 1000i128); + client.set_whitelist(&creator, &id, &Vec::from_array(&env, [allowed])); + assert!(!client.is_whitelisted_payer(&id, &stranger)); + client.pay_with_tip(&stranger, &id, &100i128, &5i128); + } + #[test] + #[should_panic(expected = "payer not whitelisted")] + fn test_pool_path_blocked_for_stranger() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let allowed = Address::generate(&env); + let stranger = Address::generate(&env); + let id = mk(&env, &client, &creator, &stranger, 1000i128); + client.set_whitelist(&creator, &id, &Vec::from_array(&env, [allowed])); + let p = InvoicePayment { invoice_id: id, amount: 100i128 }; + client.pool_pay(&stranger, &Vec::from_array(&env, [p])); + } +} From 03a6bd118e6439a437c8cba280d249a9b911e7c3 Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:07:44 +0100 Subject: [PATCH 5/6] test(contract): add whitelist listed-passes and remove-opens cases --- contracts/sharpy/src/test.rs | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 243cc9b..918ba17 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5712,3 +5712,51 @@ mod test_wl_consistency_b { client.pool_pay(&stranger, &Vec::from_array(&env, [p])); } } + +#[cfg(test)] +mod test_wl_consistency_c { + use soroban_sdk::{testutils::Address as _, token, Address, Env, Vec}; + use crate::{types::InvoicePayment, 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, payer: &Address, amt: i128) -> u64 { + let admin = Address::generate(env); + let tok = env.register_stellar_asset_contract(admin.clone()); + token::StellarAssetClient::new(env, &tok).mint(payer, &(amt * 3)); + 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 }; + client.create_invoice(creator, &Vec::from_array(env, [r]), &Vec::from_array(env, [amt]), &Vec::from_array(env, [tok]), &dl, &opts) + } + #[test] + fn test_listed_payer_passes_all_paths() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let id = mk(&env, &client, &creator, &payer, 1000i128); + client.set_whitelist(&creator, &id, &Vec::from_array(&env, [payer.clone()])); + assert!(client.is_whitelisted_payer(&id, &payer)); + client.pay_with_tip(&payer, &id, &200i128, &0i128); + let p = InvoicePayment { invoice_id: id, amount: 100i128 }; + client.pool_pay(&payer, &Vec::from_array(&env, [p])); + assert_eq!(client.get_invoice(&id).funded, 300i128); + } + #[test] + fn test_view_matches_enforcement_after_remove() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let id = mk(&env, &client, &creator, &payer, 1000i128); + client.set_whitelist(&creator, &id, &Vec::from_array(&env, [payer.clone()])); + client.remove_whitelisted_payer(&creator, &id, &payer); + assert!(client.is_whitelisted_payer(&id, &payer)); + } +} From 6b7fe45c4496fa97eb309f2caad58b498bfc908a Mon Sep 17 00:00:00 2001 From: MarcusDavidG Date: Thu, 10 Sep 2026 12:08:11 +0100 Subject: [PATCH 6/6] docs(changelog): record whitelist uniformity for #194 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 353c6fb..bdacbf8 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): whitelist enforced uniformly in `pay`/`pay_with_tip`/`pool_pay` + `is_whitelisted_payer(id, payer)` view; `require!` message consistency — feat/whitelist-consistency (closes #194) - 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)