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) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 76cf63d..8a2b6bb 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -104,10 +104,11 @@ 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() { - assert!(state.payers.contains(payer), "payer not whitelisted"); + require!(state.payers.contains(payer), "payer not whitelisted"); } } } @@ -402,6 +403,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 +983,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 +1488,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(); diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index faae5c2..918ba17 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5621,3 +5621,142 @@ 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)); + } +} + +#[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])); + } +} + +#[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)); + } +}