Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(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)
Expand Down
15 changes: 14 additions & 1 deletion contracts/sharpy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
}
Expand Down Expand Up @@ -402,6 +403,7 @@ impl SharpyContract {
// Phase 1: Validate all invoices and group totals by token
let mut token_totals: Map<Address, i128> = 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");
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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<Address>) {
caller.require_auth();
Expand Down
139 changes: 139 additions & 0 deletions contracts/sharpy/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading