diff --git a/CHANGELOG.md b/CHANGELOG.md index bdacbf8..d79e819 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): `get_fee_bps()` rate view + preview-vs-invoice consistency proof (emitting vs silent share math, release untouched) — feat/fee-consistency (closes #195) - 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) diff --git a/contracts/sharpy/src/lib.rs b/contracts/sharpy/src/lib.rs index 8a2b6bb..f45c4e3 100644 --- a/contracts/sharpy/src/lib.rs +++ b/contracts/sharpy/src/lib.rs @@ -1467,7 +1467,15 @@ impl SharpyContract { env.storage().instance().get(&fee_key()) } + /// Current fee rate in bps (0 when unset). Pure view so dashboards can + /// render the rate without fetching the full `FeeConfig`. + pub fn get_fee_bps(env: Env) -> u32 { + env.storage().instance().get::(&fee_key()).map(|c| c.fee_bps).unwrap_or(0) + } + /// Preview the protocol fee owed on `amount` (pure query, no state change). + /// Emits `fprev` for indexer visibility; for a silent estimate on an invoice + /// total see `preview_fee_for_invoice` — both share `calc_protocol_fee` math. pub fn preview_fee(env: Env, amount: i128) -> i128 { let fee = calc_protocol_fee(&env, amount); events::fee_previewed(&env, amount, fee); diff --git a/contracts/sharpy/src/test.rs b/contracts/sharpy/src/test.rs index 918ba17..324f5e6 100644 --- a/contracts/sharpy/src/test.rs +++ b/contracts/sharpy/src/test.rs @@ -5760,3 +5760,126 @@ mod test_wl_consistency_c { assert!(client.is_whitelisted_payer(&id, &payer)); } } + +#[cfg(test)] +mod test_fee_consistency_a { + use soroban_sdk::{testutils::Address as _, 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, total: i128) -> u64 { + let r = Address::generate(env); + let tok = 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, [total]), &Vec::from_array(env, [tok]), &dl, &opts) + } + #[test] + fn test_fee_bps_view_defaults_zero() { + let (env, client) = setup(); + assert_eq!(client.get_fee_bps(), 0u32); + let collector = Address::generate(&env); + client.set_protocol_fee(&250u32, &collector); + assert_eq!(client.get_fee_bps(), 250u32); + } + #[test] + fn test_invoice_preview_matches_amount_preview() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let collector = Address::generate(&env); + client.set_protocol_fee(&500u32, &collector); + let id = mk(&env, &client, &creator, 10_000i128); + assert_eq!(client.preview_fee_for_invoice(&id), client.preview_fee(&10_000i128)); + } +} + +#[cfg(test)] +mod test_fee_consistency_b { + use soroban_sdk::{testutils::Address as _, 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 mk2(env: &Env, client: &SharpyContractClient<'_>, creator: &Address) -> u64 { + let r1 = Address::generate(env); + let r2 = Address::generate(env); + let t1 = Address::generate(env); + let t2 = 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, [r1, r2]), &Vec::from_array(env, [6000i128, 4000i128]), &Vec::from_array(env, [t1, t2]), &dl, &opts) + } + #[test] + fn test_multi_recipient_total_preview_matches() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let collector = Address::generate(&env); + client.set_protocol_fee(&1000u32, &collector); + let id = mk2(&env, &client, &creator); + assert_eq!(client.preview_fee_for_invoice(&id), client.preview_fee(&10_000i128)); + assert_eq!(client.preview_fee_for_invoice(&id), 1000i128); + } + #[test] + fn test_preview_is_pure_double_call() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let collector = Address::generate(&env); + client.set_protocol_fee(&250u32, &collector); + let r = Address::generate(&env); + let tok = 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 }; + let id = client.create_invoice(&creator, &Vec::from_array(&env, [r]), &Vec::from_array(&env, [8000i128]), &Vec::from_array(&env, [tok]), &dl, &opts); + let a = client.preview_fee_for_invoice(&id); + let b = client.preview_fee_for_invoice(&id); + assert_eq!(a, b); + assert_eq!(a, 200i128); + } +} + +#[cfg(test)] +mod test_fee_consistency_c { + 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_full_bps_equals_amount() { + let (env, client) = setup(); + let collector = Address::generate(&env); + client.set_protocol_fee(&10_000u32, &collector); + assert_eq!(client.preview_fee(&12345i128), 12345i128); + assert_eq!(client.get_fee_bps(), 10_000u32); + } + #[test] + fn test_truncation_rounds_down() { + let (env, client) = setup(); + let collector = Address::generate(&env); + client.set_protocol_fee(&100u32, &collector); + assert_eq!(client.preview_fee(&1i128), 0i128); + assert_eq!(client.preview_fee(&10_000i128), 100i128); + } +}