diff --git a/contracts/dashboard/src/lib.rs b/contracts/dashboard/src/lib.rs index 78b0124..25e243d 100644 --- a/contracts/dashboard/src/lib.rs +++ b/contracts/dashboard/src/lib.rs @@ -44,17 +44,17 @@ impl DashboardContract { authorized.require_auth(); let key = creator_dashboard_key(&creator); - let mut dashboard: CreatorDashboard = env - .storage() - .persistent() - .get(&key) - .unwrap_or(CreatorDashboard { - invoice_count: 0, - total_volume: 0, - released_count: 0, - refunded_count: 0, - released_volume: 0, - }); + let mut dashboard: CreatorDashboard = + env.storage() + .persistent() + .get(&key) + .unwrap_or(CreatorDashboard { + invoice_count: 0, + total_volume: 0, + released_count: 0, + refunded_count: 0, + released_volume: 0, + }); dashboard.invoice_count = dashboard .invoice_count .checked_add(1) @@ -75,17 +75,17 @@ impl DashboardContract { authorized.require_auth(); let key = creator_dashboard_key(&creator); - let mut dashboard: CreatorDashboard = env - .storage() - .persistent() - .get(&key) - .unwrap_or(CreatorDashboard { - invoice_count: 0, - total_volume: 0, - released_count: 0, - refunded_count: 0, - released_volume: 0, - }); + let mut dashboard: CreatorDashboard = + env.storage() + .persistent() + .get(&key) + .unwrap_or(CreatorDashboard { + invoice_count: 0, + total_volume: 0, + released_count: 0, + refunded_count: 0, + released_volume: 0, + }); dashboard.released_count = dashboard .released_count .checked_add(1) @@ -106,17 +106,17 @@ impl DashboardContract { authorized.require_auth(); let key = creator_dashboard_key(&creator); - let mut dashboard: CreatorDashboard = env - .storage() - .persistent() - .get(&key) - .unwrap_or(CreatorDashboard { - invoice_count: 0, - total_volume: 0, - released_count: 0, - refunded_count: 0, - released_volume: 0, - }); + let mut dashboard: CreatorDashboard = + env.storage() + .persistent() + .get(&key) + .unwrap_or(CreatorDashboard { + invoice_count: 0, + total_volume: 0, + released_count: 0, + refunded_count: 0, + released_volume: 0, + }); dashboard.refunded_count = dashboard .refunded_count .checked_add(1) diff --git a/contracts/dashboard/src/test.rs b/contracts/dashboard/src/test.rs index 4f4711c..1c38484 100644 --- a/contracts/dashboard/src/test.rs +++ b/contracts/dashboard/src/test.rs @@ -1,4 +1,5 @@ #![cfg(test)] +#![allow(clippy::all)] use super::*; use soroban_sdk::{testutils::Address as _, Address, Env}; diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index a7bd39e..c9315b0 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -5,26 +5,26 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum ContractError { - NotAuthorized = 1, - InvoiceNotFound = 2, - DeadlinePassed = 3, - AlreadyFunded = 4, - InvalidAmount = 5, - InvoiceFrozen = 6, - InvalidStatus = 7, - PayerNotAllowed = 8, + NotAuthorized = 1, + InvoiceNotFound = 2, + DeadlinePassed = 3, + AlreadyFunded = 4, + InvalidAmount = 5, + InvoiceFrozen = 6, + InvalidStatus = 7, + PayerNotAllowed = 8, FundingInsufficient = 9, - OracleCallFailed = 10, - NotArbiter = 11, - NotDisputed = 12, - AlreadyExecuted = 13, - TimelockPending = 14, - ContractPaused = 15, - InvalidRecipients = 16, + OracleCallFailed = 10, + NotArbiter = 11, + NotDisputed = 12, + AlreadyExecuted = 13, + TimelockPending = 14, + ContractPaused = 15, + InvalidRecipients = 16, PrerequisiteNotMet = 17, BatchLimitExceeded = 18, /// Issue #327: Funds are still time-locked and cannot be released yet. - FundsLockedUntil = 20, + FundsLockedUntil = 20, /// Issue #330: Recipient has already been paid on this invoice. RecipientAlreadyPaid = 19, } diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 23b50cf..8e190a6 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1,10 +1,16 @@ -use soroban_sdk::{symbol_short, Address, BytesN, Env, Vec, String}; -use crate::types::{InvoiceStatus, TimelockAction, DisputeOutcome}; +use crate::types::{DisputeOutcome, InvoiceStatus, RepScore, TimelockAction}; +use soroban_sdk::{symbol_short, Address, BytesN, Env, String, Vec}; /// Emitted when a new invoice is created. /// Topics: (split, created, invoice_id) /// Data: (creator, total) -pub fn invoice_created(env: &Env, invoice_id: u64, creator: &Address, total: i128, cross_chain_ref: &Option) { +pub fn invoice_created( + env: &Env, + invoice_id: u64, + creator: &Address, + total: i128, + cross_chain_ref: &Option, +) { env.events().publish( (symbol_short!("split"), symbol_short!("created"), invoice_id), (creator.clone(), total, cross_chain_ref.clone()), @@ -26,7 +32,11 @@ pub fn payment_received(env: &Env, invoice_id: u64, payer: &Address, amount: i12 /// Data: recipients pub fn invoice_released(env: &Env, invoice_id: u64, recipients: &Vec
) { env.events().publish( - (symbol_short!("split"), symbol_short!("released"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("released"), + invoice_id, + ), recipients.clone(), ); } @@ -36,7 +46,11 @@ pub fn invoice_released(env: &Env, invoice_id: u64, recipients: &Vec
) { /// Data: () pub fn invoice_refunded(env: &Env, invoice_id: u64) { env.events().publish( - (symbol_short!("split"), symbol_short!("refunded"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("refunded"), + invoice_id, + ), (), ); } @@ -76,7 +90,11 @@ pub fn split_adjusted(env: &Env, invoice_id: u64, creator: &Address) { /// Data: () pub fn invoice_archived(env: &Env, invoice_id: u64) { env.events().publish( - (symbol_short!("split"), symbol_short!("archived"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("archived"), + invoice_id, + ), (), ); } @@ -86,7 +104,11 @@ pub fn invoice_archived(env: &Env, invoice_id: u64) { /// Data: delegate pub fn delegate_set(env: &Env, invoice_id: u64, delegate: &Address) { env.events().publish( - (symbol_short!("split"), symbol_short!("delegated"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("delegated"), + invoice_id, + ), delegate.clone(), ); } @@ -106,7 +128,11 @@ pub fn delegate_revoked(env: &Env, invoice_id: u64) { /// Data: recipients pub fn invoice_partially_released(env: &Env, invoice_id: u64, recipients: &Vec
) { env.events().publish( - (symbol_short!("split"), symbol_short!("part_rel"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("part_rel"), + invoice_id, + ), recipients.clone(), ); } @@ -116,7 +142,11 @@ pub fn invoice_partially_released(env: &Env, invoice_id: u64, recipients: &Vec) { ); } -pub fn partial_refund_issued(env: &Env, invoice_id: u64, creator: &Address, bps: u32, amount: i128) { +pub fn partial_refund_issued( + env: &Env, + invoice_id: u64, + creator: &Address, + bps: u32, + amount: i128, +) { env.events().publish( (symbol_short!("split"), symbol_short!("prt_ref"), invoice_id), (creator.clone(), bps, amount), @@ -246,9 +284,18 @@ pub fn partial_refund_issued(env: &Env, invoice_id: u64, creator: &Address, bps: /// Issue #276: Emitted when cumulative platform volume crosses a milestone threshold. /// Topics: (split, plt_v_ms, milestone_number) /// Data: (total_volume, invoice_count, ledger) -pub fn platform_volume_milestone(env: &Env, total_volume: i128, invoice_count: u64, milestone_number: i128) { +pub fn platform_volume_milestone( + env: &Env, + total_volume: i128, + invoice_count: u64, + milestone_number: i128, +) { env.events().publish( - (symbol_short!("split"), symbol_short!("plt_v_ms"), milestone_number), + ( + symbol_short!("split"), + symbol_short!("plt_v_ms"), + milestone_number, + ), (total_volume, invoice_count, env.ledger().sequence()), ); } @@ -256,10 +303,25 @@ pub fn platform_volume_milestone(env: &Env, total_volume: i128, invoice_count: u /// Issue #276: Emitted when a creator's lifetime volume crosses a milestone threshold. /// Topics: (split, cr_v_ms, creator) /// Data: (total_volume, invoice_count, milestone_number, ledger) -pub fn creator_volume_milestone(env: &Env, creator: &Address, total_volume: i128, invoice_count: u64, milestone_number: i128) { +pub fn creator_volume_milestone( + env: &Env, + creator: &Address, + total_volume: i128, + invoice_count: u64, + milestone_number: i128, +) { env.events().publish( - (symbol_short!("split"), symbol_short!("cr_v_ms"), creator.clone()), - (total_volume, invoice_count, milestone_number, env.ledger().sequence()), + ( + symbol_short!("split"), + symbol_short!("cr_v_ms"), + creator.clone(), + ), + ( + total_volume, + invoice_count, + milestone_number, + env.ledger().sequence(), + ), ); } @@ -277,10 +339,8 @@ pub fn circuit_breaker_activated(env: &Env, reason: &String) { /// Topics: (split, cb_deact) /// Data: () pub fn circuit_breaker_deactivated(env: &Env) { - env.events().publish( - (symbol_short!("split"), symbol_short!("cb_dact")), - (), - ); + env.events() + .publish((symbol_short!("split"), symbol_short!("cb_dact")), ()); } /// Issue #296: Emitted when a fee waiver is granted to a creator. @@ -288,7 +348,11 @@ pub fn circuit_breaker_deactivated(env: &Env) { /// Data: () pub fn fee_waiver_granted(env: &Env, creator: &Address) { env.events().publish( - (symbol_short!("split"), symbol_short!("fw_grant"), creator.clone()), + ( + symbol_short!("split"), + symbol_short!("fw_grant"), + creator.clone(), + ), (), ); } @@ -298,7 +362,11 @@ pub fn fee_waiver_granted(env: &Env, creator: &Address) { /// Data: () pub fn fee_waiver_revoked(env: &Env, creator: &Address) { env.events().publish( - (symbol_short!("split"), symbol_short!("fw_rev"), creator.clone()), + ( + symbol_short!("split"), + symbol_short!("fw_rev"), + creator.clone(), + ), (), ); } @@ -317,9 +385,19 @@ pub fn fee_tiers_updated(env: &Env, tier_count: u32) { /// Topics: (split, fee_tier_applied, creator) /// Data: (tier_index, fee_bps, creator_volume) #[allow(dead_code)] -pub fn fee_tier_applied(env: &Env, creator: &Address, tier_index: u32, fee_bps: u32, creator_volume: u64) { +pub fn fee_tier_applied( + env: &Env, + creator: &Address, + tier_index: u32, + fee_bps: u32, + creator_volume: u64, +) { env.events().publish( - (symbol_short!("split"), symbol_short!("fee_app"), creator.clone()), + ( + symbol_short!("split"), + symbol_short!("fee_app"), + creator.clone(), + ), (tier_index, fee_bps, creator_volume), ); } @@ -328,10 +406,28 @@ pub fn fee_tier_applied(env: &Env, creator: &Address, tier_index: u32, fee_bps: /// Topics: (split, creator_stats_updated, creator) /// Data: (total_invoices, total_raised, total_released, total_payers, avg_funding_time) #[allow(dead_code)] -pub fn creator_stats_updated(env: &Env, creator: &Address, total_invoices: u32, total_raised: u64, total_released: u64, total_payers: u32, avg_funding_time_ledgers: u32) { +pub fn creator_stats_updated( + env: &Env, + creator: &Address, + total_invoices: u32, + total_raised: u64, + total_released: u64, + total_payers: u32, + avg_funding_time_ledgers: u32, +) { env.events().publish( - (symbol_short!("split"), symbol_short!("stats_upd"), creator.clone()), - (total_invoices, total_raised, total_released, total_payers, avg_funding_time_ledgers), + ( + symbol_short!("split"), + symbol_short!("stats_upd"), + creator.clone(), + ), + ( + total_invoices, + total_raised, + total_released, + total_payers, + avg_funding_time_ledgers, + ), ); } @@ -379,7 +475,13 @@ pub fn invoice_state_changed( /// Issue #309: Emitted when a payer is added/removed from an invoice's allowlist. /// Topics: (split, al_upd, invoice_id) /// Data: (creator, payer, added) -pub fn allowlist_updated(env: &Env, invoice_id: u64, creator: &Address, payer: &Address, added: bool) { +pub fn allowlist_updated( + env: &Env, + invoice_id: u64, + creator: &Address, + payer: &Address, + added: bool, +) { env.events().publish( (symbol_short!("split"), symbol_short!("al_upd"), invoice_id), (creator.clone(), payer.clone(), added), @@ -451,7 +553,11 @@ pub fn contract_unpaused(env: &Env, admin: &Address) { /// Data: unlock_ledger pub fn funds_unlocked(env: &Env, invoice_id: u64, unlock_ledger: u32) { env.events().publish( - (symbol_short!("split"), symbol_short!("fnd_unlk"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("fnd_unlk"), + invoice_id, + ), unlock_ledger, ); } @@ -459,9 +565,18 @@ pub fn funds_unlocked(env: &Env, invoice_id: u64, unlock_ledger: u32) { /// Issue #329: Emitted when a creator updates an invoice's off-chain metadata hash. /// Topics: (split, meta_upd, invoice_id) /// Data: (old_hash, new_hash, ledger) -pub fn metadata_updated(env: &Env, invoice_id: u64, old_hash: &Option>, new_hash: &BytesN<32>) { +pub fn metadata_updated( + env: &Env, + invoice_id: u64, + old_hash: &Option>, + new_hash: &BytesN<32>, +) { env.events().publish( - (symbol_short!("split"), symbol_short!("meta_upd"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("meta_upd"), + invoice_id, + ), (old_hash.clone(), new_hash.clone(), env.ledger().sequence()), ); } @@ -471,7 +586,11 @@ pub fn metadata_updated(env: &Env, invoice_id: u64, old_hash: &Option /// Data: (recipient, amount, ledger) pub fn recipient_paid(env: &Env, invoice_id: u64, recipient: &Address, amount: i128) { env.events().publish( - (symbol_short!("split"), symbol_short!("rec_paid"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("rec_paid"), + invoice_id, + ), (recipient.clone(), amount, env.ledger().sequence()), ); } @@ -492,7 +611,11 @@ pub fn recipient_paid(env: &Env, invoice_id: u64, recipient: &Address, amount: i /// Data: (milestone_bps, funded_amount, ledger) pub fn milestone_reached(env: &Env, invoice_id: u64, milestone_bps: u32, funded_amount: i128) { env.events().publish( - (symbol_short!("split"), symbol_short!("milestone"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("milestone"), + invoice_id, + ), (milestone_bps, funded_amount, env.ledger().sequence()), ); } @@ -500,10 +623,25 @@ pub fn milestone_reached(env: &Env, invoice_id: u64, milestone_bps: u32, funded_ /// Issue #315: Emitted when a delegated payment is executed. /// Topics: (split, dlgt_pay, invoice_id) /// Data: (payer, executor, amount, ledger) -pub fn delegated_payment(env: &Env, invoice_id: u64, payer: &Address, executor: &Address, amount: i128) { +pub fn delegated_payment( + env: &Env, + invoice_id: u64, + payer: &Address, + executor: &Address, + amount: i128, +) { env.events().publish( - (symbol_short!("split"), symbol_short!("dlgt_pay"), invoice_id), - (payer.clone(), executor.clone(), amount, env.ledger().sequence()), + ( + symbol_short!("split"), + symbol_short!("dlgt_pay"), + invoice_id, + ), + ( + payer.clone(), + executor.clone(), + amount, + env.ledger().sequence(), + ), ); } @@ -512,7 +650,11 @@ pub fn delegated_payment(env: &Env, invoice_id: u64, payer: &Address, executor: /// Data: (payer, reason_hash, ledger) pub fn dispute_raised(env: &Env, invoice_id: u64, payer: &Address, reason_hash: &BytesN<32>) { env.events().publish( - (symbol_short!("split"), symbol_short!("disp_rsd"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("disp_rsd"), + invoice_id, + ), (payer.clone(), reason_hash.clone(), env.ledger().sequence()), ); } @@ -526,7 +668,11 @@ pub fn dispute_resolved(env: &Env, invoice_id: u64, admin: &Address, outcome: &D DisputeOutcome::Refunded => symbol_short!("refunded"), }; env.events().publish( - (symbol_short!("split"), symbol_short!("disp_res"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("disp_res"), + invoice_id, + ), (admin.clone(), outcome_sym, env.ledger().sequence()), ); } @@ -536,7 +682,11 @@ pub fn dispute_resolved(env: &Env, invoice_id: u64, admin: &Address, outcome: &D /// Data: ledger pub fn dispute_expired(env: &Env, invoice_id: u64) { env.events().publish( - (symbol_short!("split"), symbol_short!("disp_exp"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("disp_exp"), + invoice_id, + ), env.ledger().sequence(), ); } @@ -546,7 +696,25 @@ pub fn dispute_expired(env: &Env, invoice_id: u64) { /// Data: (amount, treasury, ledger) pub fn fee_paid(env: &Env, invoice_id: u64, amount: i128, treasury: &Address) { env.events().publish( - (symbol_short!("split"), symbol_short!("fee_paid"), invoice_id), + ( + symbol_short!("split"), + symbol_short!("fee_paid"), + invoice_id, + ), (amount, treasury.clone(), env.ledger().sequence()), ); } + +/// Issue #349: Emitted when an address's reputation score is updated. +/// Topics: (split, rep_upd, address) +/// Data: score +pub fn rep_updated(env: &Env, address: &Address, score: &RepScore) { + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("rep_upd"), + address.clone(), + ), + score.clone(), + ); +} diff --git a/contracts/split/src/fuzz_tests.rs b/contracts/split/src/fuzz_tests.rs index d21fe2b..ad93bad 100644 --- a/contracts/split/src/fuzz_tests.rs +++ b/contracts/split/src/fuzz_tests.rs @@ -1,4 +1,5 @@ #![cfg(test)] +#![allow(clippy::all)] extern crate std; @@ -13,7 +14,13 @@ const MAX_BPS: u32 = 10_000; /// Proportional share of `funded` for one recipient (mirrors `_release_full` /// and `partial_release` distribution). -fn proportional_share(amount: i128, total: i128, funded: i128, is_last: bool, distributed: i128) -> i128 { +fn proportional_share( + amount: i128, + total: i128, + funded: i128, + is_last: bool, + distributed: i128, +) -> i128 { if total == 0 { return 0; } @@ -25,7 +32,12 @@ fn proportional_share(amount: i128, total: i128, funded: i128, is_last: bool, di } /// Platform fee and tax deduction (mirrors `_release_full` fee logic). -fn deduct_fees(proportional: i128, platform_fee_bps: u32, tax_bps: u32, is_waived: bool) -> (i128, i128, i128) { +fn deduct_fees( + proportional: i128, + platform_fee_bps: u32, + tax_bps: u32, + is_waived: bool, +) -> (i128, i128, i128) { let tax = (proportional as u128 * tax_bps as u128 / MAX_BPS as u128) as i128; let post_tax = proportional.saturating_sub(tax); let fee = if is_waived { @@ -121,9 +133,7 @@ fn zero_or_pos_i128() -> impl Strategy { } fn invoice_amounts() -> impl Strategy> { - (1usize..=20usize).prop_flat_map(|n| { - proptest::collection::vec(pos_i128(), n) - }) + (1usize..=20usize).prop_flat_map(|n| proptest::collection::vec(pos_i128(), n)) } // --------------------------------------------------------------------------- diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 33ec9b1..f277491 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -34,20 +34,21 @@ mod fuzz_tests; #[cfg(test)] mod storage_snapshot; +use error::ContractError; +use soroban_sdk::xdr::ToXdr; use soroban_sdk::{ - String, - contract, contractimpl, symbol_short, token, Address, Bytes, BytesN, Env, IntoVal, Map, Symbol, Val, Vec, + contract, contractimpl, symbol_short, token, Address, Bytes, BytesN, Env, IntoVal, Map, String, + Symbol, TryFromVal, Val, Vec, }; -use soroban_sdk::xdr::ToXdr; use types::{ - AdminRole, AuditEntry, Bid, CloneOverrides, CompactInvoice, CompletionProof, CreatorStats, - CreateInvoiceParams, FeeTier, Invoice, InvoiceCore, InvoiceExt, InvoiceExt2, InvoiceExt3, - InvoiceHot, InvoiceOptions, InvoicePayment, InvoiceStatus, InvoiceTemplate, LegacyInvoice, - OverflowBehavior, Payment, PaymentCertificate, PaymentProof, QueuedAction, ResolveAction, - ResolveRule, SplitRule, SubscriptionParams, TimelockAction, Tranche, TreasuryRecord, - SimulateReleaseResult, CircuitBreakerStatus, ConfidentialPayment, UpgradeProposal, - DisputeRecord, DisputeStatus, DisputeOutcome, ProtocolFeeConfig, ComputeEstimate, - CompactMigrateResult, + AdminRole, AuditEntry, Bid, CircuitBreakerStatus, CloneOverrides, CompactInvoice, + CompactMigrateResult, CompletionProof, ComputeEstimate, ConfidentialPayment, + CreateInvoiceParams, CreatorStats, DisputeOutcome, DisputeRecord, DisputeStatus, FeeTier, + Invoice, InvoiceCore, InvoiceExt, InvoiceExt2, InvoiceExt3, InvoiceHot, InvoiceOptions, + InvoicePayment, InvoiceStatus, InvoiceTemplate, LegacyInvoice, OverflowBehavior, Payment, + PaymentCertificate, PaymentProof, ProtocolFeeConfig, QueuedAction, RepScore, ResolveAction, + ResolveRule, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, Tranche, + TreasuryRecord, UpgradeProposal, }; // --------------------------------------------------------------------------- @@ -239,8 +240,17 @@ fn template_key(creator: &Address, name: &Symbol) -> (Symbol, Address, Symbol) { } /// Issue #210: versioned template key. -fn template_version_key(creator: &Address, name: &Symbol, version: u32) -> (Symbol, Address, Symbol, u32) { - (symbol_short!("tmpl_v"), creator.clone(), name.clone(), version) +fn template_version_key( + creator: &Address, + name: &Symbol, + version: u32, +) -> (Symbol, Address, Symbol, u32) { + ( + symbol_short!("tmpl_v"), + creator.clone(), + name.clone(), + version, + ) } /// Issue #210: template version counter key. @@ -253,11 +263,29 @@ fn pending_payout_key(invoice_id: u64, recipient: &Address) -> (Symbol, u64, Add (symbol_short!("pend_pay"), invoice_id, recipient.clone()) } -/// Per-address reputation counter key (issue #24). +/// Per-address reputation counter key (issue #24, #349). fn rep_key(payer: &Address) -> (Symbol, Address) { (symbol_short!("rep"), payer.clone()) } +fn get_rep_internal(env: &Env, address: &Address) -> RepScore { + env.storage() + .persistent() + .get(&rep_key(address)) + .unwrap_or_default() +} + +fn update_rep_internal(env: &Env, address: &Address, update_fn: F) -> RepScore +where + F: FnOnce(&mut RepScore), +{ + let mut score = get_rep_internal(env, address); + update_fn(&mut score); + env.storage().persistent().set(&rep_key(address), &score); + events::rep_updated(env, address, &score); + score +} + /// Per-address credit score key (issue #38). fn credit_key(payer: &Address) -> (Symbol, Address) { (symbol_short!("credit"), payer.clone()) @@ -487,7 +515,11 @@ fn compute_shard_id(env: &Env, payer: &Address) -> u64 { } fn require_admin(env: &Env) -> Address { - let admin: Address = env.storage().instance().get(&admin_key()).expect("admin not set"); + let admin: Address = env + .storage() + .instance() + .get(&admin_key()) + .expect("admin not set"); admin.require_auth(); admin } @@ -541,7 +573,11 @@ fn refunded_key(invoice_id: u64) -> (Symbol, u64) { } fn maybe_record_created(env: &Env, creator: &Address, total: i128) { - if let Some(dashboard) = env.storage().persistent().get::(&dashboard_contract_key()) { + if let Some(dashboard) = env + .storage() + .persistent() + .get::(&dashboard_contract_key()) + { let _: Val = env.invoke_contract( &dashboard, &Symbol::new(env, "record_created"), @@ -551,7 +587,11 @@ fn maybe_record_created(env: &Env, creator: &Address, total: i128) { } fn maybe_record_released(env: &Env, creator: &Address, amount: i128) { - if let Some(dashboard) = env.storage().persistent().get::(&dashboard_contract_key()) { + if let Some(dashboard) = env + .storage() + .persistent() + .get::(&dashboard_contract_key()) + { let _: Val = env.invoke_contract( &dashboard, &Symbol::new(env, "record_released"), @@ -571,21 +611,34 @@ fn update_creator_stats_on_creation(env: &Env, creator: &Address) { fn update_creator_stats_on_payment(env: &Env, creator: &Address, amount: i128) { let volume_key = creator_stats_volume_key(creator); let volume: u64 = env.storage().persistent().get(&volume_key).unwrap_or(0u64); - env.storage().persistent().set(&volume_key, &(volume + amount as u64)); + env.storage() + .persistent() + .set(&volume_key, &(volume + amount as u64)); } /// Issue #299: Update creator stats on release. fn update_creator_stats_on_release(env: &Env, creator: &Address, amount: i128) { let released_key = creator_stats_released_key(creator); - let released: u64 = env.storage().persistent().get(&released_key).unwrap_or(0u64); - env.storage().persistent().set(&released_key, &(released + amount as u64)); + let released: u64 = env + .storage() + .persistent() + .get(&released_key) + .unwrap_or(0u64); + env.storage() + .persistent() + .set(&released_key, &(released + amount as u64)); } /// Issue #299: Update creator unique payers count (call after recording payment). fn update_creator_payers(env: &Env, creator: &Address, payer: &Address) { // Track unique payers using a set-like approach via a key pattern let payer_key = (symbol_short!("cr_py_set"), creator.clone(), payer.clone()); - if env.storage().persistent().get::<(Symbol, Address, Address), bool>(&payer_key).is_none() { + if env + .storage() + .persistent() + .get::<(Symbol, Address, Address), bool>(&payer_key) + .is_none() + { env.storage().persistent().set(&payer_key, &true); let payers_key = creator_stats_payers_key(creator); let payers: u64 = env.storage().persistent().get(&payers_key).unwrap_or(0u64); @@ -620,33 +673,75 @@ fn bump_invoice_ttl(env: &Env) { // --------------------------------------------------------------------------- fn archive_invoice_storage(env: &Env, id: u64, core: &InvoiceCore) { - let ext: InvoiceExt = env.storage().persistent() + let ext: InvoiceExt = env + .storage() + .persistent() .get(&invoice_ext_key(id)) .or_else(|| env.storage().instance().get(&invoice_ext_key(id))) .unwrap_or_else(|| InvoiceExt { - co_signers: Vec::new(env), required_signatures: 0, signatures: Vec::new(env), - approver: None, approved: false, oracle_address: None, condition_met: false, - penalty_bps: 0, penalty_deadline: 0, min_funding_bps: 0, - release_stages: Vec::new(env), released_stages: 0, allowed_payers: None, - price_oracle: None, base_amounts: Vec::new(env), swap_tokens: Vec::new(env), - tax_bps: 0, tax_authority: None, insurance_premium_bps: 0, insurance_fund: 0, - smart_route: false, convert_to_stream: false, accepted_tokens: Vec::new(env), - forward_to: None, forward_invoice_id: None, split_rules: Vec::new(env), - auto_resolve_rules: Vec::new(env), creator_cosigner: None, velocity_limit: 0, - velocity_window: 0, parent_invoice_id: None, pause_reason: None, auto_resume_at: None, - payment_cooldown_secs: None, max_payments_per_window: None, payment_window_secs: None, - scheduled_release_at: None, refund_grace_secs: None, - penalty_tiers: Vec::new(env), allowed_callers: None, + co_signers: Vec::new(env), + required_signatures: 0, + signatures: Vec::new(env), + approver: None, + approved: false, + oracle_address: None, + condition_met: false, + penalty_bps: 0, + penalty_deadline: 0, + min_funding_bps: 0, + release_stages: Vec::new(env), + released_stages: 0, + allowed_payers: None, + price_oracle: None, + base_amounts: Vec::new(env), + swap_tokens: Vec::new(env), + tax_bps: 0, + tax_authority: None, + insurance_premium_bps: 0, + insurance_fund: 0, + smart_route: false, + convert_to_stream: false, + accepted_tokens: Vec::new(env), + forward_to: None, + forward_invoice_id: None, + split_rules: Vec::new(env), + auto_resolve_rules: Vec::new(env), + creator_cosigner: None, + velocity_limit: 0, + velocity_window: 0, + parent_invoice_id: None, + pause_reason: None, + auto_resume_at: None, + payment_cooldown_secs: None, + max_payments_per_window: None, + payment_window_secs: None, + scheduled_release_at: None, + refund_grace_secs: None, + penalty_tiers: Vec::new(env), + allowed_callers: None, }); - let ext2: InvoiceExt2 = env.storage().persistent() + let ext2: InvoiceExt2 = env + .storage() + .persistent() .get(&invoice_ext2_key(id)) .or_else(|| env.storage().instance().get(&invoice_ext2_key(id))) .unwrap_or_else(|| InvoiceExt2 { - notification_contract: None, overflow_behavior: OverflowBehavior::Reject, - cross_chain_ref: None, require_kyc: false, arbiter: None, disputed: false, - admin_frozen: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(env), - min_payment: 0, min_funding_amount: 0, priorities: Vec::new(env), - target_usd_cents: None, refunded_addresses: Vec::new(env), + notification_contract: None, + overflow_behavior: OverflowBehavior::Reject, + cross_chain_ref: None, + require_kyc: false, + arbiter: None, + disputed: false, + admin_frozen: false, + auction_on_expiry: false, + auction_end: 0, + bids: Vec::new(env), + min_payment: 0, + min_funding_amount: 0, + priorities: Vec::new(env), + target_usd_cents: None, + refunded_addresses: Vec::new(env), + min_payer_rep: None, }); env.storage().instance().set(&invoice_key(id), core); @@ -654,23 +749,36 @@ fn archive_invoice_storage(env: &Env, id: u64, core: &InvoiceCore) { env.storage().instance().set(&invoice_ext2_key(id), &ext2); env.storage().instance().set(&archive_marker_key(id), &true); - let compact = env.storage().persistent().get::<_, CompactInvoice>(&invoice_compact_key(id)) + let compact = env + .storage() + .persistent() + .get::<_, CompactInvoice>(&invoice_compact_key(id)) .or_else(|| env.storage().instance().get(&invoice_compact_key(id))) .unwrap_or_else(|| { let invoice = Invoice::assemble(core.clone(), ext.clone(), ext2.clone()); invoice.to_compact(env) }); - env.storage().instance().set(&invoice_compact_key(id), &compact); + env.storage() + .instance() + .set(&invoice_compact_key(id), &compact); for shard_id in 0..SHARD_COUNT { let shard_key = pay_shard_key(id, shard_id); - if let Some(payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&shard_key) { + if let Some(payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&shard_key) + { env.storage().instance().set(&shard_key, &payments); } env.storage().persistent().remove(&shard_key); } - if let Some(audit_log) = env.storage().persistent().get::<_, Vec>(&audit_log_key(id)) { + if let Some(audit_log) = env + .storage() + .persistent() + .get::<_, Vec>(&audit_log_key(id)) + { env.storage().instance().set(&audit_log_key(id), &audit_log); } env.storage().persistent().remove(&invoice_key(id)); @@ -678,11 +786,19 @@ fn archive_invoice_storage(env: &Env, id: u64, core: &InvoiceCore) { env.storage().persistent().remove(&invoice_ext2_key(id)); env.storage().persistent().remove(&invoice_compact_key(id)); env.storage().persistent().remove(&audit_log_key(id)); - env.storage().instance().set(&created_ledger_key(id), &env.storage().persistent().get::<_, u32>(&created_ledger_key(id)).unwrap_or_else(|| env.ledger().sequence())); + env.storage().instance().set( + &created_ledger_key(id), + &env.storage() + .persistent() + .get::<_, u32>(&created_ledger_key(id)) + .unwrap_or_else(|| env.ledger().sequence()), + ); } fn maybe_archive_invoice(env: &Env, id: u64) { - if env.storage().instance().has(&archive_marker_key(id)) || env.storage().persistent().has(&archive_marker_key(id)) { + if env.storage().instance().has(&archive_marker_key(id)) + || env.storage().persistent().has(&archive_marker_key(id)) + { return; } @@ -697,8 +813,17 @@ fn maybe_archive_invoice(env: &Env, id: u64) { return; } - let created_ledger: u64 = env.storage().persistent().get(&created_ledger_key(id)).or_else(|| env.storage().instance().get(&created_ledger_key(id))).unwrap_or_else(|| env.ledger().sequence() as u64); - let archive_after = env.storage().instance().get(&archive_after_ledgers_key()).unwrap_or(ARCHIVE_AFTER_LEDGERS); + let created_ledger: u64 = env + .storage() + .persistent() + .get(&created_ledger_key(id)) + .or_else(|| env.storage().instance().get(&created_ledger_key(id))) + .unwrap_or_else(|| env.ledger().sequence() as u64); + let archive_after = env + .storage() + .instance() + .get(&archive_after_ledgers_key()) + .unwrap_or(ARCHIVE_AFTER_LEDGERS); if (env.ledger().sequence() as u64).saturating_sub(created_ledger) < archive_after { return; } @@ -720,14 +845,20 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { let mut core: InvoiceCore = if let Some(c) = env.storage().persistent().get(&invoice_key(id)) { c } else { - env.storage().instance().get(&invoice_key(id)).expect("invoice not found") + env.storage() + .instance() + .get(&invoice_key(id)) + .expect("invoice not found") }; - + // Aggregate payments from all shards (issue #177). let mut all_payments: Vec = Vec::new(env); for shard_id in 0..SHARD_COUNT { let shard_key = pay_shard_key(id, shard_id); - if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&shard_key) + if let Some(shard_payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&shard_key) .or_else(|| env.storage().instance().get(&shard_key)) { for payment in shard_payments.iter() { @@ -736,8 +867,10 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { } } core.payments = all_payments; - - let ext: InvoiceExt = env.storage().persistent() + + let ext: InvoiceExt = env + .storage() + .persistent() .get(&invoice_ext_key(id)) .or_else(|| env.storage().instance().get(&invoice_ext_key(id))) .unwrap_or_else(|| InvoiceExt { @@ -782,7 +915,9 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { allowed_callers: None, refund_grace_secs: None, }); - let ext2: InvoiceExt2 = env.storage().persistent() + let ext2: InvoiceExt2 = env + .storage() + .persistent() .get(&invoice_ext2_key(id)) .or_else(|| env.storage().instance().get(&invoice_ext2_key(id))) .unwrap_or_else(|| InvoiceExt2 { @@ -801,10 +936,15 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { priorities: Vec::new(env), target_usd_cents: None, refunded_addresses: Vec::new(env), + min_payer_rep: None, }); - + // Load compact representation if available, then overlay hot fields. - let mut invoice = if let Some(compact) = env.storage().persistent().get::<_, CompactInvoice>(&invoice_compact_key(id)) { + let mut invoice = if let Some(compact) = env + .storage() + .persistent() + .get::<_, CompactInvoice>(&invoice_compact_key(id)) + { Invoice::from_compact(&compact, core, ext, ext2) } else { Invoice::assemble(core, ext, ext2) @@ -812,8 +952,8 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { // Hot fields are authoritative post-migration; overlay them here. if let Some(hot) = maybe_hot { - invoice.status = hot.status; - invoice.funded = hot.funded; + invoice.status = hot.status; + invoice.funded = hot.funded; invoice.recipients = hot.recipients; } @@ -834,7 +974,8 @@ fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { let mut clean_invoice = invoice.clone(); clean_invoice.payments = Vec::new(env); let (core, ext, ext2) = clean_invoice.split(); - let archived = env.storage().instance().has(&archive_marker_key(id)) || env.storage().persistent().has(&archive_marker_key(id)); + let archived = env.storage().instance().has(&archive_marker_key(id)) + || env.storage().persistent().has(&archive_marker_key(id)); if archived { env.storage().instance().set(&invoice_key(id), &core); @@ -852,10 +993,14 @@ fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { // Store compact representation in the same tier as the invoice data. let compact = invoice.to_compact(env); if archived { - env.storage().instance().set(&invoice_compact_key(id), &compact); + env.storage() + .instance() + .set(&invoice_compact_key(id), &compact); env.storage().persistent().remove(&invoice_compact_key(id)); } else { - env.storage().persistent().set(&invoice_compact_key(id), &compact); + env.storage() + .persistent() + .set(&invoice_compact_key(id), &compact); } env.storage().persistent().set(&invoice_key(id), &core); env.storage().persistent().set(&invoice_ext_key(id), &ext); @@ -863,14 +1008,16 @@ fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { // Store compact representation let compact = invoice.to_compact(env); - env.storage().persistent().set(&invoice_compact_key(id), &compact); + env.storage() + .persistent() + .set(&invoice_compact_key(id), &compact); // Write hot fields to instance storage and bump TTL. // status, funded, and recipients change on pay/release/refund paths. let total: i128 = invoice.amounts.iter().sum(); let hot = InvoiceHot { - status: invoice.status.clone(), - funded: invoice.funded, + status: invoice.status.clone(), + funded: invoice.funded, total, recipients: invoice.recipients.clone(), }; @@ -884,12 +1031,23 @@ fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { fn append_audit_entry(env: &Env, id: u64, action: Symbol, actor: &Address) { let timestamp = env.ledger().timestamp(); - let entry = AuditEntry { action, actor: actor.clone(), timestamp }; - let archived = env.storage().instance().has(&archive_marker_key(id)) || env.storage().persistent().has(&archive_marker_key(id)); + let entry = AuditEntry { + action, + actor: actor.clone(), + timestamp, + }; + let archived = env.storage().instance().has(&archive_marker_key(id)) + || env.storage().persistent().has(&archive_marker_key(id)); let mut log: Vec = if archived { - env.storage().instance().get(&audit_log_key(id)).unwrap_or_else(|| Vec::new(env)) + env.storage() + .instance() + .get(&audit_log_key(id)) + .unwrap_or_else(|| Vec::new(env)) } else { - env.storage().persistent().get(&audit_log_key(id)).unwrap_or_else(|| Vec::new(env)) + env.storage() + .persistent() + .get(&audit_log_key(id)) + .unwrap_or_else(|| Vec::new(env)) }; log.push_back(entry); if archived { @@ -900,7 +1058,12 @@ fn append_audit_entry(env: &Env, id: u64, action: Symbol, actor: &Address) { } } -fn notify_invoice(env: &Env, invoice_id: u64, event: Symbol, notification_contract: &Option
) { +fn notify_invoice( + env: &Env, + invoice_id: u64, + event: Symbol, + notification_contract: &Option
, +) { if let Some(contract) = notification_contract { let args = (invoice_id, event).into_val(env); let _: Val = env.invoke_contract(contract, &Symbol::new(env, "notify"), args); @@ -921,9 +1084,15 @@ pub fn get_audit_log(env: &Env, id: u64) -> Vec { fn is_paused(env: &Env) -> bool { // Issue #328: primary store is instance; fall back to persistent for migration compat. - env.storage().instance().get(&paused_key()).unwrap_or_else(|| - env.storage().persistent().get(&paused_key()).unwrap_or(false) - ) + env.storage() + .instance() + .get(&paused_key()) + .unwrap_or_else(|| { + env.storage() + .persistent() + .get(&paused_key()) + .unwrap_or(false) + }) } fn require_not_paused(env: &Env) { @@ -976,7 +1145,11 @@ fn require_fn_not_paused(env: &Env, name: &Symbol) { fn load_group(env: &Env, group_id: u64) -> Vec { // New groups are stored as InvoiceGroup; fall back for legacy Vec groups. - if let Some(grp) = env.storage().persistent().get::<_, types::InvoiceGroup>(&group_key(group_id)) { + if let Some(grp) = env + .storage() + .persistent() + .get::<_, types::InvoiceGroup>(&group_key(group_id)) + { grp.invoice_ids } else { env.storage() @@ -1018,7 +1191,11 @@ fn treasury_record_for_invoice(env: &Env, invoice_id: u64) -> Option<(u64, Treas .persistent() .get::<(Symbol, u64), u64>(&invoice_treasury_key(invoice_id)) { - if let Some(record) = env.storage().persistent().get(&group_treasury_key(group_id)) { + if let Some(record) = env + .storage() + .persistent() + .get(&group_treasury_key(group_id)) + { return Some((group_id, record)); } } @@ -1041,8 +1218,12 @@ fn load_treasury_record(env: &Env, group_id: u64) -> TreasuryRecord { /// Called once at invoice creation (or migration). During release we load /// both vecs with two `get()` calls instead of N per-recipient reads. fn save_recipients_list(env: &Env, id: u64, recipients: &Vec
, amounts: &Vec) { - env.storage().persistent().set(&recipients_list_key(id), recipients); - env.storage().persistent().set(&amounts_list_key(id), amounts); + env.storage() + .persistent() + .set(&recipients_list_key(id), recipients); + env.storage() + .persistent() + .set(&amounts_list_key(id), amounts); } /// Load the contiguous recipient list. Falls back to the invoice struct's @@ -1155,29 +1336,60 @@ fn maybe_record_refunded(env: &Env, creator: &Address) { // Contract // --------------------------------------------------------------------------- - /// Issue #276: Check and emit platform volume milestone events. fn check_platform_milestone(env: &Env, new_volume: i128) { - let threshold: i128 = env.storage().persistent().get(&platform_vol_thresh_key()).unwrap_or(10_000_0000000i128); // 10,000 USDC (7 decimals) - if threshold <= 0 { return; } - let last_milestone: i128 = env.storage().persistent().get(&platform_vol_mile_key()).unwrap_or(0i128); + let threshold: i128 = env + .storage() + .persistent() + .get(&platform_vol_thresh_key()) + .unwrap_or(10_000_000_000_i128); // 10,000 USDC (7 decimals) + if threshold <= 0 { + return; + } + let last_milestone: i128 = env + .storage() + .persistent() + .get(&platform_vol_mile_key()) + .unwrap_or(0i128); let new_milestone = new_volume / threshold; if new_milestone > last_milestone { - env.storage().persistent().set(&platform_vol_mile_key(), &new_milestone); - let invoice_count: u64 = env.storage().persistent().get(&total_invoices_key()).unwrap_or(0u64); + env.storage() + .persistent() + .set(&platform_vol_mile_key(), &new_milestone); + let invoice_count: u64 = env + .storage() + .persistent() + .get(&total_invoices_key()) + .unwrap_or(0u64); events::platform_volume_milestone(env, new_volume, invoice_count, new_milestone); } } /// Issue #276: Check and emit creator volume milestone events. fn check_creator_milestone(env: &Env, creator: &Address, new_volume: i128) { - let threshold: i128 = env.storage().persistent().get(&creator_vol_thresh_key()).unwrap_or(1_000_0000000i128); // 1,000 USDC (7 decimals) - if threshold <= 0 { return; } - let last_milestone: i128 = env.storage().persistent().get(&creator_vol_mile_key(creator)).unwrap_or(0i128); + let threshold: i128 = env + .storage() + .persistent() + .get(&creator_vol_thresh_key()) + .unwrap_or(1_000_000_000_i128); // 1,000 USDC (7 decimals) + if threshold <= 0 { + return; + } + let last_milestone: i128 = env + .storage() + .persistent() + .get(&creator_vol_mile_key(creator)) + .unwrap_or(0i128); let new_milestone = new_volume / threshold; if new_milestone > last_milestone { - env.storage().persistent().set(&creator_vol_mile_key(creator), &new_milestone); - let invoice_count: u64 = env.storage().persistent().get(&creator_stats_count_key(creator)).unwrap_or(0u64); + env.storage() + .persistent() + .set(&creator_vol_mile_key(creator), &new_milestone); + let invoice_count: u64 = env + .storage() + .persistent() + .get(&creator_stats_count_key(creator)) + .unwrap_or(0u64); events::creator_volume_milestone(env, creator, new_volume, invoice_count, new_milestone); } } @@ -1207,23 +1419,43 @@ impl SplitContract { "already initialized" ); assert!(creation_fee >= 0, "creation_fee must be non-negative"); - assert!(platform_fee_bps <= 10_000, "platform_fee_bps must be ≤ 10000"); + assert!( + platform_fee_bps <= 10_000, + "platform_fee_bps must be ≤ 10000" + ); assert!(max_cancel_bps <= 10_000, "max_cancel_bps must be ≤ 10000"); - assert!(rate_window > 0 || rate_limit == 0, "rate_window must be positive when rate_limit is enabled"); + assert!( + rate_window > 0 || rate_limit == 0, + "rate_window must be positive when rate_limit is enabled" + ); let mut admins: Map = Map::new(&env); admins.set(admin.clone(), AdminRole::SuperAdmin); env.storage().instance().set(&admins_key(), &admins); env.storage().instance().set(&admin_key(), &admin); - env.storage().instance().set(&creation_fee_key(), &creation_fee); + env.storage() + .instance() + .set(&creation_fee_key(), &creation_fee); env.storage().instance().set(&treasury_key(), &treasury); env.storage().instance().set(&usdc_token_key(), &usdc_token); - env.storage().instance().set(&platform_fee_bps_key(), &platform_fee_bps); - env.storage().instance().set(&governance_contract_key(), &governance_contract); - env.storage().instance().set(&archive_after_ledgers_key(), &ARCHIVE_AFTER_LEDGERS); + env.storage() + .instance() + .set(&platform_fee_bps_key(), &platform_fee_bps); + env.storage() + .instance() + .set(&governance_contract_key(), &governance_contract); + env.storage() + .instance() + .set(&archive_after_ledgers_key(), &ARCHIVE_AFTER_LEDGERS); env.storage().persistent().set(&paused_key(), &false); - env.storage().persistent().set(&max_cancel_bps_key(), &max_cancel_bps); - env.storage().persistent().set(&rate_limit_key(), &rate_limit); - env.storage().persistent().set(&rate_window_key(), &rate_window); + env.storage() + .persistent() + .set(&max_cancel_bps_key(), &max_cancel_bps); + env.storage() + .persistent() + .set(&rate_limit_key(), &rate_limit); + env.storage() + .persistent() + .set(&rate_window_key(), &rate_window); } /// Add a new admin with a given role. Requires SuperAdmin auth. @@ -1247,7 +1479,10 @@ impl SplitContract { .instance() .get(&admins_key()) .expect("admins not set"); - assert!(admins.get(target.clone()).is_some(), "target is not an admin"); + assert!( + admins.get(target.clone()).is_some(), + "target is not an admin" + ); let mut super_admin_count: u32 = 0; for (_, r) in admins.iter() { if r == AdminRole::SuperAdmin { @@ -1294,7 +1529,9 @@ impl SplitContract { if !paused_fns.iter().any(|f| f == function) { paused_fns.push_back(function); } - env.storage().persistent().set(&paused_fns_key(), &paused_fns); + env.storage() + .persistent() + .set(&paused_fns_key(), &paused_fns); } /// Unpause a specific function by name. Requires Operator+ auth. @@ -1319,9 +1556,13 @@ impl SplitContract { pub fn set_pause_exempt(env: Env, admin: Address, address: Address, exempt: bool) { require_role(&env, &admin, AdminRole::Operator); if exempt { - env.storage().persistent().set(&pause_exempt_key(&address), &true); + env.storage() + .persistent() + .set(&pause_exempt_key(&address), &true); } else { - env.storage().persistent().remove(&pause_exempt_key(&address)); + env.storage() + .persistent() + .remove(&pause_exempt_key(&address)); } } @@ -1329,15 +1570,21 @@ impl SplitContract { pub fn set_global_payer_limit(env: Env, admin: Address, limit: i128, window_secs: u64) { require_role(&env, &admin, AdminRole::Operator); assert!(limit >= 0, "limit must be non-negative"); - env.storage().persistent().set(&global_payer_limit_key(), &limit); - env.storage().persistent().set(&global_payer_window_key(), &window_secs); + env.storage() + .persistent() + .set(&global_payer_limit_key(), &limit); + env.storage() + .persistent() + .set(&global_payer_window_key(), &window_secs); } /// Update the creation fee. Requires admin auth. pub fn set_creation_fee(env: Env, admin: Address, creation_fee: i128) { require_role(&env, &admin, AdminRole::Operator); assert!(creation_fee >= 0, "creation_fee must be non-negative"); - env.storage().instance().set(&creation_fee_key(), &creation_fee); + env.storage() + .instance() + .set(&creation_fee_key(), &creation_fee); } /// Update the treasury address. Requires admin auth. @@ -1351,7 +1598,9 @@ impl SplitContract { pub fn set_archive_after_ledgers(env: Env, admin: Address, ledgers: u64) { require_role(&env, &admin, AdminRole::Operator); assert!(ledgers > 0, "archive_after_ledgers must be positive"); - env.storage().instance().set(&archive_after_ledgers_key(), &ledgers); + env.storage() + .instance() + .set(&archive_after_ledgers_key(), &ledgers); } /// Return the configured ledger threshold for lazy invoice archival. @@ -1369,13 +1618,17 @@ impl SplitContract { /// Store the address of the Stellar payment streaming contract. Requires admin auth. pub fn set_stream_contract(env: Env, admin: Address, contract: Address) { require_role(&env, &admin, AdminRole::Operator); - env.storage().persistent().set(&stream_contract_key(), &contract); + env.storage() + .persistent() + .set(&stream_contract_key(), &contract); } /// Store the DEX contract address used for token swaps in pay_with_token(). Requires admin auth. pub fn set_dex_contract(env: Env, admin: Address, contract: Address) { require_role(&env, &admin, AdminRole::Operator); - env.storage().persistent().set(&soroban_sdk::symbol_short!("dex_ctr"), &contract); + env.storage() + .persistent() + .set(&soroban_sdk::symbol_short!("dex_ctr"), &contract); } // ----------------------------------------------------------------------- @@ -1386,7 +1639,9 @@ impl SplitContract { pub fn propose_admin(env: Env, admin: Address, new_admin: Address) { require_admin(&env); let _ = admin; - env.storage().instance().set(&pending_admin_key(), &new_admin); + env.storage() + .instance() + .set(&pending_admin_key(), &new_admin); } /// Accept the admin role. Requires the proposed admin to authenticate. @@ -1411,7 +1666,9 @@ impl SplitContract { require_admin(&env); let _ = admin; assert!(cap >= 0, "cap must be non-negative"); - env.storage().persistent().set(&creator_volume_cap_key(&creator), &cap); + env.storage() + .persistent() + .set(&creator_volume_cap_key(&creator), &cap); } /// Return the volume cap for a creator (0 = no limit). @@ -1440,7 +1697,9 @@ impl SplitContract { require_admin(&env); let _ = admin; assert!(threshold >= 0, "threshold must be non-negative"); - env.storage().persistent().set(&platform_vol_thresh_key(), &threshold); + env.storage() + .persistent() + .set(&platform_vol_thresh_key(), &threshold); } /// Set the per-creator volume milestone threshold (in token base units). @@ -1449,7 +1708,9 @@ impl SplitContract { require_admin(&env); let _ = admin; assert!(threshold >= 0, "threshold must be non-negative"); - env.storage().persistent().set(&creator_vol_thresh_key(), &threshold); + env.storage() + .persistent() + .set(&creator_vol_thresh_key(), &threshold); } // ----------------------------------------------------------------------- @@ -1511,16 +1772,20 @@ impl SplitContract { // If the invoice has no payments, mark as cancelled. if invoice.funded == 0 { invoice.status = InvoiceStatus::Cancelled; - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Cancelled, &arbiter); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Cancelled, + &arbiter, + ); save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("resolve"), &arbiter); return; } - let token_client = token::Client::new( - &env, - &invoice.tokens.get(0).expect("no token"), - ); + let token_client = + token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); let mut totals: Map = Map::new(&env); for payment in invoice.payments.iter() { let prev = totals.get(payment.payer.clone()).unwrap_or(0); @@ -1528,11 +1793,7 @@ impl SplitContract { } let mut total_refunded_amount: i128 = 0; for (payer, amount) in totals.iter() { - token_client.transfer( - &env.current_contract_address(), - &payer, - &amount, - ); + token_client.transfer(&env.current_contract_address(), &payer, &amount); total_refunded_amount += amount; events::payer_refunded(&env, invoice_id, &payer, amount); } @@ -1550,7 +1811,13 @@ impl SplitContract { save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("resolve"), &arbiter); events::invoice_refunded(&env, invoice_id); - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Refunded, &arbiter); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Refunded, + &arbiter, + ); let total_refunded: i128 = env .storage() @@ -1575,7 +1842,9 @@ impl SplitContract { /// The factory must expose: mint_receipt(invoice_id: u64, payer: Address, amount: i128) -> Address pub fn set_receipt_factory(env: Env, admin: Address, factory: Address) { require_role(&env, &admin, AdminRole::Operator); - env.storage().persistent().set(&receipt_factory_key(), &factory); + env.storage() + .persistent() + .set(&receipt_factory_key(), &factory); } /// Return the receipt token address minted for a specific payer on a specific invoice. @@ -1591,14 +1860,14 @@ impl SplitContract { pub fn set_dashboard_contract(env: Env, admin: Address, dashboard: Address) { require_admin(&env); let _ = admin; - env.storage().persistent().set(&dashboard_contract_key(), &dashboard); + env.storage() + .persistent() + .set(&dashboard_contract_key(), &dashboard); } /// Return the dashboard contract address, or None if not set. pub fn get_dashboard_contract(env: Env) -> Option
{ - env.storage() - .persistent() - .get(&dashboard_contract_key()) + env.storage().persistent().get(&dashboard_contract_key()) } // ----------------------------------------------------------------------- @@ -1622,7 +1891,9 @@ impl SplitContract { if !wl.iter().any(|a| a == address) { wl.push_back(address); } - env.storage().persistent().set(&creator_whitelist_key(), &wl); + env.storage() + .persistent() + .set(&creator_whitelist_key(), &wl); } /// Remove an address from the creator whitelist. Requires admin auth. @@ -1639,7 +1910,9 @@ impl SplitContract { new_wl.push_back(a); } } - env.storage().persistent().set(&creator_whitelist_key(), &new_wl); + env.storage() + .persistent() + .set(&creator_whitelist_key(), &new_wl); } // ----------------------------------------------------------------------- @@ -1658,7 +1931,9 @@ impl SplitContract { if !waivers.iter().any(|a| a == address) { waivers.push_back(address); } - env.storage().persistent().set(&platform_fee_waiver_list_key(), &waivers); + env.storage() + .persistent() + .set(&platform_fee_waiver_list_key(), &waivers); } /// Remove an address from the platform fee waiver list. Requires admin auth. @@ -1675,7 +1950,9 @@ impl SplitContract { new_waivers.push_back(a); } } - env.storage().persistent().set(&platform_fee_waiver_list_key(), &new_waivers); + env.storage() + .persistent() + .set(&platform_fee_waiver_list_key(), &new_waivers); } /// Check if an address is on the platform fee waiver list. @@ -1775,25 +2052,42 @@ impl SplitContract { priorities: Vec::new(&env), target_usd_cents: None, refunded_addresses: Vec::new(&env), + min_payer_rep: None, }) }); let audit_log: Vec = get_audit_log(&env, invoice_id); - types::InvoiceSnapshot { core, ext, ext2, audit_log } + types::InvoiceSnapshot { + core, + ext, + ext2, + audit_log, + } } /// Issue #327 / #329 / #330: Return extended per-invoice fields not present in InvoiceSnapshot. pub fn get_invoice_ext3(env: Env, invoice_id: u64) -> InvoiceExt3 { - let release_delay: Option = env.storage().persistent().get(&release_delay_key(invoice_id)); - let funded_at: Option = env.storage().persistent().get(&funded_at_ledger_key(invoice_id)); + let release_delay: Option = env + .storage() + .persistent() + .get(&release_delay_key(invoice_id)); + let funded_at: Option = env + .storage() + .persistent() + .get(&funded_at_ledger_key(invoice_id)); let unlock_at: Option = match (funded_at, release_delay) { (Some(fa), Some(d)) => Some(fa.saturating_add(d)), _ => None, }; - let metadata_hash: Option> = env.storage().persistent().get(&metadata_hash_key(invoice_id)); - let paid_recipients: Vec
= env.storage().persistent() - .get(&paid_recipients_key(invoice_id)) - .unwrap_or_else(|| Vec::new(&env)); - InvoiceExt3 { + let metadata_hash: Option> = env + .storage() + .persistent() + .get(&metadata_hash_key(invoice_id)); + let paid_recipients: Vec
= env + .storage() + .persistent() + .get(&paid_recipients_key(invoice_id)) + .unwrap_or_else(|| Vec::new(&env)); + InvoiceExt3 { release_delay_ledgers: release_delay, funded_at_ledger: funded_at, unlock_at_ledger: unlock_at, @@ -1850,11 +2144,14 @@ impl SplitContract { for i in 1..tiers.len() { let prev = tiers.get(i - 1).unwrap(); let curr = tiers.get(i).unwrap(); - debug_assert!(prev.volume_threshold < curr.volume_threshold, "Fee tiers must be sorted by volume_threshold"); + debug_assert!( + prev.volume_threshold < curr.volume_threshold, + "Fee tiers must be sorted by volume_threshold" + ); } env.storage().instance().set(&fee_tiers_key(), &tiers); - events::fee_tiers_updated(&env, tiers.len() as u32); + events::fee_tiers_updated(&env, tiers.len()); } /// Get the applicable fee in basis points for a creator based on their lifetime volume. @@ -1987,8 +2284,12 @@ impl SplitContract { executed: false, }; - env.storage().persistent().set(&timelock_action_key(counter), &queued); - env.storage().persistent().set(&timelock_action_counter_key(), &counter); + env.storage() + .persistent() + .set(&timelock_action_key(counter), &queued); + env.storage() + .persistent() + .set(&timelock_action_counter_key(), &counter); append_audit_entry(&env, 0, Symbol::new(&env, "queue"), &admin_addr); events::action_queued(&env, counter, &action, &admin_addr); @@ -2024,14 +2325,23 @@ impl SplitContract { } TimelockAction::SetPlatformFee(new_fee) => { assert!(*new_fee <= 10_000, "platform_fee_bps must be ≤ 10000"); - env.storage().instance().set(&platform_fee_bps_key(), new_fee); + env.storage() + .instance() + .set(&platform_fee_bps_key(), new_fee); } } queued.executed = true; - env.storage().persistent().set(&timelock_action_key(action_id), &queued); + env.storage() + .persistent() + .set(&timelock_action_key(action_id), &queued); - append_audit_entry(&env, 0, Symbol::new(&env, "exec"), &env.current_contract_address()); + append_audit_entry( + &env, + 0, + Symbol::new(&env, "exec"), + &env.current_contract_address(), + ); events::action_executed(&env, action_id, &queued.action); } @@ -2048,7 +2358,9 @@ impl SplitContract { assert!(!queued.executed, "action already executed"); - env.storage().persistent().remove(&timelock_action_key(action_id)); + env.storage() + .persistent() + .remove(&timelock_action_key(action_id)); append_audit_entry(&env, 0, Symbol::new(&env, "cancel"), &admin_addr); events::action_cancelled(&env, action_id, &queued.action, &admin_addr); @@ -2108,11 +2420,19 @@ impl SplitContract { options: InvoiceOptions, ) -> u64 { // Issue #297: circuit breaker blocks all creation, no exemptions. - let cb_active: bool = env.storage().persistent().get(&circuit_breaker_key()).unwrap_or(false); + let cb_active: bool = env + .storage() + .persistent() + .get(&circuit_breaker_key()) + .unwrap_or(false); assert!(!cb_active, "ContractPaused"); // Check if contract is paused, but allow exempt creators let is_paused = is_paused(&env); - let is_exempt = env.storage().persistent().get::<_, bool>(&pause_exempt_key(&creator)).unwrap_or(false); + let is_exempt = env + .storage() + .persistent() + .get::<_, bool>(&pause_exempt_key(&creator)) + .unwrap_or(false); if is_paused && !is_exempt { panic!("contract is paused"); } @@ -2130,7 +2450,12 @@ impl SplitContract { } // Issue #192: NFT gate — creator must hold at least one NFT from the gate contract. - if let Some(nft_contract) = env.storage().persistent().get::<_, Option
>(&nft_gate_key()).unwrap_or(None) { + if let Some(nft_contract) = env + .storage() + .persistent() + .get::<_, Option
>(&nft_gate_key()) + .unwrap_or(None) + { let balance: i128 = env.invoke_contract( &nft_contract, &Symbol::new(&env, "balance_of"), @@ -2185,6 +2510,7 @@ impl SplitContract { options.priorities, options.require_kyc, options.scheduled_release_at, + options.min_payer_rep, None, // release_delay_ledgers (use create_invoice_ext for this) None, // metadata_hash None, // target_usd_cents @@ -2238,6 +2564,7 @@ impl SplitContract { priorities: Vec, require_kyc: bool, scheduled_release_at: Option, + min_payer_rep: Option, release_delay_ledgers: Option, metadata_hash: Option>, target_usd_cents: Option, @@ -2247,14 +2574,23 @@ impl SplitContract { "recipients and amounts length mismatch" ); assert!(!recipients.is_empty(), "must have at least one recipient"); - assert!(deadline > env.ledger().timestamp(), "deadline must be in the future"); + assert!( + deadline > env.ledger().timestamp(), + "deadline must be in the future" + ); assert!(bonus_pool >= 0, "bonus_pool must be non-negative"); assert!(penalty_bps <= 10_000, "penalty_bps must be ≤ 10000"); assert!(min_funding_bps <= 10_000, "min_funding_bps must be ≤ 10000"); assert!(tax_bps <= 10_000, "tax_bps must be ≤ 10000"); - assert!(insurance_premium_bps <= 10_000, "insurance_premium_bps must be ≤ 10000"); + assert!( + insurance_premium_bps <= 10_000, + "insurance_premium_bps must be ≤ 10000" + ); if tax_bps > 0 { - assert!(tax_authority.is_some(), "tax_authority must be set if tax_bps > 0"); + assert!( + tax_authority.is_some(), + "tax_authority must be set if tax_bps > 0" + ); } if !priorities.is_empty() { assert!( @@ -2279,12 +2615,24 @@ impl SplitContract { } } - if let Some(compliance_contract) = env.storage().persistent().get::<_, Address>(&soroban_sdk::symbol_short!("comp_ctr")) { - let creator_ok: bool = env.invoke_contract(&compliance_contract, &soroban_sdk::Symbol::new(env, "check"), (creator.clone(),).into_val(env)); + if let Some(compliance_contract) = env + .storage() + .persistent() + .get::<_, Address>(&soroban_sdk::symbol_short!("comp_ctr")) + { + let creator_ok: bool = env.invoke_contract( + &compliance_contract, + &soroban_sdk::Symbol::new(env, "check"), + (creator.clone(),).into_val(env), + ); assert!(creator_ok, "compliance check failed"); - + for recipient in recipients.iter() { - let recipient_ok: bool = env.invoke_contract(&compliance_contract, &soroban_sdk::Symbol::new(env, "check"), (recipient.clone(),).into_val(env)); + let recipient_ok: bool = env.invoke_contract( + &compliance_contract, + &soroban_sdk::Symbol::new(env, "check"), + (recipient.clone(),).into_val(env), + ); assert!(recipient_ok, "compliance check failed"); } } @@ -2295,12 +2643,18 @@ impl SplitContract { if !tranches.is_empty() { let total_bps: u32 = tranches.iter().map(|t| t.basis_points).sum(); - assert!(total_bps == 10_000, "tranches must sum to 10000 basis points"); + assert!( + total_bps == 10_000, + "tranches must sum to 10000 basis points" + ); } if !release_stages.is_empty() { let total_bps: u32 = release_stages.iter().sum(); - assert!(total_bps == 10_000, "release_stages must sum to 10000 basis points"); + assert!( + total_bps == 10_000, + "release_stages must sum to 10000 basis points" + ); } // Issue: validate split_rules — must have one rule per recipient, rules must sum to 100%. @@ -2325,19 +2679,28 @@ impl SplitContract { } } } - assert!(total_bps == 10_000, "split_rules must sum to 100% of funded amount"); + assert!( + total_bps == 10_000, + "split_rules must sum to 100% of funded amount" + ); } // Compliance check: if a compliance contract is configured, verify creator and all recipients. - if let Some(cc) = env.storage().persistent().get::(&compliance_key()) { + if let Some(cc) = env + .storage() + .persistent() + .get::(&compliance_key()) + { let mut check_args: Vec = Vec::new(env); check_args.push_back(creator.clone().into_val(env)); - let creator_ok: bool = env.invoke_contract(&cc, &Symbol::new(env, "is_compliant"), check_args); + let creator_ok: bool = + env.invoke_contract(&cc, &Symbol::new(env, "is_compliant"), check_args); assert!(creator_ok, "compliance check failed"); for recipient in recipients.iter() { let mut r_args: Vec = Vec::new(env); r_args.push_back(recipient.clone().into_val(env)); - let r_ok: bool = env.invoke_contract(&cc, &Symbol::new(env, "is_compliant"), r_args); + let r_ok: bool = + env.invoke_contract(&cc, &Symbol::new(env, "is_compliant"), r_args); assert!(r_ok, "compliance check failed"); } } @@ -2348,7 +2711,7 @@ impl SplitContract { .instance() .get(&creation_fee_key()) .unwrap_or(0); - + let _creation_fee = if base_creation_fee > 0 { // Get creator's lifetime volume (stored as u64 by update_creator_stats_on_payment). let creator_volume: u64 = env @@ -2358,7 +2721,11 @@ impl SplitContract { .unwrap_or(0u64); // Look up highest matching tier discount - let discount_bps: u32 = if let Some(tiers) = env.storage().persistent().get::<_, Vec<(i128, u32)>>(&fee_tiers_key()) { + let discount_bps: u32 = if let Some(tiers) = env + .storage() + .persistent() + .get::<_, Vec<(i128, u32)>>(&fee_tiers_key()) + { let mut best_discount = 0u32; for (threshold, discount) in tiers.iter() { if (creator_volume as i128) >= threshold && discount > best_discount { @@ -2369,10 +2736,11 @@ impl SplitContract { } else { 0u32 }; - + // Apply discount - let discounted_fee = base_creation_fee - (base_creation_fee * discount_bps as i128 / 10_000); - + let discounted_fee = + base_creation_fee - (base_creation_fee * discount_bps as i128 / 10_000); + let usdc_token: Address = env .storage() .instance() @@ -2385,7 +2753,7 @@ impl SplitContract { .expect("treasury not set"); let usdc_client = token::Client::new(env, &usdc_token); usdc_client.transfer(&creator, &treasury, &discounted_fee); - + discounted_fee } else { 0 @@ -2412,12 +2780,16 @@ impl SplitContract { .persistent() .set(&invoice_count_key(&creator), &(inv_cnt + 1)); - let total: i128 = amounts.iter().sum(); - let gov_opt: Option> = env.storage().instance().get(&governance_contract_key()); + let gov_opt: Option> = + env.storage().instance().get(&governance_contract_key()); if let Some(Some(gov)) = gov_opt { - let approved: bool = env.invoke_contract(&gov, &Symbol::new(env, "check_approval"), (creator.clone(), total).into_val(env)); + let approved: bool = env.invoke_contract( + &gov, + &Symbol::new(env, "check_approval"), + (creator.clone(), total).into_val(env), + ); assert!(approved, "governance approval required"); } @@ -2563,6 +2935,7 @@ impl SplitContract { admin_frozen: false, min_funding_amount: 0, target_usd_cents, + min_payer_rep, }; save_invoice(env, id, &invoice); @@ -2573,7 +2946,9 @@ impl SplitContract { // Issue #327: store optional time-lock delay. if let Some(delay) = release_delay_ledgers { assert!(delay <= 100_000, "release_delay_ledgers must be ≤ 100000"); - env.storage().persistent().set(&release_delay_key(id), &delay); + env.storage() + .persistent() + .set(&release_delay_key(id), &delay); } // Issue #329: store optional metadata hash. if let Some(ref hash) = metadata_hash { @@ -2604,7 +2979,9 @@ impl SplitContract { .unwrap_or(0u64); env.storage().persistent().set( &total_invoices_key(), - &total_invoices.checked_add(1).expect("total_invoices overflow"), + &total_invoices + .checked_add(1) + .expect("total_invoices overflow"), ); id @@ -2697,11 +3074,12 @@ impl SplitContract { None, None, Vec::new(&env), // priorities - false, // require_kyc - None, // scheduled_release_at - None, // release_delay_ledgers - None, // metadata_hash - None, // target_usd_cents + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // min_payer_rep ); ids.push_back(id); } @@ -2733,7 +3111,10 @@ impl SplitContract { params.recipients.len() == params.amounts.len(), "recipients and amounts length mismatch" ); - assert!(!params.recipients.is_empty(), "must have at least one recipient"); + assert!( + !params.recipients.is_empty(), + "must have at least one recipient" + ); assert!( params.deadline > env.ledger().timestamp(), "deadline must be in the future" @@ -2752,48 +3133,49 @@ impl SplitContract { params.amounts, params.token, params.deadline, - Vec::new(&env), // co_creators - false, // allow_early_withdrawal - 0_i128, // bonus_pool - 0_u32, // bonus_max_payers - None, // prerequisite_id - Vec::new(&env), // tranches - Vec::new(&env), // co_signers - 0_u32, // required_signatures - 0_u32, // penalty_bps - 0_u64, // penalty_deadline - 0_u32, // min_funding_bps - Vec::new(&env), // release_stages - None, // price_oracle - Vec::new(&env), // swap_tokens - None, // oracle_address - 0_u32, // tax_bps - None, // tax_authority - 0_u32, // insurance_premium_bps - false, // smart_route - None, // notification_contract + Vec::new(&env), // co_creators + false, // allow_early_withdrawal + 0_i128, // bonus_pool + 0_u32, // bonus_max_payers + None, // prerequisite_id + Vec::new(&env), // tranches + Vec::new(&env), // co_signers + 0_u32, // required_signatures + 0_u32, // penalty_bps + 0_u64, // penalty_deadline + 0_u32, // min_funding_bps + Vec::new(&env), // release_stages + None, // price_oracle + Vec::new(&env), // swap_tokens + None, // oracle_address + 0_u32, // tax_bps + None, // tax_authority + 0_u32, // insurance_premium_bps + false, // smart_route + None, // notification_contract OverflowBehavior::Reject, - false, // convert_to_stream - Vec::new(&env), // accepted_tokens - None, // forward_to - None, // forward_invoice_id - None, // creator_cosigner - 0_i128, // velocity_limit - 0_u64, // velocity_window - Vec::new(&env), // split_rules - Vec::new(&env), // auto_resolve_rules - None, // cross_chain_ref - None, // allowed_payers - None, // payment_cooldown_secs - None, // max_payments_per_window - None, // payment_window_secs - None, // refund_grace_secs - Vec::new(&env), // priorities - false, // require_kyc - None, // scheduled_release_at - None, // release_delay_ledgers - None, // metadata_hash - None, // target_usd_cents + false, // convert_to_stream + Vec::new(&env), // accepted_tokens + None, // forward_to + None, // forward_invoice_id + None, // creator_cosigner + 0_i128, // velocity_limit + 0_u64, // velocity_window + Vec::new(&env), // split_rules + Vec::new(&env), // auto_resolve_rules + None, // cross_chain_ref + None, // allowed_payers + None, // payment_cooldown_secs + None, // max_payments_per_window + None, // payment_window_secs + None, // refund_grace_secs + Vec::new(&env), // priorities + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // min_payer_rep ); ids.push_back(id); } @@ -2816,7 +3198,10 @@ impl SplitContract { "recipients and amounts length mismatch" ); assert!(!recipients.is_empty(), "must have at least one recipient"); - assert!(months > 0 && months <= 12, "months must be between 1 and 12"); + assert!( + months > 0 && months <= 12, + "months must be between 1 and 12" + ); for amt in amounts.iter() { assert!(amt > 0, "amounts must be positive"); } @@ -2866,11 +3251,12 @@ impl SplitContract { None, None, Vec::new(&env), // priorities - false, // require_kyc - None, // scheduled_release_at - None, // release_delay_ledgers - None, // metadata_hash - None, // target_usd_cents + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // min_payer_rep ); if months > 1 { @@ -2951,7 +3337,9 @@ impl SplitContract { .unwrap_or(0u64) + 1; env.storage().persistent().set(&counter_key(), &id); - env.storage().persistent().set(&created_ledger_key(id), &env.ledger().sequence()); + env.storage() + .persistent() + .set(&created_ledger_key(id), &env.ledger().sequence()); let mut tokens: Vec
= Vec::new(&env); for _ in recipients.iter() { @@ -3044,6 +3432,7 @@ impl SplitContract { priorities: source.priorities.clone(), target_usd_cents: source.target_usd_cents, refunded_addresses: Vec::new(&env), + min_payer_rep: source.min_payer_rep, }; save_invoice(&env, id, &new_invoice); @@ -3072,7 +3461,7 @@ impl SplitContract { // // `nonce` must equal the current expected nonce for this (invoice_id, payer) // pair — starts at 0 and increments with each successful payment. - // + // // `auto_convert` (issue #88): when true, invokes DEX swap to convert payer's // source asset to invoice token before crediting payment. When false, behaves // identically to current implementation. @@ -3088,7 +3477,7 @@ impl SplitContract { for p in invoice.payments.iter() { let current_amt = payer_amounts.get(p.payer.clone()).unwrap_or(0); payer_amounts.set(p.payer.clone(), current_amt + p.amount); - + let current_tip = payer_tips.get(p.payer.clone()).unwrap_or(0); payer_tips.set(p.payer.clone(), current_tip + p.tip); } @@ -3096,7 +3485,13 @@ impl SplitContract { let mut new_payments: Vec = Vec::new(&env); for (payer, amount) in payer_amounts.iter() { let tip = payer_tips.get(payer.clone()).unwrap_or(0); - new_payments.push_back(Payment { payer, amount, tip, attestation_hash: None, donate_on_failure: false }); + new_payments.push_back(Payment { + payer, + amount, + tip, + attestation_hash: None, + donate_on_failure: false, + }); } // Verify total funded is unchanged (optional assertion, as asked by Acceptance Criteria) @@ -3104,13 +3499,18 @@ impl SplitContract { for p in new_payments.iter() { total_funded += p.amount; } - assert_eq!(total_funded, invoice.funded, "total funded changed after compression"); + assert_eq!( + total_funded, invoice.funded, + "total funded changed after compression" + ); // Clear all shards and write compressed payments to appropriate shards (issue #177). for shard_id in 0..SHARD_COUNT { - env.storage().persistent().remove(&pay_shard_key(invoice_id, shard_id)); + env.storage() + .persistent() + .remove(&pay_shard_key(invoice_id, shard_id)); } - + for payment in new_payments.iter() { let shard_id = compute_shard_id(&env, &payment.payer); let mut shard_payments: Vec = env @@ -3119,11 +3519,12 @@ impl SplitContract { .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) .unwrap_or_else(|| Vec::new(&env)); shard_payments.push_back(payment.clone()); - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &shard_payments); } } - // ----------------------------------------------------------------------- // Payment Channel (Issue #1) // ----------------------------------------------------------------------- @@ -3134,7 +3535,10 @@ impl SplitContract { assert!(deposit > 0, "deposit must be positive"); let invoice = load_invoice(&env, invoice_id); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); assert!(!invoice.disputed, "invoice is disputed"); let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); @@ -3142,7 +3546,9 @@ impl SplitContract { // Store (balance, deposited) let state: (i128, i128) = (deposit, deposit); - env.storage().persistent().set(&channel_key(invoice_id, &payer), &state); + env.storage() + .persistent() + .set(&channel_key(invoice_id, &payer), &state); } pub fn channel_pay(env: Env, payer: Address, invoice_id: u64, amount: i128) { @@ -3150,18 +3556,28 @@ impl SplitContract { payer.require_auth(); assert!(amount > 0, "amount must be positive"); - let mut state: (i128, i128) = env.storage().persistent().get(&channel_key(invoice_id, &payer)).expect("channel not found"); + let mut state: (i128, i128) = env + .storage() + .persistent() + .get(&channel_key(invoice_id, &payer)) + .expect("channel not found"); assert!(state.0 >= amount, "insufficient channel balance"); state.0 -= amount; - env.storage().persistent().set(&channel_key(invoice_id, &payer), &state); + env.storage() + .persistent() + .set(&channel_key(invoice_id, &payer), &state); } pub fn close_channel(env: Env, payer: Address, invoice_id: u64) { require_not_paused(&env); payer.require_auth(); - let state: (i128, i128) = env.storage().persistent().get(&channel_key(invoice_id, &payer)).expect("channel not found"); + let state: (i128, i128) = env + .storage() + .persistent() + .get(&channel_key(invoice_id, &payer)) + .expect("channel not found"); let balance = state.0; let deposited = state.1; let net_paid = deposited - balance; @@ -3170,7 +3586,10 @@ impl SplitContract { assert!(!invoice.disputed, "invoice is disputed"); if net_paid > 0 { - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); // Write payment to sharded storage (issue #177). let shard_id = compute_shard_id(&env, &payer); @@ -3179,26 +3598,40 @@ impl SplitContract { .persistent() .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) .unwrap_or_else(|| Vec::new(&env)); - shard_payments.push_back(Payment { payer: payer.clone(), amount: net_paid, tip: 0, attestation_hash: None, donate_on_failure: false }); - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments); - + shard_payments.push_back(Payment { + payer: payer.clone(), + amount: net_paid, + tip: 0, + attestation_hash: None, + donate_on_failure: false, + }); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &shard_payments); + invoice.funded += net_paid; // In real app we might handle penalty/oracle, but for simplicity: events::payment_received(&env, invoice_id, &payer, net_paid); - + let total: i128 = invoice.amounts.iter().sum(); - + if invoice.funded >= total { - let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); - let guarded = - invoice.prerequisite_id.is_some() - || !invoice.tranches.is_empty() - || !invoice.release_stages.is_empty() - || in_group - || !invoice.co_signers.is_empty() - || (invoice.oracle_address.is_some() && !invoice.condition_met) - || (invoice.min_funding_bps > 0 && invoice.funded < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 / 10_000)); + let in_group = env + .storage() + .persistent() + .has(&invoice_group_key(invoice_id)); + let guarded = invoice.prerequisite_id.is_some() + || !invoice.tranches.is_empty() + || !invoice.release_stages.is_empty() + || in_group + || !invoice.co_signers.is_empty() + || (invoice.oracle_address.is_some() && !invoice.condition_met) + || (invoice.min_funding_bps > 0 + && invoice.funded + < (invoice.amounts.iter().sum::() + * invoice.min_funding_bps as i128 + / 10_000)); if guarded { save_invoice(&env, invoice_id, &invoice); } else { @@ -3214,29 +3647,80 @@ impl SplitContract { token_client.transfer(&env.current_contract_address(), &payer, &balance); } - env.storage().persistent().remove(&channel_key(invoice_id, &payer)); + env.storage() + .persistent() + .remove(&channel_key(invoice_id, &payer)); } - pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, donate_on_failure: bool) { + pub fn pay( + env: Env, + payer: Address, + invoice_id: u64, + amount: i128, + nonce: u64, + _auto_convert: bool, + donate_on_failure: bool, + ) { require_fn_not_paused(&env, &symbol_short!("pay")); payer.require_auth(); - Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, None, None, donate_on_failure); + Self::_pay( + &env, + &payer, + invoice_id, + amount, + nonce, + _auto_convert, + None, + None, + donate_on_failure, + ); } /// Pay with a signed attestation binding the payment to an off-chain identity - pub fn pay_with_attestation(env: Env, payer: Address, invoice_id: u64, amount: i128, nonce: u64, attestation_hash: BytesN<32>, signature: BytesN<64>, signer_pubkey: BytesN<32>, _auto_convert: bool) { + pub fn pay_with_attestation( + env: Env, + payer: Address, + invoice_id: u64, + amount: i128, + nonce: u64, + attestation_hash: BytesN<32>, + signature: BytesN<64>, + signer_pubkey: BytesN<32>, + _auto_convert: bool, + ) { require_fn_not_paused(&env, &symbol_short!("pay")); payer.require_auth(); // Verify ed25519 signature over attestation_hash let attestation_msg: soroban_sdk::Bytes = attestation_hash.clone().into(); - env.crypto().ed25519_verify(&signer_pubkey, &attestation_msg, &signature); + env.crypto() + .ed25519_verify(&signer_pubkey, &attestation_msg, &signature); // Proceed with payment, storing the attestation hash - Self::_pay(&env, &payer, invoice_id, amount, nonce, _auto_convert, None, Some(attestation_hash), false); + Self::_pay( + &env, + &payer, + invoice_id, + amount, + nonce, + _auto_convert, + None, + Some(attestation_hash), + false, + ); } - fn _pay(env: &Env, payer: &Address, invoice_id: u64, amount: i128, nonce: u64, _auto_convert: bool, via: Option
, attestation_hash: Option>, donate_on_failure: bool) { + fn _pay( + env: &Env, + payer: &Address, + invoice_id: u64, + amount: i128, + nonce: u64, + _auto_convert: bool, + via: Option
, + attestation_hash: Option>, + donate_on_failure: bool, + ) { let mut invoice = load_invoice(env, invoice_id); assert!( @@ -3269,6 +3753,12 @@ impl SplitContract { assert!(whitelist.contains(payer), "payer not allowed"); } + // Check min_payer_rep requirement (issue #349). + if let Some(min_rep) = invoice.min_payer_rep { + let rep = get_rep_internal(env, payer); + assert!(rep.paid_on_time >= min_rep, "insufficient payer reputation"); + } + // Issue #208: source contract allowlist check. if let Some(ref callers) = invoice.allowed_callers { match via { @@ -3280,11 +3770,8 @@ impl SplitContract { // Issue #142: when a price oracle is configured, query current price and // compute the oracle-adjusted total. oracle_price of 1_000_000 = 1.0 (identity). let total: i128 = if let Some(ref oracle) = invoice.price_oracle { - let oracle_price: i128 = env.invoke_contract( - oracle, - &Symbol::new(env, "get_price"), - Vec::new(env), - ); + let oracle_price: i128 = + env.invoke_contract(oracle, &Symbol::new(env, "get_price"), Vec::new(env)); let base_total: i128 = invoice.base_amounts.iter().sum(); base_total * oracle_price / 1_000_000 } else { @@ -3316,11 +3803,18 @@ impl SplitContract { .unwrap_or(0i128); accumulator += amount; if accumulator < invoice.min_payment { - env.storage().persistent().set(&accum_key(invoice_id, payer), &accumulator); + env.storage() + .persistent() + .set(&accum_key(invoice_id, payer), &accumulator); return; } - assert!(accumulator <= remaining, "payment exceeds remaining balance"); - env.storage().persistent().remove(&accum_key(invoice_id, payer)); + assert!( + accumulator <= remaining, + "payment exceeds remaining balance" + ); + env.storage() + .persistent() + .remove(&accum_key(invoice_id, payer)); accumulator } else { amount @@ -3354,15 +3848,28 @@ impl SplitContract { window.0 = now; window.1 = 0; } - assert!(window.1 + amount <= invoice.velocity_limit, "velocity limit exceeded"); + assert!( + window.1 + amount <= invoice.velocity_limit, + "velocity limit exceeded" + ); window.1 += amount; - env.storage().persistent().set(&vel_key(invoice_id, payer), &window); + env.storage() + .persistent() + .set(&vel_key(invoice_id, payer), &window); } // Global cross-invoice velocity limiting per payer - let global_limit: i128 = env.storage().persistent().get(&global_payer_limit_key()).unwrap_or(0i128); + let global_limit: i128 = env + .storage() + .persistent() + .get(&global_payer_limit_key()) + .unwrap_or(0i128); if global_limit > 0 { - let global_window_secs: u64 = env.storage().persistent().get(&global_payer_window_key()).unwrap_or(0u64); + let global_window_secs: u64 = env + .storage() + .persistent() + .get(&global_payer_window_key()) + .unwrap_or(0u64); let now = env.ledger().timestamp(); let mut global_window: (u64, i128) = env .storage() @@ -3374,9 +3881,14 @@ impl SplitContract { global_window.0 = now; global_window.1 = 0; } - assert!(global_window.1 + amount <= global_limit, "global payer limit exceeded"); + assert!( + global_window.1 + amount <= global_limit, + "global payer limit exceeded" + ); global_window.1 += amount; - env.storage().persistent().set(&global_vel_key(payer), &global_window); + env.storage() + .persistent() + .set(&global_vel_key(payer), &global_window); } let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token")); @@ -3402,7 +3914,8 @@ impl SplitContract { } }; - let premium = (credited_amount as u128 * invoice.insurance_premium_bps as u128 / 10_000u128) as i128; + let premium = + (credited_amount as u128 * invoice.insurance_premium_bps as u128 / 10_000u128) as i128; // Transfer the full amount from payer so excess can be refunded/donated. let excess = amount - credited_amount; let total_charge = credited_amount + premium + excess; @@ -3468,8 +3981,16 @@ impl SplitContract { .persistent() .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) .unwrap_or_else(|| Vec::new(env)); - shard_payments.push_back(Payment { payer: payer.clone(), amount: credited_amount, tip: 0, attestation_hash, donate_on_failure }); - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments); + shard_payments.push_back(Payment { + payer: payer.clone(), + amount: credited_amount, + tip: 0, + attestation_hash, + donate_on_failure, + }); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &shard_payments); // Issue #334: write compact status to optimised storage. save_compact_status(env, invoice_id, &invoice.status); @@ -3478,15 +3999,16 @@ impl SplitContract { let prev_funded = invoice.funded; invoice.funded += credited_amount; - // Increment per-address reputation counter (issue #24). - let rep: u64 = env - .storage() - .persistent() - .get(&rep_key(payer)) - .unwrap_or(0u64); - env.storage() - .persistent() - .set(&rep_key(payer), &(rep + 1)); + // Increment per-address reputation counter (issue #24, #349). + let is_late = + invoice.penalty_deadline > 0 && env.ledger().timestamp() > invoice.penalty_deadline; + update_rep_internal(env, payer, |score| { + if is_late { + score.late_pays = score.late_pays.saturating_add(1); + } else { + score.paid_on_time = score.paid_on_time.saturating_add(1); + } + }); // Increment per-address credit score (issue #38). let credit: u64 = env @@ -3503,11 +4025,22 @@ impl SplitContract { // Issue #333: emit milestone events for any thresholds crossed by this payment. { let total_for_milestone: i128 = total; // already computed above - check_and_emit_milestones(env, invoice_id, prev_funded, invoice.funded, total_for_milestone); + check_and_emit_milestones( + env, + invoice_id, + prev_funded, + invoice.funded, + total_for_milestone, + ); } update_creator_stats_on_payment(env, &invoice.creator, credited_amount); update_creator_payers(env, &invoice.creator, payer); - notify_invoice(env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); + notify_invoice( + env, + invoice_id, + symbol_short!("pay"), + &invoice.notification_contract, + ); // Record rate-limiter timestamps after successful payment (issue #168). Self::record_payment_limits(env, invoice_id, payer, &invoice, now_ts); @@ -3522,11 +4055,8 @@ impl SplitContract { args.push_back(invoice_id.into_val(env)); args.push_back(payer.clone().into_val(env)); args.push_back(credited_amount.into_val(env)); - let receipt_addr: Address = env.invoke_contract( - &factory, - &Symbol::new(env, "mint_receipt"), - args, - ); + let receipt_addr: Address = + env.invoke_contract(&factory, &Symbol::new(env, "mint_receipt"), args); env.storage() .persistent() .set(&receipt_token_key(invoice_id, payer), &receipt_addr); @@ -3534,11 +4064,14 @@ impl SplitContract { if invoice.funded >= total { // Issue #325: record the ledger when invoice becomes fully funded (dispute window start). - if !env.storage().persistent().has(&dispute_raised_at_key(invoice_id)) { - env.storage().persistent().set( - &dispute_raised_at_key(invoice_id), - &env.ledger().sequence(), - ); + if !env + .storage() + .persistent() + .has(&dispute_raised_at_key(invoice_id)) + { + env.storage() + .persistent() + .set(&dispute_raised_at_key(invoice_id), &env.ledger().sequence()); } // Auto-release only when no tranches, prerequisite, or group constraint @@ -3548,23 +4081,35 @@ impl SplitContract { .persistent() .has(&invoice_group_key(invoice_id)); // Issue #327: a release delay forces a manual release() call. - let has_release_delay = env.storage().persistent() + let has_release_delay = env + .storage() + .persistent() .get::<_, u32>(&release_delay_key(invoice_id)) .is_some(); - let guarded = - invoice.prerequisite_id.is_some() - || !invoice.tranches.is_empty() - || !invoice.release_stages.is_empty() - || in_group - || !invoice.co_signers.is_empty() - || (invoice.oracle_address.is_some() && !invoice.condition_met) - || (invoice.min_funding_bps > 0 && invoice.funded < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 / 10_000)) - || has_release_delay - || invoice.scheduled_release_at.map_or(false, |t| env.ledger().timestamp() < t); + let guarded = invoice.prerequisite_id.is_some() + || !invoice.tranches.is_empty() + || !invoice.release_stages.is_empty() + || in_group + || !invoice.co_signers.is_empty() + || (invoice.oracle_address.is_some() && !invoice.condition_met) + || (invoice.min_funding_bps > 0 + && invoice.funded + < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 + / 10_000)) + || has_release_delay + || invoice + .scheduled_release_at + .is_some_and(|t| env.ledger().timestamp() < t); // Issue #327: record the ledger sequence when full funding is reached. - if !env.storage().persistent().has(&funded_at_ledger_key(invoice_id)) { + if !env + .storage() + .persistent() + .has(&funded_at_ledger_key(invoice_id)) + { let seq = env.ledger().sequence(); - env.storage().persistent().set(&funded_at_ledger_key(invoice_id), &seq); + env.storage() + .persistent() + .set(&funded_at_ledger_key(invoice_id), &seq); } if guarded { save_invoice(env, invoice_id, &invoice); @@ -3599,17 +4144,22 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); assert!(!invoice.disputed, "invoice is disputed"); - assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); + assert!( + env.ledger().timestamp() <= invoice.deadline, + "invoice deadline has passed" + ); assert!(amount > 0, "payment amount must be positive"); let invoice_token = invoice.tokens.get(0).expect("no token"); // Accept the base token or any token in accepted_tokens. let is_base = source_token == invoice_token; - let is_accepted = is_base - || invoice.accepted_tokens.iter().any(|t| t == source_token); + let is_accepted = is_base || invoice.accepted_tokens.iter().any(|t| t == source_token); assert!(is_accepted, "token not accepted"); // Validate and increment nonce. @@ -3649,7 +4199,10 @@ impl SplitContract { let total: i128 = invoice.amounts.iter().sum(); let remaining = total - invoice.funded; - assert!(credited_amount <= remaining, "payment exceeds remaining balance"); + assert!( + credited_amount <= remaining, + "payment exceeds remaining balance" + ); // Write payment to sharded storage (issue #177). let shard_id = compute_shard_id(&env, &payer); @@ -3658,24 +4211,42 @@ impl SplitContract { .persistent() .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) .unwrap_or_else(|| Vec::new(&env)); - shard_payments.push_back(Payment { payer: payer.clone(), amount: credited_amount, tip: 0, attestation_hash: None, donate_on_failure: false }); - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments); + shard_payments.push_back(Payment { + payer: payer.clone(), + amount: credited_amount, + tip: 0, + attestation_hash: None, + donate_on_failure: false, + }); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &shard_payments); invoice.funded += credited_amount; append_audit_entry(&env, invoice_id, symbol_short!("pay_tok"), &payer); events::payment_received(&env, invoice_id, &payer, credited_amount); - notify_invoice(&env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); + notify_invoice( + &env, + invoice_id, + symbol_short!("pay"), + &invoice.notification_contract, + ); if invoice.funded >= total { - let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); - let guarded = - invoice.prerequisite_id.is_some() - || !invoice.tranches.is_empty() - || !invoice.release_stages.is_empty() - || in_group - || !invoice.co_signers.is_empty() - || (invoice.min_funding_bps > 0 && invoice.funded < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 / 10_000)); + let in_group = env + .storage() + .persistent() + .has(&invoice_group_key(invoice_id)); + let guarded = invoice.prerequisite_id.is_some() + || !invoice.tranches.is_empty() + || !invoice.release_stages.is_empty() + || in_group + || !invoice.co_signers.is_empty() + || (invoice.min_funding_bps > 0 + && invoice.funded + < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 + / 10_000)); if guarded { save_invoice(&env, invoice_id, &invoice); } else { @@ -3699,9 +4270,15 @@ impl SplitContract { payer.require_auth(); let mut invoice = load_invoice(&env, invoice_id); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); assert!(!invoice.disputed, "invoice is disputed"); - assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); + assert!( + env.ledger().timestamp() <= invoice.deadline, + "invoice deadline has passed" + ); assert!(source_amount > 0, "payment amount must be positive"); let invoice_token = invoice.tokens.get(0).expect("no token"); @@ -3730,25 +4307,43 @@ impl SplitContract { .persistent() .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) .unwrap_or_else(|| Vec::new(&env)); - shard_payments.push_back(Payment { payer: payer.clone(), amount: converted, tip: 0, attestation_hash: None, donate_on_failure: false }); - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments); - - invoice.funded += converted; - - append_audit_entry(&env, invoice_id, symbol_short!("brdg_pay"), &payer); + shard_payments.push_back(Payment { + payer: payer.clone(), + amount: converted, + tip: 0, + attestation_hash: None, + donate_on_failure: false, + }); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &shard_payments); + + invoice.funded += converted; + + append_audit_entry(&env, invoice_id, symbol_short!("brdg_pay"), &payer); events::payment_received(&env, invoice_id, &payer, converted); - notify_invoice(&env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); + notify_invoice( + &env, + invoice_id, + symbol_short!("pay"), + &invoice.notification_contract, + ); if invoice.funded >= total { - let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); - let guarded = - invoice.prerequisite_id.is_some() - || !invoice.tranches.is_empty() - || !invoice.release_stages.is_empty() - || in_group - || !invoice.co_signers.is_empty() - || (invoice.oracle_address.is_some() && !invoice.condition_met) - || (invoice.min_funding_bps > 0 && invoice.funded < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 / 10_000)); + let in_group = env + .storage() + .persistent() + .has(&invoice_group_key(invoice_id)); + let guarded = invoice.prerequisite_id.is_some() + || !invoice.tranches.is_empty() + || !invoice.release_stages.is_empty() + || in_group + || !invoice.co_signers.is_empty() + || (invoice.oracle_address.is_some() && !invoice.condition_met) + || (invoice.min_funding_bps > 0 + && invoice.funded + < (invoice.amounts.iter().sum::() * invoice.min_funding_bps as i128 + / 10_000)); if guarded { save_invoice(&env, invoice_id, &invoice); } else { @@ -3783,7 +4378,10 @@ impl SplitContract { let mut total: i128 = 0; for p in payments.iter() { let inv = load_invoice(&env, p.invoice_id); - assert!(inv.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + inv.status == InvoiceStatus::Pending, + "invoice is not pending" + ); assert!(!inv.disputed, "invoice is disputed"); assert!( env.ledger().timestamp() <= inv.deadline, @@ -3817,8 +4415,16 @@ impl SplitContract { .persistent() .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(p.invoice_id, shard_id)) .unwrap_or_else(|| Vec::new(&env)); - shard_payments.push_back(Payment { payer: payer.clone(), amount: p.amount, tip: 0, attestation_hash: None, donate_on_failure: false }); - env.storage().persistent().set(&pay_shard_key(p.invoice_id, shard_id), &shard_payments); + shard_payments.push_back(Payment { + payer: payer.clone(), + amount: p.amount, + tip: 0, + attestation_hash: None, + donate_on_failure: false, + }); + env.storage() + .persistent() + .set(&pay_shard_key(p.invoice_id, shard_id), &shard_payments); inv.funded += p.amount; @@ -3827,15 +4433,20 @@ impl SplitContract { let inv_total: i128 = inv.amounts.iter().sum(); if inv.funded >= inv_total { - let in_group = env.storage().persistent().has(&invoice_group_key(p.invoice_id)); - let guarded = - inv.prerequisite_id.is_some() - || !inv.tranches.is_empty() - || !inv.release_stages.is_empty() - || in_group - || !inv.co_signers.is_empty() - || (inv.oracle_address.is_some() && !inv.condition_met) - || (inv.min_funding_bps > 0 && inv.funded < (inv.amounts.iter().sum::() * inv.min_funding_bps as i128 / 10_000)); + let in_group = env + .storage() + .persistent() + .has(&invoice_group_key(p.invoice_id)); + let guarded = inv.prerequisite_id.is_some() + || !inv.tranches.is_empty() + || !inv.release_stages.is_empty() + || in_group + || !inv.co_signers.is_empty() + || (inv.oracle_address.is_some() && !inv.condition_met) + || (inv.min_funding_bps > 0 + && inv.funded + < (inv.amounts.iter().sum::() * inv.min_funding_bps as i128 + / 10_000)); if guarded { save_invoice(&env, p.invoice_id, &inv); } else { @@ -3925,7 +4536,9 @@ impl SplitContract { }; // Issue #330: if some recipients were already paid via release_to_recipient, // reduce min_required by their paid amounts so the funded check still passes. - let paid_set_rel: Vec
= env.storage().persistent() + let paid_set_rel: Vec
= env + .storage() + .persistent() .get(&paid_recipients_key(invoice_id)) .unwrap_or_else(|| Vec::new(&env)); let already_paid_amount: i128 = if paid_set_rel.is_empty() { @@ -3941,11 +4554,22 @@ impl SplitContract { paid_total }; let effective_min_required = min_required.saturating_sub(already_paid_amount); - assert!(invoice.funded >= effective_min_required, "minimum funding not reached"); + assert!( + invoice.funded >= effective_min_required, + "minimum funding not reached" + ); // Issue #327: enforce time-lock delay set by the creator. - if let Some(delay_ledgers) = env.storage().persistent().get::<_, u32>(&release_delay_key(invoice_id)) { - if let Some(funded_at) = env.storage().persistent().get::<_, u32>(&funded_at_ledger_key(invoice_id)) { + if let Some(delay_ledgers) = env + .storage() + .persistent() + .get::<_, u32>(&release_delay_key(invoice_id)) + { + if let Some(funded_at) = env + .storage() + .persistent() + .get::<_, u32>(&funded_at_ledger_key(invoice_id)) + { let unlock_at = funded_at.saturating_add(delay_ledgers); if env.ledger().sequence() < unlock_at { panic!("FundsLockedUntil"); @@ -3984,10 +4608,16 @@ impl SplitContract { .unwrap_or(types::GroupMode::AllOrNothing); match mode { types::GroupMode::AllOrNothing => { - assert!(group_all_funded(&env, group_id), "group members not fully funded"); + assert!( + group_all_funded(&env, group_id), + "group members not fully funded" + ); } types::GroupMode::Majority => { - assert!(group_majority_funded(&env, group_id), "group majority not funded"); + assert!( + group_majority_funded(&env, group_id), + "group majority not funded" + ); } } } @@ -4010,16 +4640,28 @@ impl SplitContract { assert!(!invoice.frozen, "invoice is frozen"); assert!(!invoice.admin_frozen, "invoice frozen by admin"); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); - let scheduled_at = invoice.scheduled_release_at.expect("no scheduled release time"); - assert!(env.ledger().timestamp() >= scheduled_at, "scheduled release time not reached"); + let scheduled_at = invoice + .scheduled_release_at + .expect("no scheduled release time"); + assert!( + env.ledger().timestamp() >= scheduled_at, + "scheduled release time not reached" + ); // Check min funding requirement if set if invoice.min_funding_bps > 0 { let total: i128 = invoice.amounts.iter().sum(); - let min_required = (total as u128 * invoice.min_funding_bps as u128 / 10_000u128) as i128; - assert!(invoice.funded >= min_required, "minimum funding not reached"); + let min_required = + (total as u128 * invoice.min_funding_bps as u128 / 10_000u128) as i128; + assert!( + invoice.funded >= min_required, + "minimum funding not reached" + ); } // Approval check (issue #25) @@ -4030,7 +4672,10 @@ impl SplitContract { // Prerequisite check (issue #22) if let Some(prereq_id) = invoice.prerequisite_id { let prereq = load_invoice(&env, prereq_id); - assert!(prereq.status == InvoiceStatus::Released, "prerequisite not released"); + assert!( + prereq.status == InvoiceStatus::Released, + "prerequisite not released" + ); } // Co-signer approval check @@ -4053,20 +4698,29 @@ impl SplitContract { } } - fn execute_smart_route(env: &Env, invoice: &Invoice, recipient: &Address, payout: i128) -> bool { + fn execute_smart_route( + env: &Env, + invoice: &Invoice, + recipient: &Address, + payout: i128, + ) -> bool { if invoice.smart_route { - if let Some(dex_router) = env.storage().instance().get::<_, Address>(&soroban_sdk::symbol_short!("dex_rtr")) { + if let Some(dex_router) = env + .storage() + .instance() + .get::<_, Address>(&soroban_sdk::symbol_short!("dex_rtr")) + { let token = invoice.tokens.get(0).expect("no token"); let path: Vec
= env.invoke_contract( &dex_router, &soroban_sdk::Symbol::new(env, "get_path"), - (token.clone(), recipient.clone()).into_val(env) + (token.clone(), recipient.clone()).into_val(env), ); if !path.is_empty() { let _: Val = env.invoke_contract( &dex_router, &soroban_sdk::Symbol::new(env, "route_transfer"), - (path, payout, recipient.clone()).into_val(env) + (path, payout, recipient.clone()).into_val(env), ); return true; } @@ -4082,7 +4736,10 @@ impl SplitContract { require_not_paused(&env); let mut invoice = load_invoice(&env, invoice_id); - let approver = invoice.approver.as_ref().expect("no approver set on this invoice"); + let approver = invoice + .approver + .as_ref() + .expect("no approver set on this invoice"); approver.require_auth(); invoice.approved = true; @@ -4110,8 +4767,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!( - invoice.creator == creator - || invoice.co_creators.iter().any(|c| c == creator), + invoice.creator == creator || invoice.co_creators.iter().any(|c| c == creator), "only creator can pause invoice" ); assert!( @@ -4139,8 +4795,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!( - invoice.creator == creator - || invoice.co_creators.iter().any(|c| c == creator), + invoice.creator == creator || invoice.co_creators.iter().any(|c| c == creator), "only creator can resume invoice" ); assert!(invoice.frozen, "invoice is not frozen"); @@ -4165,8 +4820,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!( - invoice.creator == creator - || invoice.co_creators.iter().any(|c| c == creator), + invoice.creator == creator || invoice.co_creators.iter().any(|c| c == creator), "only creator can modify allowlist" ); @@ -4197,8 +4851,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!( - invoice.creator == creator - || invoice.co_creators.iter().any(|c| c == creator), + invoice.creator == creator || invoice.co_creators.iter().any(|c| c == creator), "only creator can modify allowlist" ); @@ -4230,10 +4883,18 @@ impl SplitContract { creator.require_auth(); let invoice = load_invoice(&env, invoice_id); - assert!(invoice.creator == creator, "only creator can update metadata hash"); + assert!( + invoice.creator == creator, + "only creator can update metadata hash" + ); - let old_hash: Option> = env.storage().persistent().get(&metadata_hash_key(invoice_id)); - env.storage().persistent().set(&metadata_hash_key(invoice_id), &new_hash); + let old_hash: Option> = env + .storage() + .persistent() + .get(&metadata_hash_key(invoice_id)); + env.storage() + .persistent() + .set(&metadata_hash_key(invoice_id), &new_hash); append_audit_entry(&env, invoice_id, symbol_short!("meta_upd"), &creator); events::metadata_updated(&env, invoice_id, &old_hash, &new_hash); @@ -4262,12 +4923,23 @@ impl SplitContract { // After partial releases, funded may be less than total, so we check funded_at_ledger. let total: i128 = invoice.amounts.iter().sum(); let was_fully_funded = invoice.funded >= total - || env.storage().persistent().has(&funded_at_ledger_key(invoice_id)); + || env + .storage() + .persistent() + .has(&funded_at_ledger_key(invoice_id)); assert!(was_fully_funded, "invoice not fully funded"); // Issue #327: respect time-lock delay. - if let Some(delay_ledgers) = env.storage().persistent().get::<_, u32>(&release_delay_key(invoice_id)) { - if let Some(funded_at) = env.storage().persistent().get::<_, u32>(&funded_at_ledger_key(invoice_id)) { + if let Some(delay_ledgers) = env + .storage() + .persistent() + .get::<_, u32>(&release_delay_key(invoice_id)) + { + if let Some(funded_at) = env + .storage() + .persistent() + .get::<_, u32>(&funded_at_ledger_key(invoice_id)) + { let unlock_at = funded_at.saturating_add(delay_ledgers); assert!(env.ledger().sequence() >= unlock_at, "FundsLockedUntil"); } @@ -4285,13 +4957,12 @@ impl SplitContract { let idx = recipient_idx.unwrap(); // Check not already paid. - let mut paid: Vec
= env.storage().persistent() + let mut paid: Vec
= env + .storage() + .persistent() .get(&paid_recipients_key(invoice_id)) .unwrap_or_else(|| Vec::new(&env)); - assert!( - !paid.iter().any(|a| a == recipient), - "RecipientAlreadyPaid" - ); + assert!(!paid.iter().any(|a| a == recipient), "RecipientAlreadyPaid"); let amount = invoice.amounts.get(idx).unwrap(); assert!(amount > 0, "recipient amount must be positive"); @@ -4300,7 +4971,9 @@ impl SplitContract { token_client.transfer(&env.current_contract_address(), &recipient, &amount); paid.push_back(recipient.clone()); - env.storage().persistent().set(&paid_recipients_key(invoice_id), &paid); + env.storage() + .persistent() + .set(&paid_recipients_key(invoice_id), &paid); // Reduce funded so the contract's token balance stays consistent with paid amounts. invoice.funded -= amount; @@ -4375,7 +5048,10 @@ impl SplitContract { require_not_paused(&env); let mut invoice = load_invoice(&env, invoice_id); assert!(!invoice.disputed, "invoice is disputed"); - let oracle = invoice.oracle_address.as_ref().expect("no oracle set for invoice"); + let oracle = invoice + .oracle_address + .as_ref() + .expect("no oracle set for invoice"); oracle.require_auth(); invoice.condition_met = true; save_invoice(&env, invoice_id, &invoice); @@ -4403,13 +5079,20 @@ impl SplitContract { .expect("reminder not set"); assert!(env.ledger().timestamp() >= remind_at, "reminder not due"); events::payment_reminder(&env, invoice_id, &who); - env.storage().persistent().remove(&reminder_key(invoice_id, &who)); + env.storage() + .persistent() + .remove(&reminder_key(invoice_id, &who)); append_audit_entry(&env, invoice_id, symbol_short!("trig_rmd"), &who); } /// Create a treasury group linking multiple invoice IDs to a single treasury address. /// Returns the new group id. - pub fn group_treasury_create(env: Env, creator: Address, invoice_ids: Vec, treasury: Address) -> u64 { + pub fn group_treasury_create( + env: Env, + creator: Address, + invoice_ids: Vec, + treasury: Address, + ) -> u64 { require_not_paused(&env); creator.require_auth(); let id: u64 = env @@ -4418,11 +5101,20 @@ impl SplitContract { .get(&treasury_group_counter_key()) .unwrap_or(0u64) + 1; - env.storage().persistent().set(&treasury_group_counter_key(), &id); - let record = types::TreasuryRecord { invoice_ids: invoice_ids.clone(), treasury: treasury.clone() }; - env.storage().persistent().set(&group_treasury_key(id), &record); + env.storage() + .persistent() + .set(&treasury_group_counter_key(), &id); + let record = types::TreasuryRecord { + invoice_ids: invoice_ids.clone(), + treasury: treasury.clone(), + }; + env.storage() + .persistent() + .set(&group_treasury_key(id), &record); for iid in invoice_ids.iter() { - env.storage().persistent().set(&invoice_treasury_key(iid), &id); + env.storage() + .persistent() + .set(&invoice_treasury_key(iid), &id); append_audit_entry(&env, iid, symbol_short!("grp_tr"), &creator); } id @@ -4430,12 +5122,30 @@ impl SplitContract { /// Pay toward an invoice using a memo that encodes the invoice id. /// Requires payer auth and emits a payment_matched event on success. - pub fn pay_with_memo(env: Env, payer: Address, memo: u64, amount: i128, nonce: u64, _auto_convert: bool, via: Option
) { + pub fn pay_with_memo( + env: Env, + payer: Address, + memo: u64, + amount: i128, + nonce: u64, + _auto_convert: bool, + via: Option
, + ) { require_not_paused(&env); payer.require_auth(); // Validate memo corresponds to an existing invoice. let _ = load_invoice(&env, memo); - Self::_pay(&env, &payer, memo, amount, nonce, _auto_convert, via, None, false); + Self::_pay( + &env, + &payer, + memo, + amount, + nonce, + _auto_convert, + via, + None, + false, + ); events::payment_matched(&env, memo, memo, &payer); } @@ -4515,12 +5225,12 @@ impl SplitContract { let payout = proportional - fee - tax; let token_client = token::Client::new(&env, &invoice.tokens.get(idx).expect("no token")); - + if tax > 0 { let tax_authority = invoice.tax_authority.as_ref().unwrap(); token_client.transfer(&env.current_contract_address(), tax_authority, &tax); } - + let routed = Self::execute_smart_route(&env, &invoice, &recipient, payout); if !routed { token_client.transfer(&env.current_contract_address(), &recipient, &payout); @@ -4574,8 +5284,7 @@ impl SplitContract { let new_bps = unlocked_bps.saturating_sub(invoice.released_bps); assert!(new_bps > 0, "no tranches unlocked"); - let token_client = - token::Client::new(env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token")); let platform_fee_bps: u32 = env .storage() @@ -4588,13 +5297,13 @@ impl SplitContract { let n = invoice.recipients.len(); let mut total_fee: i128 = 0; let mut total_tax: i128 = 0; - + let waivers: Vec
= env .storage() .persistent() .get(&platform_fee_waiver_list_key()) - .unwrap_or_else(|| Vec::new(&env)); - + .unwrap_or_else(|| Vec::new(env)); + for i in 0..n { let recipient = invoice.recipients.get(i).unwrap(); let amount = invoice.amounts.get(i).unwrap(); @@ -4645,9 +5354,8 @@ impl SplitContract { invoice.released_bps += new_bps; // Calculate amount released in this tranche call. - let amount_released = ((funded as u128) - .saturating_mul(new_bps as u128) - / 10_000u128) as i128; + let amount_released = + ((funded as u128).saturating_mul(new_bps as u128) / 10_000u128) as i128; // Increment total_volume and total_released counters (issue #28). let total_volume: i128 = env @@ -4657,7 +5365,9 @@ impl SplitContract { .unwrap_or(0i128); env.storage().persistent().set( &total_volume_key(), - &total_volume.checked_add(amount_released).expect("total_volume overflow"), + &total_volume + .checked_add(amount_released) + .expect("total_volume overflow"), ); let total_released: i128 = env @@ -4667,20 +5377,37 @@ impl SplitContract { .unwrap_or(0i128); env.storage().persistent().set( &total_released_key(), - &total_released.checked_add(amount_released).expect("total_released overflow"), + &total_released + .checked_add(amount_released) + .expect("total_released overflow"), ); if invoice.released_bps >= 10_000 { invoice.status = InvoiceStatus::Released; invoice.completion_time = Some(now); if invoice.insurance_fund > 0 { - token_client.transfer(&env.current_contract_address(), &invoice.creator, &invoice.insurance_fund); + token_client.transfer( + &env.current_contract_address(), + &invoice.creator, + &invoice.insurance_fund, + ); invoice.insurance_fund = 0; } append_audit_entry(env, invoice_id, symbol_short!("release"), actor); events::invoice_released(env, invoice_id, &invoice.recipients); - events::invoice_state_changed(env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Released, actor); - notify_invoice(env, invoice_id, symbol_short!("release"), &invoice.notification_contract); + events::invoice_state_changed( + env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Released, + actor, + ); + notify_invoice( + env, + invoice_id, + symbol_short!("release"), + &invoice.notification_contract, + ); maybe_record_released(env, &invoice.creator, amount_released); update_creator_stats_on_release(env, &invoice.creator, amount_released); } @@ -4702,14 +5429,20 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); - assert!(invoice.creator == creator, "only creator can call stage_release"); + assert!( + invoice.creator == creator, + "only creator can call stage_release" + ); assert!(!invoice.frozen, "invoice is frozen"); assert!(!invoice.disputed, "invoice is disputed"); assert!( invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); - assert!(!invoice.release_stages.is_empty(), "no release stages defined"); + assert!( + !invoice.release_stages.is_empty(), + "no release stages defined" + ); let total: i128 = invoice.amounts.iter().sum(); assert!(invoice.funded >= total, "invoice not fully funded"); @@ -4722,8 +5455,7 @@ impl SplitContract { let stage_bps = invoice.release_stages.get(stage_idx).unwrap(); - let token_client = - token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); let platform_fee_bps: u32 = env .storage() @@ -4735,13 +5467,13 @@ impl SplitContract { let n = invoice.recipients.len(); let mut total_fee: i128 = 0; let mut total_tax: i128 = 0; - + let waivers: Vec
= env .storage() .persistent() .get(&platform_fee_waiver_list_key()) .unwrap_or_else(|| Vec::new(&env)); - + for i in 0..n { let recipient = invoice.recipients.get(i).unwrap(); let amount = invoice.amounts.get(i).unwrap(); @@ -4785,9 +5517,8 @@ impl SplitContract { invoice.released_stages += 1; // Calculate amount released in this stage. - let amount_released = ((stage_bps as u128) - .saturating_mul(funded as u128) - / 10_000u128) as i128; + let amount_released = + ((stage_bps as u128).saturating_mul(funded as u128) / 10_000u128) as i128; // Increment total_volume and total_released counters (issue #28). let total_volume: i128 = env @@ -4797,7 +5528,9 @@ impl SplitContract { .unwrap_or(0i128); env.storage().persistent().set( &total_volume_key(), - &total_volume.checked_add(amount_released).expect("total_volume overflow"), + &total_volume + .checked_add(amount_released) + .expect("total_volume overflow"), ); let total_released: i128 = env @@ -4807,7 +5540,9 @@ impl SplitContract { .unwrap_or(0i128); env.storage().persistent().set( &total_released_key(), - &total_released.checked_add(amount_released).expect("total_released overflow"), + &total_released + .checked_add(amount_released) + .expect("total_released overflow"), ); let now = env.ledger().timestamp(); @@ -4815,13 +5550,28 @@ impl SplitContract { invoice.status = InvoiceStatus::Released; invoice.completion_time = Some(now); if invoice.insurance_fund > 0 { - token_client.transfer(&env.current_contract_address(), &invoice.creator, &invoice.insurance_fund); + token_client.transfer( + &env.current_contract_address(), + &invoice.creator, + &invoice.insurance_fund, + ); invoice.insurance_fund = 0; } append_audit_entry(&env, invoice_id, symbol_short!("stg_rel"), &creator); events::invoice_released(&env, invoice_id, &invoice.recipients); - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Released, &creator); - notify_invoice(&env, invoice_id, symbol_short!("release"), &invoice.notification_contract); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Released, + &creator, + ); + notify_invoice( + &env, + invoice_id, + symbol_short!("release"), + &invoice.notification_contract, + ); } else { append_audit_entry(&env, invoice_id, symbol_short!("stg_rel"), &creator); } @@ -4839,10 +5589,16 @@ impl SplitContract { creator.require_auth(); let mut invoice = load_invoice(&env, invoice_id); - assert!(invoice.creator == creator, "only creator can call partial_release"); + assert!( + invoice.creator == creator, + "only creator can call partial_release" + ); assert!(!invoice.frozen, "invoice is frozen"); assert!(!invoice.disputed, "invoice is disputed"); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); assert!(amount > 0, "amount must be positive"); assert!(amount <= invoice.funded, "amount exceeds funded balance"); @@ -4891,7 +5647,11 @@ impl SplitContract { let recipient = invoice.recipients.get(idx).unwrap(); let recip_amount = invoice.amounts.get(idx).unwrap(); if remaining >= recip_amount { - token_client.transfer(&env.current_contract_address(), &recipient, &recip_amount); + token_client.transfer( + &env.current_contract_address(), + &recipient, + &recip_amount, + ); remaining -= recip_amount; } // Skip recipients whose full amount cannot be covered. @@ -4927,8 +5687,7 @@ impl SplitContract { fn _release_full(env: &Env, invoice_id: u64, invoice: &mut Invoice, actor: &Address) { // Issue #27: vesting cliff field not in current schema; proceed normally - let token_client = - token::Client::new(env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token")); // Issue #296: if creator has a fee waiver, platform_fee_bps is 0 for this invoice. let creator_waived: bool = { @@ -4943,11 +5702,10 @@ impl SplitContract { let platform_fee_bps: u32 = if creator_waived { 0 } else { - env - .storage() - .instance() - .get(&platform_fee_bps_key()) - .unwrap_or(0u32) + env.storage() + .instance() + .get(&platform_fee_bps_key()) + .unwrap_or(0u32) }; let total: i128 = invoice.amounts.iter().sum(); @@ -4959,7 +5717,9 @@ impl SplitContract { let mut payouts: Vec = Vec::new(env); // Issue #330: recipients already paid via release_to_recipient are skipped here. - let paid_set: Vec
= env.storage().persistent() + let paid_set: Vec
= env + .storage() + .persistent() .get(&paid_recipients_key(invoice_id)) .unwrap_or_else(|| Vec::new(env)); // Compute effective total excluding paid recipients' amounts. @@ -4973,7 +5733,11 @@ impl SplitContract { et += invoice.amounts.get(i).unwrap(); } } - if et == 0 { 1 } else { et } + if et == 0 { + 1 + } else { + et + } }; // Find the index of the last unpaid recipient (for remainder assignment). let last_unpaid_idx: u32 = { @@ -4991,7 +5755,7 @@ impl SplitContract { .storage() .persistent() .get(&platform_fee_waiver_list_key()) - .unwrap_or_else(|| Vec::new(&env)); + .unwrap_or_else(|| Vec::new(env)); for i in 0..n { let recipient = invoice.recipients.get(i).unwrap(); @@ -5042,15 +5806,19 @@ impl SplitContract { } // Issue #326: deduct protocol fee from the release amount before distributing. - let proto_fee_amount: i128 = if let Some(proto_cfg) = env - .storage() - .instance() - .get::(&protocol_fee_key()) + let proto_fee_amount: i128 = if let Some(proto_cfg) = + env.storage() + .instance() + .get::(&protocol_fee_key()) { if proto_cfg.rate_bps > 0 { let fee = (funded as u128 * proto_cfg.rate_bps as u128 / 10_000u128) as i128; if fee > 0 { - token_client.transfer(&env.current_contract_address(), &proto_cfg.treasury, &fee); + token_client.transfer( + &env.current_contract_address(), + &proto_cfg.treasury, + &fee, + ); events::fee_paid(env, invoice_id, fee, &proto_cfg.treasury); } fee @@ -5092,13 +5860,16 @@ impl SplitContract { let proportional = payouts.get(i).unwrap(); // Issue #330: skip recipients already paid via release_to_recipient. - if proportional == 0 && !paid_set.is_empty() && paid_set.iter().any(|p| p == recipient) { + if proportional == 0 + && !paid_set.is_empty() + && paid_set.iter().any(|p| p == recipient) + { continue; } let tax = (proportional as u128 * invoice.tax_bps as u128 / 10_000u128) as i128; let post_tax = proportional - tax; - + let is_waived = waivers.iter().any(|a| a == recipient); let fee = if is_waived { 0 @@ -5108,10 +5879,7 @@ impl SplitContract { let payout = post_tax - fee; // Issue #41: if a swap token is configured for this recipient, invoke DEX swap. - let swap_token: Option
= invoice - .swap_tokens - .get(i) - .unwrap_or(None); + let swap_token: Option
= invoice.swap_tokens.get(i).unwrap_or(None); if let Some(out_token) = swap_token { let from_token = invoice.tokens.get(0).expect("no token"); let mut args: Vec = Vec::new(env); @@ -5119,7 +5887,8 @@ impl SplitContract { args.push_back(out_token.clone().into_val(env)); args.push_back(payout.into_val(env)); args.push_back(recipient.into_val(env)); - let _swapped: i128 = env.invoke_contract(&out_token, &Symbol::new(env, "swap"), args); + let _swapped: i128 = + env.invoke_contract(&out_token, &Symbol::new(env, "swap"), args); } else if invoice.smart_route { let from_token = invoice.tokens.get(0).expect("no token"); let mut route_args: Vec = Vec::new(env); @@ -5128,14 +5897,26 @@ impl SplitContract { route_args.push_back(recipient.clone().into_val(env)); token_client.transfer(&env.current_contract_address(), &recipient, &payout); } else if invoice.convert_to_stream { - if let Some(stream_contract) = env.storage().persistent().get::(&stream_contract_key()) { + if let Some(stream_contract) = env + .storage() + .persistent() + .get::(&stream_contract_key()) + { let duration = invoice.drip_duration.unwrap_or(86_400); - token_client.transfer(&env.current_contract_address(), &stream_contract, &payout); + token_client.transfer( + &env.current_contract_address(), + &stream_contract, + &payout, + ); let mut args: Vec = Vec::new(env); args.push_back(recipient.clone().into_val(env)); args.push_back(payout.into_val(env)); args.push_back(duration.into_val(env)); - let _: Val = env.invoke_contract(&stream_contract, &Symbol::new(env, "create_stream"), args); + let _: Val = env.invoke_contract( + &stream_contract, + &Symbol::new(env, "create_stream"), + args, + ); } else { token_client.transfer(&env.current_contract_address(), &recipient, &payout); } @@ -5209,24 +5990,35 @@ impl SplitContract { let member_n = member.recipients.len(); let mut member_distributed: i128 = 0; let mut group_total_fee: i128 = 0; - for (j, (recipient, amount)) in - member.recipients.iter().zip(member.amounts.iter()).enumerate() + for (j, (recipient, amount)) in member + .recipients + .iter() + .zip(member.amounts.iter()) + .enumerate() { let proportional = if j == (member_n - 1) as usize { member_funded - member_distributed } else { - (amount as u128 * member_funded as u128 / member_total as u128) as i128 + (amount as u128 * member_funded as u128 / member_total as u128) + as i128 }; - let fee = (proportional as u128 * platform_fee_bps as u128 / 10_000u128) as i128; - let tax = (proportional as u128 * member.tax_bps as u128 / 10_000u128) as i128; + let fee = (proportional as u128 * platform_fee_bps as u128 / 10_000u128) + as i128; + let tax = (proportional as u128 * member.tax_bps as u128 / 10_000u128) + as i128; let payout = proportional - fee - tax; member_distributed += proportional; group_total_fee += fee; if tax > 0 { let tax_authority = member.tax_authority.as_ref().unwrap(); - member_token.transfer(&env.current_contract_address(), tax_authority, &tax); + member_token.transfer( + &env.current_contract_address(), + tax_authority, + &tax, + ); } - let routed = Self::execute_smart_route(env, &member, &recipient, payout); + let routed = + Self::execute_smart_route(env, &member, &recipient, payout); if !routed { member_token.transfer( &env.current_contract_address(), @@ -5252,7 +6044,13 @@ impl SplitContract { save_invoice(env, member_id, &member); append_audit_entry(env, member_id, symbol_short!("release"), actor); events::invoice_released(env, member_id, &member.recipients); - events::invoice_state_changed(env, member_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Released, actor); + events::invoice_state_changed( + env, + member_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Released, + actor, + ); } } } @@ -5260,7 +6058,11 @@ impl SplitContract { // Return insurance fund to creator on successful release. if invoice.insurance_fund > 0 { - token_client.transfer(&env.current_contract_address(), &invoice.creator, &invoice.insurance_fund); + token_client.transfer( + &env.current_contract_address(), + &invoice.creator, + &invoice.insurance_fund, + ); invoice.insurance_fund = 0; } @@ -5279,24 +6081,39 @@ impl SplitContract { .persistent() .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(target_id, shard_id)) .unwrap_or_else(|| Vec::new(env)); - shard_payments.push_back(Payment { payer: env.current_contract_address(), amount: leftover, tip: 0, attestation_hash: None, donate_on_failure: false }); - env.storage().persistent().set(&pay_shard_key(target_id, shard_id), &shard_payments); + shard_payments.push_back(Payment { + payer: env.current_contract_address(), + amount: leftover, + tip: 0, + attestation_hash: None, + donate_on_failure: false, + }); + env.storage() + .persistent() + .set(&pay_shard_key(target_id, shard_id), &shard_payments); target.funded += leftover; // If target becomes fully funded, trigger auto-release where applicable. let target_total: i128 = target.amounts.iter().sum(); if target.funded >= target_total { - let in_group = env.storage().persistent().has(&invoice_group_key(target_id)); - let guarded = - target.prerequisite_id.is_some() - || !target.tranches.is_empty() - || !target.release_stages.is_empty() - || in_group - || !target.co_signers.is_empty(); + let in_group = env + .storage() + .persistent() + .has(&invoice_group_key(target_id)); + let guarded = target.prerequisite_id.is_some() + || !target.tranches.is_empty() + || !target.release_stages.is_empty() + || in_group + || !target.co_signers.is_empty(); if guarded { save_invoice(env, target_id, &target); } else { - Self::_release(env, target_id, &mut target, &env.current_contract_address()); + Self::_release( + env, + target_id, + &mut target, + &env.current_contract_address(), + ); } } else { save_invoice(env, target_id, &target); @@ -5308,15 +6125,33 @@ impl SplitContract { invoice.completion_time = Some(env.ledger().timestamp()); if invoice.insurance_fund > 0 { let token_client = token::Client::new(env, &invoice.tokens.get(0).expect("no token")); - token_client.transfer(&env.current_contract_address(), &invoice.creator, &invoice.insurance_fund); + token_client.transfer( + &env.current_contract_address(), + &invoice.creator, + &invoice.insurance_fund, + ); invoice.insurance_fund = 0; } save_invoice(env, invoice_id, invoice); append_audit_entry(env, invoice_id, symbol_short!("release"), actor); events::invoice_released(env, invoice_id, &invoice.recipients); - events::invoice_state_changed(env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Released, actor); - notify_invoice(env, invoice_id, symbol_short!("release"), &invoice.notification_contract); + events::invoice_state_changed( + env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Released, + actor, + ); + notify_invoice( + env, + invoice_id, + symbol_short!("release"), + &invoice.notification_contract, + ); maybe_record_released(env, &invoice.creator, funded); + update_rep_internal(env, &invoice.creator, |score| { + score.invoices_released = score.invoices_released.saturating_add(1); + }); // Increment total_volume and total_released counters (issue #28). let total_volume: i128 = env @@ -5324,11 +6159,12 @@ impl SplitContract { .persistent() .get(&total_volume_key()) .unwrap_or(0i128); - let new_total_volume = total_volume.checked_add(funded).expect("total_volume overflow"); - env.storage().persistent().set( - &total_volume_key(), - &new_total_volume, - ); + let new_total_volume = total_volume + .checked_add(funded) + .expect("total_volume overflow"); + env.storage() + .persistent() + .set(&total_volume_key(), &new_total_volume); // Issue #276: emit platform volume milestone if threshold crossed. check_platform_milestone(env, new_total_volume); @@ -5339,7 +6175,9 @@ impl SplitContract { .unwrap_or(0i128); env.storage().persistent().set( &total_released_key(), - &total_released.checked_add(funded).expect("total_released overflow"), + &total_released + .checked_add(funded) + .expect("total_released overflow"), ); // Increment creator analytics (issue #106). @@ -5349,7 +6187,9 @@ impl SplitContract { .persistent() .get(&creator_stats_volume_key(&invoice.creator)) .unwrap_or(0u64); - let new_creator_volume = creator_volume.checked_add(funded as u64).expect("creator_volume overflow"); + let new_creator_volume = creator_volume + .checked_add(funded as u64) + .expect("creator_volume overflow"); env.storage().persistent().set( &creator_stats_volume_key(&invoice.creator), &new_creator_volume, @@ -5411,11 +6251,12 @@ impl SplitContract { None, None, Vec::new(env), // priorities - false, // require_kyc - None, // scheduled_release_at - None, // release_delay_ledgers - None, // metadata_hash - None, // target_usd_cents + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // min_payer_rep ); env.storage() .persistent() @@ -5439,7 +6280,10 @@ impl SplitContract { "invoice is not pending" ); assert!(!invoice.disputed, "invoice is disputed"); - assert!(!invoice.auto_resolve_rules.is_empty(), "no auto-resolve rules defined"); + assert!( + !invoice.auto_resolve_rules.is_empty(), + "no auto-resolve rules defined" + ); let total: i128 = invoice.amounts.iter().sum(); assert!(total > 0, "invoice total must be positive"); @@ -5456,10 +6300,8 @@ impl SplitContract { Self::_release(&env, invoice_id, &mut invoice, &caller); } ResolveAction::Refund => { - let token_client = token::Client::new( - &env, - &invoice.tokens.get(0).expect("no token"), - ); + let token_client = + token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); let mut totals: Map = Map::new(&env); for payment in invoice.payments.iter() { let prev = totals.get(payment.payer.clone()).unwrap_or(0); @@ -5467,11 +6309,7 @@ impl SplitContract { } let mut total_refunded_amount: i128 = 0; for (payer, amount) in totals.iter() { - token_client.transfer( - &env.current_contract_address(), - &payer, - &amount, - ); + token_client.transfer(&env.current_contract_address(), &payer, &amount); total_refunded_amount += amount; events::payer_refunded(&env, invoice_id, &payer, amount); } @@ -5481,9 +6319,20 @@ impl SplitContract { let actor = env.current_contract_address(); append_audit_entry(&env, invoice_id, symbol_short!("auto_ref"), &actor); events::invoice_refunded(&env, invoice_id); - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Refunded, &actor); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Refunded, + &actor, + ); maybe_record_refunded(&env, &invoice.creator); - notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract); + notify_invoice( + &env, + invoice_id, + symbol_short!("refund"), + &invoice.notification_contract, + ); let total_refunded: i128 = env .storage() .persistent() @@ -5517,8 +6366,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!( - invoice.creator == creator - || invoice.co_creators.iter().any(|c| c == creator), + invoice.creator == creator || invoice.co_creators.iter().any(|c| c == creator), "only creator can refund" ); assert!( @@ -5528,8 +6376,7 @@ impl SplitContract { assert!(bps <= 10_000, "bps must be ≤ 10000"); assert!(invoice.funded > 0, "no funds to refund"); - let token_client = - token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); let mut total_refunded: i128 = 0; for payment in invoice.payments.iter() { @@ -5545,7 +6392,10 @@ impl SplitContract { } } - invoice.funded = invoice.funded.checked_sub(total_refunded).expect("funded underflow"); + invoice.funded = invoice + .funded + .checked_sub(total_refunded) + .expect("funded underflow"); save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("part_ref"), &creator); events::partial_refund_issued(&env, invoice_id, &creator, bps, total_refunded); @@ -5578,22 +6428,30 @@ impl SplitContract { if invoice.auction_end == 0 { invoice.auction_end = now.saturating_add(24 * 60 * 60); save_invoice(&env, invoice_id, &invoice); - append_audit_entry(&env, invoice_id, symbol_short!("auc_strt"), &env.current_contract_address()); + append_audit_entry( + &env, + invoice_id, + symbol_short!("auc_strt"), + &env.current_contract_address(), + ); return; } assert!(now > invoice.auction_end, "auction in progress"); panic!("auction ended; settle auction"); } - let token_client = - token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); // Aggregate payments from all shards (issue #177). let mut totals: Map = Map::new(&env); // Issue #204: separate map for donate-on-failure contributions. let mut donate_totals: Map = Map::new(&env); for shard_id in 0..SHARD_COUNT { - if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) { + if let Some(shard_payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) + { for payment in shard_payments.iter() { if payment.donate_on_failure { let prev = donate_totals.get(payment.payer.clone()).unwrap_or(0); @@ -5635,9 +6493,23 @@ impl SplitContract { let actor = env.current_contract_address(); append_audit_entry(&env, invoice_id, symbol_short!("refund"), &actor); events::invoice_refunded(&env, invoice_id); - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Refunded, &actor); - notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Refunded, + &actor, + ); + notify_invoice( + &env, + invoice_id, + symbol_short!("refund"), + &invoice.notification_contract, + ); maybe_record_refunded(&env, &invoice.creator); + update_rep_internal(&env, &invoice.creator, |score| { + score.invoices_refunded = score.invoices_refunded.saturating_add(1); + }); // Increment total_refunded counter (issue #28). let total_refunded: i128 = env @@ -5647,7 +6519,9 @@ impl SplitContract { .unwrap_or(0i128); env.storage().persistent().set( &total_refunded_key(), - &total_refunded.checked_add(total_refunded_amount).expect("total_refunded overflow"), + &total_refunded + .checked_add(total_refunded_amount) + .expect("total_refunded overflow"), ); // Increment creator refund counter (issue #106). @@ -5658,7 +6532,9 @@ impl SplitContract { .unwrap_or(0u64); env.storage().persistent().set( &creator_stats_refunded_key(&invoice.creator), - &creator_refunded.checked_add(1).expect("creator_refunded overflow"), + &creator_refunded + .checked_add(1) + .expect("creator_refunded overflow"), ); } @@ -5674,17 +6550,24 @@ impl SplitContract { assert!(now <= invoice.auction_end, "auction not active"); assert!(amount > 0, "bid amount must be positive"); - let current_highest = invoice - .bids - .iter() - .map(|b| b.amount) - .fold(0, |max, amt| if amt > max { amt } else { max }); - assert!(amount > current_highest, "bid must be higher than current highest bid"); + let current_highest = + invoice + .bids + .iter() + .map(|b| b.amount) + .fold(0, |max, amt| if amt > max { amt } else { max }); + assert!( + amount > current_highest, + "bid must be higher than current highest bid" + ); let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); token_client.transfer(&bidder, &env.current_contract_address(), &amount); - invoice.bids.push_back(Bid { bidder: bidder.clone(), amount }); + invoice.bids.push_back(Bid { + bidder: bidder.clone(), + amount, + }); save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("bid"), &bidder); } @@ -5698,7 +6581,10 @@ impl SplitContract { assert!(invoice.auction_end > 0, "auction not started"); let now = env.ledger().timestamp(); assert!(now > invoice.auction_end, "auction not ended"); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); @@ -5714,18 +6600,37 @@ impl SplitContract { if let Some(idx) = winner_idx { let winner = invoice.bids.get(idx).unwrap(); - token_client.transfer(&env.current_contract_address(), &winner.bidder, &invoice.funded); + token_client.transfer( + &env.current_contract_address(), + &winner.bidder, + &invoice.funded, + ); for i in 0..invoice.bids.len() { if i != idx { let bid = invoice.bids.get(i).unwrap(); - token_client.transfer(&env.current_contract_address(), &bid.bidder, &bid.amount); + token_client.transfer( + &env.current_contract_address(), + &bid.bidder, + &bid.amount, + ); } } invoice.status = InvoiceStatus::Refunded; invoice.completion_time = Some(now); save_invoice(&env, invoice_id, &invoice); - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Refunded, &env.current_contract_address()); - append_audit_entry(&env, invoice_id, symbol_short!("auc_stl"), &env.current_contract_address()); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Refunded, + &env.current_contract_address(), + ); + append_audit_entry( + &env, + invoice_id, + symbol_short!("auc_stl"), + &env.current_contract_address(), + ); return; } @@ -5733,7 +6638,11 @@ impl SplitContract { // Aggregate payments from all shards (issue #177). let mut totals: Map = Map::new(&env); for shard_id in 0..SHARD_COUNT { - if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) { + if let Some(shard_payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) + { for payment in shard_payments.iter() { let prev = totals.get(payment.payer.clone()).unwrap_or(0); totals.set(payment.payer.clone(), prev + payment.amount); @@ -5749,19 +6658,40 @@ impl SplitContract { } if invoice.bonus_pool > 0 { - token_client.transfer(&env.current_contract_address(), &invoice.creator, &invoice.bonus_pool); + token_client.transfer( + &env.current_contract_address(), + &invoice.creator, + &invoice.bonus_pool, + ); } invoice.status = InvoiceStatus::Refunded; invoice.completion_time = Some(now); save_invoice(&env, invoice_id, &invoice); - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Refunded, &env.current_contract_address()); - append_audit_entry(&env, invoice_id, symbol_short!("auc_stl"), &env.current_contract_address()); - - let total_refunded: i128 = env.storage().persistent().get(&total_refunded_key()).unwrap_or(0i128); - env.storage().persistent().set( - &total_refunded_key(), - &total_refunded.checked_add(total_refunded_amount).expect("total_refunded overflow"), + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Refunded, + &env.current_contract_address(), + ); + append_audit_entry( + &env, + invoice_id, + symbol_short!("auc_stl"), + &env.current_contract_address(), + ); + + let total_refunded: i128 = env + .storage() + .persistent() + .get(&total_refunded_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_refunded_key(), + &total_refunded + .checked_add(total_refunded_amount) + .expect("total_refunded overflow"), ); } @@ -5813,13 +6743,16 @@ impl SplitContract { if invoice.funded > 0 { // Refund all payments. - let token_client = - token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); // Aggregate payments from all shards (issue #177). let mut totals: Map = Map::new(&env); for shard_id in 0..SHARD_COUNT { - if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) { + if let Some(shard_payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) + { for payment in shard_payments.iter() { let prev = totals.get(payment.payer.clone()).unwrap_or(0); totals.set(payment.payer.clone(), prev + payment.amount); @@ -5834,7 +6767,8 @@ impl SplitContract { for (payer, amount) in totals.iter() { let mut refund = amount; if invoice.insurance_fund > 0 { - let premium_refund = (amount as u128 * invoice.insurance_fund as u128 / invoice.funded as u128) as i128; + let premium_refund = (amount as u128 * invoice.insurance_fund as u128 + / invoice.funded as u128) as i128; refund += premium_refund; } token_client.transfer(&env.current_contract_address(), &payer, &refund); @@ -5860,7 +6794,8 @@ impl SplitContract { } if total_paid > 0 { for (payer, amt) in totals.iter() { - let share = (invoice.insurance_fund as u128 * amt as u128 / total_paid as u128) as i128; + let share = (invoice.insurance_fund as u128 * amt as u128 + / total_paid as u128) as i128; if share > 0 { token_client.transfer(&env.current_contract_address(), &payer, &share); } @@ -5870,7 +6805,13 @@ impl SplitContract { } invoice.status = InvoiceStatus::Refunded; - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Refunded, &caller); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Refunded, + &caller, + ); maybe_record_refunded(&env, &invoice.creator); // Increment total_refunded counter (issue #28). @@ -5881,7 +6822,9 @@ impl SplitContract { .unwrap_or(0i128); env.storage().persistent().set( &total_refunded_key(), - &total_refunded.checked_add(total_refunded_amount).expect("total_refunded overflow"), + &total_refunded + .checked_add(total_refunded_amount) + .expect("total_refunded overflow"), ); } else { if invoice.bonus_pool > 0 { @@ -5893,12 +6836,18 @@ impl SplitContract { &invoice.bonus_pool, ); } - + // Issue #89: Return stake to creator if no payments were made. // (stake_amount field not yet on Invoice; skipped) invoice.status = InvoiceStatus::Cancelled; - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Cancelled, &caller); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Cancelled, + &caller, + ); } save_invoice(&env, invoice_id, &invoice); @@ -5954,10 +6903,8 @@ impl SplitContract { cos.require_auth(); } else { // Accept caller = creator OR assigned delegate (issue #43). - let delegate: Option
= env - .storage() - .persistent() - .get(&delegate_key(invoice_id)); + let delegate: Option
= + env.storage().persistent().get(&delegate_key(invoice_id)); let is_creator = invoice.creator == caller; let is_delegate = delegate.map(|d| d == caller).unwrap_or(false); assert!(is_creator || is_delegate, "not authorized"); @@ -6044,6 +6991,7 @@ impl SplitContract { old_invoice.priorities.clone(), old_invoice.require_kyc, old_invoice.scheduled_release_at, + old_invoice.min_payer_rep, None, // release_delay_ledgers None, // metadata_hash None, // target_usd_cents @@ -6051,11 +6999,17 @@ impl SplitContract { // Copy payments from shards to new invoice (issue #177). for shard_id in 0..SHARD_COUNT { - if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) { - env.storage().persistent().set(&pay_shard_key(new_id, shard_id), &shard_payments); + if let Some(shard_payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) + { + env.storage() + .persistent() + .set(&pay_shard_key(new_id, shard_id), &shard_payments); } } - + // Load the newly created invoice and set funded amount. let mut new_invoice = load_invoice(&env, new_id); new_invoice.funded = old_invoice.funded; @@ -6065,7 +7019,13 @@ impl SplitContract { old_invoice.status = InvoiceStatus::Refunded; old_invoice.completion_time = Some(env.ledger().timestamp()); save_invoice(&env, invoice_id, &old_invoice); - events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Refunded, &caller); + events::invoice_state_changed( + &env, + invoice_id, + Some(&InvoiceStatus::Pending), + &InvoiceStatus::Refunded, + &caller, + ); append_audit_entry(&env, invoice_id, symbol_short!("rollover"), &caller); append_audit_entry(&env, new_id, symbol_short!("rollover"), &caller); @@ -6107,7 +7067,10 @@ impl SplitContract { ); assert!(!invoice.disputed, "invoice is disputed"); assert!(invoice.creator == caller, "only creator can add recipients"); - assert!(invoice.funded == 0, "cannot add recipient after payment received"); + assert!( + invoice.funded == 0, + "cannot add recipient after payment received" + ); assert!(amount > 0, "amount must be positive"); let token = invoice.tokens.get(0).expect("no token"); @@ -6141,12 +7104,7 @@ impl SplitContract { /// Only the creator may call this. Panics if any payment has already been /// made (`invoice.funded > 0`). The length of `new_amounts` must match the /// existing number of recipients, and every amount must be positive. - pub fn adjust_split( - env: Env, - caller: Address, - invoice_id: u64, - new_amounts: Vec, - ) { + pub fn adjust_split(env: Env, caller: Address, invoice_id: u64, new_amounts: Vec) { require_not_paused(&env); caller.require_auth(); @@ -6315,11 +7273,12 @@ impl SplitContract { None, None, Vec::new(&env), // priorities - false, // require_kyc - None, // scheduled_release_at - None, // release_delay_ledgers - None, // metadata_hash - None, // target_usd_cents + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // min_payer_rep ) } @@ -6332,12 +7291,7 @@ impl SplitContract { assert!(invoice_ids.len() >= 2, "group needs at least 2 invoices"); let grp_cnt_key = symbol_short!("grp_cnt"); - let group_id: u64 = env - .storage() - .persistent() - .get(&grp_cnt_key) - .unwrap_or(0u64) - + 1; + let group_id: u64 = env.storage().persistent().get(&grp_cnt_key).unwrap_or(0u64) + 1; env.storage().persistent().set(&grp_cnt_key, &group_id); for id in invoice_ids.iter() { @@ -6345,11 +7299,13 @@ impl SplitContract { .persistent() .set(&invoice_group_key(id), &group_id); } - let mode = if majority { types::GroupMode::Majority } else { types::GroupMode::AllOrNothing }; + let mode = if majority { + types::GroupMode::Majority + } else { + types::GroupMode::AllOrNothing + }; let group = types::InvoiceGroup { invoice_ids, mode }; - env.storage() - .persistent() - .set(&group_key(group_id), &group); + env.storage().persistent().set(&group_key(group_id), &group); group_id } @@ -6365,7 +7321,10 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); - assert!(invoice.allow_early_withdrawal, "early withdrawal not allowed"); + assert!( + invoice.allow_early_withdrawal, + "early withdrawal not allowed" + ); assert!(!invoice.disputed, "invoice is disputed"); assert!( invoice.status == InvoiceStatus::Pending, @@ -6382,7 +7341,11 @@ impl SplitContract { // Remove payer's payments from all shards (issue #177). for shard_id in 0..SHARD_COUNT { - if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) { + if let Some(shard_payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) + { let mut new_shard_payments: Vec = Vec::new(&env); for payment in shard_payments.iter() { if payment.payer != payer { @@ -6390,16 +7353,19 @@ impl SplitContract { } } if new_shard_payments.is_empty() { - env.storage().persistent().remove(&pay_shard_key(invoice_id, shard_id)); + env.storage() + .persistent() + .remove(&pay_shard_key(invoice_id, shard_id)); } else { - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &new_shard_payments); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &new_shard_payments); } } } invoice.funded -= total_paid; - let token_client = - token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); token_client.transfer(&env.current_contract_address(), &payer, &total_paid); // Decrement credit score by 2 on early withdrawal (floor 0) (issue #38). @@ -6500,8 +7466,7 @@ impl SplitContract { invoice.claimed.set(idx, already_claimed + claimable); save_invoice(&env, invoice_id, &invoice); - let token_client = - token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); token_client.transfer(&env.current_contract_address(), &recipient, &claimable); } @@ -6518,7 +7483,10 @@ impl SplitContract { env.storage() .instance() .has(&archive_marker_key(invoice_id)) - || env.storage().persistent().has(&archive_marker_key(invoice_id)) + || env + .storage() + .persistent() + .has(&archive_marker_key(invoice_id)) } pub fn get_invoice_ext(env: Env, invoice_id: u64) -> InvoiceExt { @@ -6546,14 +7514,17 @@ impl SplitContract { .sum() } + /// Returns the full `RepScore` struct for an address (issue #349). + pub fn get_rep(env: Env, address: Address) -> RepScore { + get_rep_internal(&env, &address) + } + /// Returns the on-chain reputation score (number of successful payments) for an address. /// /// Returns 0 for an address that has never paid. pub fn get_reputation(env: Env, address: Address) -> u64 { - env.storage() - .persistent() - .get(&rep_key(&address)) - .unwrap_or(0u64) + let score = get_rep_internal(&env, &address); + (score.paid_on_time as u64).saturating_add(score.late_pays as u64) } /// Returns the credit score for an address. @@ -6583,8 +7554,7 @@ impl SplitContract { let invoice = load_invoice(&env, invoice_id); assert!( - invoice.status == InvoiceStatus::Released - || invoice.status == InvoiceStatus::Refunded, + invoice.status == InvoiceStatus::Released || invoice.status == InvoiceStatus::Refunded, "invoice not finalized" ); @@ -6634,7 +7604,12 @@ impl SplitContract { let bytes = Bytes::from_array(&env, &preimage); let proof_hash: BytesN<32> = env.crypto().sha256(&bytes).into(); - PaymentProof { invoice_id, payer, total_paid, proof_hash } + PaymentProof { + invoice_id, + payer, + total_paid, + proof_hash, + } } /// Verify a payment proof against the current invoice state. @@ -6646,8 +7621,12 @@ impl SplitContract { /// Pure view function — no state mutation, no auth required. pub fn verify_payment_proof(env: Env, proof: PaymentProof) -> bool { // Return false if invoice doesn't exist - let invoice = if env.storage().persistent().has(&invoice_key(proof.invoice_id)) - || env.storage().instance().has(&invoice_key(proof.invoice_id)) { + let invoice = if env + .storage() + .persistent() + .has(&invoice_key(proof.invoice_id)) + || env.storage().instance().has(&invoice_key(proof.invoice_id)) + { load_invoice(&env, proof.invoice_id) } else { return false; @@ -6770,7 +7749,11 @@ impl SplitContract { /// Panics with "invoice not completed" if the invoice is still Pending or Cancelled. /// After archival, `get_invoice` still returns the invoice from instance storage. pub fn archive_invoice(env: Env, invoice_id: u64) { - if env.storage().instance().has(&archive_marker_key(invoice_id)) { + if env + .storage() + .instance() + .has(&archive_marker_key(invoice_id)) + { return; } @@ -6782,48 +7765,98 @@ impl SplitContract { .expect("invoice not found"); assert!( - core.status == InvoiceStatus::Released - || core.status == InvoiceStatus::Refunded, + core.status == InvoiceStatus::Released || core.status == InvoiceStatus::Refunded, "invoice not completed" ); - let ext: InvoiceExt = env.storage().persistent() + let ext: InvoiceExt = env + .storage() + .persistent() .get(&invoice_ext_key(invoice_id)) .unwrap_or_else(|| InvoiceExt { - co_signers: Vec::new(&env), required_signatures: 0, signatures: Vec::new(&env), - approver: None, approved: false, oracle_address: None, condition_met: false, - penalty_bps: 0, penalty_deadline: 0, min_funding_bps: 0, - release_stages: Vec::new(&env), released_stages: 0, allowed_payers: None, - price_oracle: None, base_amounts: Vec::new(&env), swap_tokens: Vec::new(&env), - tax_bps: 0, tax_authority: None, insurance_premium_bps: 0, insurance_fund: 0, - smart_route: false, convert_to_stream: false, accepted_tokens: Vec::new(&env), - forward_to: None, forward_invoice_id: None, split_rules: Vec::new(&env), - auto_resolve_rules: Vec::new(&env), creator_cosigner: None, velocity_limit: 0, - velocity_window: 0, parent_invoice_id: None, pause_reason: None, auto_resume_at: None, - payment_cooldown_secs: None, max_payments_per_window: None, payment_window_secs: None, - scheduled_release_at: None, refund_grace_secs: None, - penalty_tiers: Vec::new(&env), allowed_callers: None, + co_signers: Vec::new(&env), + required_signatures: 0, + signatures: Vec::new(&env), + approver: None, + approved: false, + oracle_address: None, + condition_met: false, + penalty_bps: 0, + penalty_deadline: 0, + min_funding_bps: 0, + release_stages: Vec::new(&env), + released_stages: 0, + allowed_payers: None, + price_oracle: None, + base_amounts: Vec::new(&env), + swap_tokens: Vec::new(&env), + tax_bps: 0, + tax_authority: None, + insurance_premium_bps: 0, + insurance_fund: 0, + smart_route: false, + convert_to_stream: false, + accepted_tokens: Vec::new(&env), + forward_to: None, + forward_invoice_id: None, + split_rules: Vec::new(&env), + auto_resolve_rules: Vec::new(&env), + creator_cosigner: None, + velocity_limit: 0, + velocity_window: 0, + parent_invoice_id: None, + pause_reason: None, + auto_resume_at: None, + payment_cooldown_secs: None, + max_payments_per_window: None, + payment_window_secs: None, + scheduled_release_at: None, + refund_grace_secs: None, + penalty_tiers: Vec::new(&env), + allowed_callers: None, }); - let ext2: InvoiceExt2 = env.storage().persistent() + let ext2: InvoiceExt2 = env + .storage() + .persistent() .get(&invoice_ext2_key(invoice_id)) .unwrap_or_else(|| InvoiceExt2 { - notification_contract: None, overflow_behavior: OverflowBehavior::Reject, - cross_chain_ref: None, require_kyc: false, arbiter: None, disputed: false, + notification_contract: None, + overflow_behavior: OverflowBehavior::Reject, + cross_chain_ref: None, + require_kyc: false, + arbiter: None, + disputed: false, admin_frozen: false, - auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), - min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), - target_usd_cents: None, refunded_addresses: Vec::new(&env), + auction_on_expiry: false, + auction_end: 0, + bids: Vec::new(&env), + min_payment: 0, + min_funding_amount: 0, + priorities: Vec::new(&env), + target_usd_cents: None, + refunded_addresses: Vec::new(&env), + min_payer_rep: None, }); // Copy to instance storage. - env.storage().instance().set(&invoice_key(invoice_id), &core); - env.storage().instance().set(&invoice_ext_key(invoice_id), &ext); - env.storage().instance().set(&invoice_ext2_key(invoice_id), &ext2); + env.storage() + .instance() + .set(&invoice_key(invoice_id), &core); + env.storage() + .instance() + .set(&invoice_ext_key(invoice_id), &ext); + env.storage() + .instance() + .set(&invoice_ext2_key(invoice_id), &ext2); // Remove from persistent storage. env.storage().persistent().remove(&invoice_key(invoice_id)); - env.storage().persistent().remove(&invoice_ext_key(invoice_id)); - env.storage().persistent().remove(&invoice_ext2_key(invoice_id)); + env.storage() + .persistent() + .remove(&invoice_ext_key(invoice_id)); + env.storage() + .persistent() + .remove(&invoice_ext2_key(invoice_id)); events::invoice_archived(&env, invoice_id); } @@ -6842,32 +7875,73 @@ impl SplitContract { } let core: InvoiceCore = env.storage().persistent().get(&invoice_key(id)).unwrap(); if core.status == InvoiceStatus::Released || core.status == InvoiceStatus::Refunded { - let ext: InvoiceExt = env.storage().persistent() + let ext: InvoiceExt = env + .storage() + .persistent() .get(&invoice_ext_key(id)) .unwrap_or_else(|| InvoiceExt { - co_signers: Vec::new(&env), required_signatures: 0, signatures: Vec::new(&env), - approver: None, approved: false, oracle_address: None, condition_met: false, - penalty_bps: 0, penalty_deadline: 0, min_funding_bps: 0, - release_stages: Vec::new(&env), released_stages: 0, allowed_payers: None, - price_oracle: None, base_amounts: Vec::new(&env), swap_tokens: Vec::new(&env), - tax_bps: 0, tax_authority: None, insurance_premium_bps: 0, insurance_fund: 0, - smart_route: false, convert_to_stream: false, accepted_tokens: Vec::new(&env), - forward_to: None, forward_invoice_id: None, split_rules: Vec::new(&env), - auto_resolve_rules: Vec::new(&env), creator_cosigner: None, velocity_limit: 0, - velocity_window: 0, parent_invoice_id: None, pause_reason: None, auto_resume_at: None, - payment_cooldown_secs: None, max_payments_per_window: None, payment_window_secs: None, - scheduled_release_at: None, refund_grace_secs: None, - penalty_tiers: Vec::new(&env), allowed_callers: None, + co_signers: Vec::new(&env), + required_signatures: 0, + signatures: Vec::new(&env), + approver: None, + approved: false, + oracle_address: None, + condition_met: false, + penalty_bps: 0, + penalty_deadline: 0, + min_funding_bps: 0, + release_stages: Vec::new(&env), + released_stages: 0, + allowed_payers: None, + price_oracle: None, + base_amounts: Vec::new(&env), + swap_tokens: Vec::new(&env), + tax_bps: 0, + tax_authority: None, + insurance_premium_bps: 0, + insurance_fund: 0, + smart_route: false, + convert_to_stream: false, + accepted_tokens: Vec::new(&env), + forward_to: None, + forward_invoice_id: None, + split_rules: Vec::new(&env), + auto_resolve_rules: Vec::new(&env), + creator_cosigner: None, + velocity_limit: 0, + velocity_window: 0, + parent_invoice_id: None, + pause_reason: None, + auto_resume_at: None, + payment_cooldown_secs: None, + max_payments_per_window: None, + payment_window_secs: None, + scheduled_release_at: None, + refund_grace_secs: None, + penalty_tiers: Vec::new(&env), + allowed_callers: None, }); - let ext2: InvoiceExt2 = env.storage().persistent() + let ext2: InvoiceExt2 = env + .storage() + .persistent() .get(&invoice_ext2_key(id)) .unwrap_or_else(|| InvoiceExt2 { - notification_contract: None, overflow_behavior: OverflowBehavior::Reject, - cross_chain_ref: None, require_kyc: false, arbiter: None, disputed: false, + notification_contract: None, + overflow_behavior: OverflowBehavior::Reject, + cross_chain_ref: None, + require_kyc: false, + arbiter: None, + disputed: false, admin_frozen: false, - auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), - min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), - target_usd_cents: None, refunded_addresses: Vec::new(&env), + auction_on_expiry: false, + auction_end: 0, + bids: Vec::new(&env), + min_payment: 0, + min_funding_amount: 0, + priorities: Vec::new(&env), + target_usd_cents: None, + refunded_addresses: Vec::new(&env), + min_payer_rep: None, }); env.storage().instance().set(&invoice_key(id), &core); @@ -6902,7 +7976,12 @@ impl SplitContract { .set(&delegate_key(invoice_id), &delegate); events::delegate_set(&env, invoice_id, &delegate); - append_audit_entry(&env, invoice_id, symbol_short!("delegate"), &invoice.creator); + append_audit_entry( + &env, + invoice_id, + symbol_short!("delegate"), + &invoice.creator, + ); } /// Remove the delegate from an invoice. Requires creator auth. @@ -6910,9 +7989,7 @@ impl SplitContract { let invoice = load_invoice(&env, invoice_id); invoice.creator.require_auth(); - env.storage() - .persistent() - .remove(&delegate_key(invoice_id)); + env.storage().persistent().remove(&delegate_key(invoice_id)); events::delegate_revoked(&env, invoice_id); append_audit_entry(&env, invoice_id, symbol_short!("rvk_del"), &invoice.creator); @@ -6920,9 +7997,7 @@ impl SplitContract { /// Return the current delegate for an invoice, or None if none is set. pub fn get_delegate(env: Env, invoice_id: u64) -> Option
{ - env.storage() - .persistent() - .get(&delegate_key(invoice_id)) + env.storage().persistent().get(&delegate_key(invoice_id)) } /// Authorise an address to pay on behalf of the beneficiary. @@ -6939,7 +8014,9 @@ impl SplitContract { if !delegates.iter().any(|d| d == delegate) { delegates.push_back(delegate.clone()); - env.storage().persistent().set(&delegate_pay_key(&beneficiary), &delegates); + env.storage() + .persistent() + .set(&delegate_pay_key(&beneficiary), &delegates); } } @@ -6963,9 +8040,15 @@ impl SplitContract { assert!(delegates.iter().any(|d| d == delegate), "not authorised"); let mut invoice = load_invoice(&env, invoice_id); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); assert!(!invoice.disputed, "invoice is disputed"); - assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); + assert!( + env.ledger().timestamp() <= invoice.deadline, + "invoice deadline has passed" + ); assert!(amount > 0, "payment amount must be positive"); let total: i128 = invoice.amounts.iter().sum(); @@ -6982,22 +8065,37 @@ impl SplitContract { .persistent() .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) .unwrap_or_else(|| Vec::new(&env)); - shard_payments.push_back(Payment { payer: beneficiary.clone(), amount, tip: 0, attestation_hash: None, donate_on_failure: false }); - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments); - + shard_payments.push_back(Payment { + payer: beneficiary.clone(), + amount, + tip: 0, + attestation_hash: None, + donate_on_failure: false, + }); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &shard_payments); + invoice.funded += amount; append_audit_entry(&env, invoice_id, symbol_short!("del_pay"), &delegate); events::payment_received(&env, invoice_id, &beneficiary, amount); - notify_invoice(&env, invoice_id, symbol_short!("pay"), &invoice.notification_contract); + notify_invoice( + &env, + invoice_id, + symbol_short!("pay"), + &invoice.notification_contract, + ); - let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); - let guarded = - invoice.prerequisite_id.is_some() - || !invoice.tranches.is_empty() - || !invoice.release_stages.is_empty() - || in_group - || !invoice.co_signers.is_empty(); + let in_group = env + .storage() + .persistent() + .has(&invoice_group_key(invoice_id)); + let guarded = invoice.prerequisite_id.is_some() + || !invoice.tranches.is_empty() + || !invoice.release_stages.is_empty() + || in_group + || !invoice.co_signers.is_empty(); if invoice.funded >= total { if guarded { save_invoice(&env, invoice_id, &invoice); @@ -7034,10 +8132,7 @@ impl SplitContract { (invoice.max_payments_per_window, invoice.payment_window_secs) { let recent = Self::active_payment_window(env, invoice_id, now, window_secs); - assert!( - recent.len() < max_payments, - "payment rate limit exceeded" - ); + assert!(recent.len() < max_payments, "payment rate limit exceeded"); } } @@ -7068,12 +8163,7 @@ impl SplitContract { } } - fn active_payment_window( - env: &Env, - invoice_id: u64, - now: u64, - window_secs: u64, - ) -> Vec { + fn active_payment_window(env: &Env, invoice_id: u64, now: u64, window_secs: u64) -> Vec { let stored: Vec = env .storage() .persistent() @@ -7132,14 +8222,11 @@ impl SplitContract { cert_hash, }; - env.storage() - .persistent() - .set(&cert_key(invoice_id), &cert); + env.storage().persistent().set(&cert_key(invoice_id), &cert); cert } - pub fn get_certificate(env: Env, invoice_id: u64) -> PaymentCertificate { env.storage() .persistent() @@ -7181,16 +8268,24 @@ impl SplitContract { /// Activate the circuit breaker. Admin-only. Halts all mutating entry points. pub fn activate_circuit_breaker(env: Env, admin: Address, reason: String) { require_role(&env, &admin, AdminRole::SuperAdmin); - env.storage().persistent().set(&circuit_breaker_key(), &true); - env.storage().persistent().set(&circuit_breaker_reason_key(), &reason.clone()); + env.storage() + .persistent() + .set(&circuit_breaker_key(), &true); + env.storage() + .persistent() + .set(&circuit_breaker_reason_key(), &reason.clone()); events::circuit_breaker_activated(&env, &reason); } /// Deactivate the circuit breaker. Admin-only. pub fn deactivate_circuit_breaker(env: Env, admin: Address) { require_role(&env, &admin, AdminRole::SuperAdmin); - env.storage().persistent().set(&circuit_breaker_key(), &false); - env.storage().persistent().remove(&circuit_breaker_reason_key()); + env.storage() + .persistent() + .set(&circuit_breaker_key(), &false); + env.storage() + .persistent() + .remove(&circuit_breaker_reason_key()); events::circuit_breaker_deactivated(&env); } @@ -7227,7 +8322,9 @@ impl SplitContract { ); if !waivers.iter().any(|a| a == creator) { waivers.push_back(creator.clone()); - env.storage().persistent().set(&creator_fee_waiver_key(), &waivers); + env.storage() + .persistent() + .set(&creator_fee_waiver_key(), &waivers); events::fee_waiver_granted(&env, &creator); } } @@ -7246,7 +8343,9 @@ impl SplitContract { new_waivers.push_back(a); } } - env.storage().persistent().set(&creator_fee_waiver_key(), &new_waivers); + env.storage() + .persistent() + .set(&creator_fee_waiver_key(), &new_waivers); events::fee_waiver_revoked(&env, &creator); } @@ -7298,7 +8397,9 @@ impl SplitContract { let proof_is_nonzero = range_proof.iter().any(|b| b != 0); assert!(proof_is_nonzero, "invalid range proof"); - let already_exists = env.storage().persistent() + let already_exists = env + .storage() + .persistent() .has(&confidential_pay_key(invoice_id, &payer)); let record = ConfidentialPayment { @@ -7355,7 +8456,11 @@ impl SplitContract { let mut invoice = invoice; let total: i128 = invoice.amounts.iter().sum(); let new_funded = invoice.funded + decrypted_sum; - invoice.funded = if new_funded > total { total } else { new_funded }; + invoice.funded = if new_funded > total { + total + } else { + new_funded + }; save_invoice(&env, invoice_id, &invoice); if invoice.funded >= total { @@ -7407,7 +8512,11 @@ impl SplitContract { // Compute this payer's total contribution (across all shards). let mut payer_total: i128 = 0; for shard_id in 0..SHARD_COUNT { - if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) { + if let Some(shard_payments) = env + .storage() + .persistent() + .get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) + { for payment in shard_payments.iter() { if payment.payer == payer && !payment.donate_on_failure { payer_total += payment.amount; @@ -7451,7 +8560,9 @@ impl SplitContract { new_wasm_hash: new_wasm_hash.clone(), eligible_at, }; - env.storage().instance().set(&upgrade_proposal_key(), &proposal); + env.storage() + .instance() + .set(&upgrade_proposal_key(), &proposal); events::upgrade_proposed(&env, &new_wasm_hash, eligible_at); } @@ -7473,7 +8584,8 @@ impl SplitContract { env.storage().instance().remove(&upgrade_proposal_key()); events::upgrade_executed(&env, &proposal.new_wasm_hash); - env.deployer().update_current_contract_wasm(proposal.new_wasm_hash); + env.deployer() + .update_current_contract_wasm(proposal.new_wasm_hash); } /// Cancel a pending upgrade proposal. Only the admin may call this. @@ -7530,22 +8642,34 @@ impl SplitContract { .persistent() .get(&key) .expect("no delegation authorization"); - assert!(stored_delegate == executor, "caller is not the authorized delegate"); + assert!( + stored_delegate == executor, + "caller is not the authorized delegate" + ); // Consume delegation (single-use). env.storage().persistent().remove(&key); let mut invoice = load_invoice(&env, invoice_id); - assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); assert!(!invoice.disputed, "invoice is disputed"); - assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); + assert!( + env.ledger().timestamp() <= invoice.deadline, + "invoice deadline has passed" + ); assert!(amount > 0, "payment amount must be positive"); assert!(!invoice.frozen, "invoice is frozen"); assert!(!invoice.admin_frozen, "invoice frozen by admin"); // Allowed-payers check uses on_behalf_of identity. if let Some(ref whitelist) = invoice.allowed_payers { - assert!(whitelist.contains(&on_behalf_of), "on_behalf_of not in allowed payers"); + assert!( + whitelist.contains(&on_behalf_of), + "on_behalf_of not in allowed payers" + ); } let total: i128 = invoice.amounts.iter().sum(); @@ -7580,7 +8704,9 @@ impl SplitContract { attestation_hash: None, donate_on_failure: false, }); - env.storage().persistent().set(&pay_shard_key(invoice_id, shard_id), &shard_payments); + env.storage() + .persistent() + .set(&pay_shard_key(invoice_id, shard_id), &shard_payments); invoice.funded += amount; @@ -7590,7 +8716,10 @@ impl SplitContract { update_creator_stats_on_payment(&env, &invoice.creator, amount); if invoice.funded >= total { - let in_group = env.storage().persistent().has(&invoice_group_key(invoice_id)); + let in_group = env + .storage() + .persistent() + .has(&invoice_group_key(invoice_id)); let guarded = invoice.prerequisite_id.is_some() || !invoice.tranches.is_empty() || !invoice.release_stages.is_empty() @@ -7614,12 +8743,7 @@ impl SplitContract { /// Raise a dispute on an invoice within 48 ledgers of full funding. /// Callable by any address that has made a payment toward the invoice. /// Blocked on `release_funds` until resolved or auto-expired (72 ledgers). - pub fn raise_payer_dispute( - env: Env, - invoice_id: u64, - payer: Address, - reason_hash: BytesN<32>, - ) { + pub fn raise_payer_dispute(env: Env, invoice_id: u64, payer: Address, reason_hash: BytesN<32>) { require_not_paused(&env); payer.require_auth(); @@ -7681,12 +8805,17 @@ impl SplitContract { .persistent() .get(&dispute_record_key(invoice_id)) .expect("no dispute record"); - assert!(record.status == DisputeStatus::Active, "dispute is not active"); + assert!( + record.status == DisputeStatus::Active, + "dispute is not active" + ); match outcome { DisputeOutcome::Approved => { record.status = DisputeStatus::Resolved; - env.storage().persistent().set(&dispute_record_key(invoice_id), &record); + env.storage() + .persistent() + .set(&dispute_record_key(invoice_id), &record); invoice.disputed = false; save_invoice(&env, invoice_id, &invoice); events::dispute_resolved(&env, invoice_id, &admin_addr, &DisputeOutcome::Approved); @@ -7695,12 +8824,12 @@ impl SplitContract { } DisputeOutcome::Refunded => { record.status = DisputeStatus::Resolved; - env.storage().persistent().set(&dispute_record_key(invoice_id), &record); + env.storage() + .persistent() + .set(&dispute_record_key(invoice_id), &record); - let token_client = token::Client::new( - &env, - &invoice.tokens.get(0).expect("no token"), - ); + let token_client = + token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); let mut totals: Map = Map::new(&env); for payment in invoice.payments.iter() { let prev = totals.get(payment.payer.clone()).unwrap_or(0); @@ -7742,7 +8871,10 @@ impl SplitContract { .persistent() .get(&dispute_record_key(invoice_id)) .expect("no dispute record"); - assert!(record.status == DisputeStatus::Active, "dispute is not active"); + assert!( + record.status == DisputeStatus::Active, + "dispute is not active" + ); assert!( env.ledger().sequence() > record.raised_at + 72, "dispute expiry window not reached (72 ledgers)" @@ -7750,7 +8882,9 @@ impl SplitContract { let mut updated = record.clone(); updated.status = DisputeStatus::Expired; - env.storage().persistent().set(&dispute_record_key(invoice_id), &updated); + env.storage() + .persistent() + .set(&dispute_record_key(invoice_id), &updated); invoice.disputed = false; save_invoice(&env, invoice_id, &invoice); @@ -7796,73 +8930,264 @@ impl SplitContract { // ----------------------------------------------------------------------- // Issue #316: Compute budget estimation utility // ----------------------------------------------------------------------- + // Issue #316 / #351: Compute budget estimation utility + // ----------------------------------------------------------------------- - /// Estimate the compute budget for a given public function and recipient count. - /// Off-chain callers use this to size transactions before submission. - pub fn estimate_compute(env: Env, function_name: Symbol, recipient_count: u32) -> ComputeEstimate { - let recipients = recipient_count as u64; + /// Helper for estimate_compute parameter extraction. + fn get_u64_param( + env: &Env, + params: &Map, + keys: &[Symbol], + ) -> Result, ContractError> { + for k in keys.iter() { + if let Some(val) = params.get(k.clone()) { + if let Ok(v) = u64::try_from_val(env, &val) { + return Ok(Some(v)); + } else if let Ok(v) = u32::try_from_val(env, &val) { + return Ok(Some(v as u64)); + } else if let Ok(v) = i128::try_from_val(env, &val) { + if v >= 0 { + return Ok(Some(v as u64)); + } else { + return Err(ContractError::InvalidAmount); + } + } else { + return Err(ContractError::InvalidAmount); + } + } + } + Ok(None) + } + + /// Helper for estimate_compute recipient list/count extraction. + fn get_recipients_len( + env: &Env, + params: &Map, + ) -> Result, ContractError> { + let keys = [ + Symbol::new(env, "recipients"), + symbol_short!("recip"), + Symbol::new(env, "recipient_count"), + symbol_short!("count"), + ]; + for k in keys.iter() { + if let Some(val) = params.get(k.clone()) { + if let Ok(vec) = Vec::
::try_from_val(env, &val) { + return Ok(Some(vec.len() as u64)); + } else if let Ok(vec) = Vec::::try_from_val(env, &val) { + return Ok(Some(vec.len() as u64)); + } else if let Ok(c) = u32::try_from_val(env, &val) { + return Ok(Some(c as u64)); + } else if let Ok(c) = u64::try_from_val(env, &val) { + return Ok(Some(c)); + } else { + return Err(ContractError::InvalidRecipients); + } + } + } + Ok(None) + } + + /// Helper for estimate_compute invoice verification. + fn load_invoice_opt(env: &Env, invoice_id: u64) -> Result { + if let Some(core) = env.storage().persistent().get(&invoice_key(invoice_id)) { + Ok(core) + } else if let Some(core) = env.storage().instance().get(&invoice_key(invoice_id)) { + Ok(core) + } else { + Err(ContractError::InvoiceNotFound) + } + } + + /// Estimate the compute budget for a given public contract function and parameters. + /// Returns `Result` without mutating state. + pub fn estimate_compute( + env: Env, + operation: Symbol, + params: Map, + ) -> Result { + let sym_create_full = Symbol::new(&env, "create_invoice"); + let sym_create_short = symbol_short!("create"); + let sym_create_alt = symbol_short!("create_i"); + + let sym_pay = symbol_short!("pay"); + let sym_pay_full = Symbol::new(&env, "pay"); + let sym_dlgt = symbol_short!("pay_dlgt"); - let sym_create = symbol_short!("create_i"); - let sym_pay = symbol_short!("pay"); - let sym_dlgt = symbol_short!("pay_dlgt"); let sym_release = symbol_short!("release"); - let sym_get_inv = symbol_short!("get_inv"); - let sym_stats = symbol_short!("get_stat"); - - let (instructions, mem_bytes, read_entries, write_entries): (u64, u64, u32, u32) = - if function_name == sym_create { - ( - INSTRUCTIONS_BASE + recipients * 200_000, - (128 + recipients * 64) * 1024, - (2 + recipients) as u32, - (4 + recipients) as u32, - ) - } else if function_name == sym_pay || function_name == sym_dlgt { - ( - INSTRUCTIONS_BASE + INSTRUCTIONS_PER_SHARD * SHARD_COUNT, - 256 * 1024, - 4, - 4, - ) - } else if function_name == sym_release { - ( - INSTRUCTIONS_BASE + recipients * INSTRUCTIONS_PER_RECIPIENT + INSTRUCTIONS_PER_SHARD * SHARD_COUNT, - (256 + recipients * 32) * 1024, - 4 + SHARD_COUNT as u32 + recipients as u32, - 2 + recipients as u32, - ) - } else if function_name == sym_get_inv { - ( - INSTRUCTIONS_BASE / 4, - 64 * 1024, - 3, - 0, - ) - } else if function_name == sym_stats { - ( - INSTRUCTIONS_BASE / 2, - 128 * 1024, - 2, - 0, - ) - } else { - (INSTRUCTIONS_BASE, 128 * 1024, 2, 2) + let sym_release_full = Symbol::new(&env, "release"); + + let sym_refund = symbol_short!("refund"); + let sym_refund_full = Symbol::new(&env, "refund"); + + let sym_dispute_full = Symbol::new(&env, "open_dispute"); + let sym_dispute_raise = Symbol::new(&env, "raise_dispute"); + let sym_dispute_short = symbol_short!("dispute"); + + let sym_approve_full = Symbol::new(&env, "approve_release"); + let sym_approve_inv = Symbol::new(&env, "approve_invoice"); + let sym_approve_short = symbol_short!("approve"); + + let (cpu_insns, mem_bytes): (u64, u64) = if operation == sym_create_full + || operation == sym_create_short + || operation == sym_create_alt + { + let r = Self::get_recipients_len(&env, ¶ms)?; + let recip_count = match r { + Some(cnt) => cnt, + None => return Err(ContractError::InvalidRecipients), + }; + if recip_count == 0 { + return Err(ContractError::InvalidRecipients); + } + ( + INSTRUCTIONS_BASE + recip_count * 200_000, + (128 + recip_count * 64) * 1024, + ) + } else if operation == sym_pay || operation == sym_pay_full || operation == sym_dlgt { + let inv_id_opt = Self::get_u64_param( + &env, + ¶ms, + &[ + Symbol::new(&env, "invoice_id"), + symbol_short!("id"), + Symbol::new(&env, "invoice"), + ], + )?; + let recip_cnt_opt = Self::get_recipients_len(&env, ¶ms)?; + + let recip_cnt = match (inv_id_opt, recip_cnt_opt) { + (Some(id), _) => { + let inv = Self::load_invoice_opt(&env, id)?; + inv.recipients.len() as u64 + } + (None, Some(cnt)) => cnt, + (None, None) => return Err(ContractError::InvoiceNotFound), + }; + + ( + INSTRUCTIONS_BASE + + INSTRUCTIONS_PER_SHARD * SHARD_COUNT + + recip_cnt * (INSTRUCTIONS_PER_RECIPIENT / 2), + (256 + recip_cnt * 16) * 1024, + ) + } else if operation == sym_release || operation == sym_release_full { + let inv_id_opt = Self::get_u64_param( + &env, + ¶ms, + &[ + Symbol::new(&env, "invoice_id"), + symbol_short!("id"), + Symbol::new(&env, "invoice"), + ], + )?; + let recip_cnt_opt = Self::get_recipients_len(&env, ¶ms)?; + + let recip_cnt = match (inv_id_opt, recip_cnt_opt) { + (Some(id), _) => { + let inv = Self::load_invoice_opt(&env, id)?; + inv.recipients.len() as u64 + } + (None, Some(cnt)) => cnt, + (None, None) => return Err(ContractError::InvoiceNotFound), + }; + + ( + INSTRUCTIONS_BASE + + recip_cnt * INSTRUCTIONS_PER_RECIPIENT + + INSTRUCTIONS_PER_SHARD * SHARD_COUNT, + (256 + recip_cnt * 32) * 1024, + ) + } else if operation == sym_refund || operation == sym_refund_full { + let inv_id_opt = Self::get_u64_param( + &env, + ¶ms, + &[ + Symbol::new(&env, "invoice_id"), + symbol_short!("id"), + Symbol::new(&env, "invoice"), + ], + )?; + let payer_cnt_opt = Self::get_u64_param( + &env, + ¶ms, + &[ + Symbol::new(&env, "payer_count"), + Symbol::new(&env, "payers"), + symbol_short!("count"), + ], + )?; + + let payer_cnt = match (inv_id_opt, payer_cnt_opt) { + (Some(id), _) => { + let _inv = Self::load_invoice_opt(&env, id)?; + 1u64 + } + (None, Some(cnt)) => cnt, + (None, None) => return Err(ContractError::InvoiceNotFound), + }; + + ( + INSTRUCTIONS_BASE + INSTRUCTIONS_PER_SHARD * SHARD_COUNT + payer_cnt * 350_000, + (256 + payer_cnt * 32) * 1024, + ) + } else if operation == sym_dispute_full + || operation == sym_dispute_raise + || operation == sym_dispute_short + { + let inv_id = match Self::get_u64_param( + &env, + ¶ms, + &[ + Symbol::new(&env, "invoice_id"), + symbol_short!("id"), + Symbol::new(&env, "invoice"), + ], + )? { + Some(id) => id, + None => return Err(ContractError::InvoiceNotFound), + }; + + let _inv = Self::load_invoice_opt(&env, inv_id)?; + (INSTRUCTIONS_BASE + 200_000, 128 * 1024) + } else if operation == sym_approve_full + || operation == sym_approve_inv + || operation == sym_approve_short + { + let inv_id = match Self::get_u64_param( + &env, + ¶ms, + &[ + Symbol::new(&env, "invoice_id"), + symbol_short!("id"), + Symbol::new(&env, "invoice"), + ], + )? { + Some(id) => id, + None => return Err(ContractError::InvoiceNotFound), }; - let budget_pct = instructions * 100 / INSTRUCTION_BUDGET_LIMIT; + let _inv = Self::load_invoice_opt(&env, inv_id)?; + (INSTRUCTIONS_BASE + 150_000, 128 * 1024) + } else { + return Err(ContractError::InvalidStatus); + }; + + let budget_pct = cpu_insns * 100 / INSTRUCTION_BUDGET_LIMIT; if budget_pct > 80 { env.events().publish( - (symbol_short!("split"), symbol_short!("bdgt_w"), function_name), - (instructions, INSTRUCTION_BUDGET_LIMIT), + (symbol_short!("split"), symbol_short!("bdgt_w"), operation), + (cpu_insns, INSTRUCTION_BUDGET_LIMIT), ); } - ComputeEstimate { - instructions, + let fee_stroops = (cpu_insns as i128 / 10_000) * STROOPS_PER_10K_INSTRUCTIONS as i128; + + Ok(ComputeEstimate { + cpu_insns, mem_bytes, - read_entries, - write_entries, - } + fee_stroops, + }) } // ----------------------------------------------------------------------- @@ -7915,7 +9240,11 @@ impl SplitContract { }; // Issue #332: ensure recipients + amounts lists are present. - if !env.storage().persistent().has(&recipients_list_key(invoice_id)) { + if !env + .storage() + .persistent() + .has(&recipients_list_key(invoice_id)) + { save_recipients_list(&env, invoice_id, &invoice.recipients, &invoice.amounts); } @@ -7955,12 +9284,8 @@ impl SplitContract { /// Falls back to reading from `InvoiceCore` for invoices created before /// this optimisation was deployed. pub fn get_recipients_list(env: Env, invoice_id: u64) -> Vec
{ - let (recipients, _amounts) = load_recipients_list( - &env, - invoice_id, - &Vec::new(&env), - &Vec::new(&env), - ); + let (recipients, _amounts) = + load_recipients_list(&env, invoice_id, &Vec::new(&env), &Vec::new(&env)); if !recipients.is_empty() { recipients } else { @@ -7969,4 +9294,3 @@ impl SplitContract { } } } - diff --git a/contracts/split/src/storage_snapshot.rs b/contracts/split/src/storage_snapshot.rs index dff57c2..92e7501 100644 --- a/contracts/split/src/storage_snapshot.rs +++ b/contracts/split/src/storage_snapshot.rs @@ -1,4 +1,5 @@ #![cfg(test)] +#![allow(clippy::all)] extern crate std; @@ -23,7 +24,10 @@ fn hex_xdr(env: &Env, val: impl ToXdr) -> StdString { fn storage_key_snapshot() { let env = Env::default(); // Deterministic address (all-zero G... strkey) — do not change. - let a = Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + let a = Address::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); let s = symbol_short!("x"); let mut keys: StdVec<(&str, StdString)> = StdVec::new(); @@ -39,40 +43,88 @@ fn storage_key_snapshot() { keys.push(("treasury_key", hex_xdr(&env, treasury_key()))); keys.push(("usdc_token_key", hex_xdr(&env, usdc_token_key()))); keys.push(("creation_fee_key", hex_xdr(&env, creation_fee_key()))); - keys.push(("platform_fee_bps_key", hex_xdr(&env, platform_fee_bps_key()))); - keys.push(("platform_fee_waiver_list_key", hex_xdr(&env, platform_fee_waiver_list_key()))); - keys.push(("creator_fee_waiver_key", hex_xdr(&env, creator_fee_waiver_key()))); + keys.push(( + "platform_fee_bps_key", + hex_xdr(&env, platform_fee_bps_key()), + )); + keys.push(( + "platform_fee_waiver_list_key", + hex_xdr(&env, platform_fee_waiver_list_key()), + )); + keys.push(( + "creator_fee_waiver_key", + hex_xdr(&env, creator_fee_waiver_key()), + )); keys.push(("counter_key", hex_xdr(&env, counter_key()))); - keys.push(("global_payer_limit_key", hex_xdr(&env, global_payer_limit_key()))); - keys.push(("global_payer_window_key", hex_xdr(&env, global_payer_window_key()))); + keys.push(( + "global_payer_limit_key", + hex_xdr(&env, global_payer_limit_key()), + )); + keys.push(( + "global_payer_window_key", + hex_xdr(&env, global_payer_window_key()), + )); keys.push(("stream_contract_key", hex_xdr(&env, stream_contract_key()))); - keys.push(("creator_whitelist_key", hex_xdr(&env, creator_whitelist_key()))); + keys.push(( + "creator_whitelist_key", + hex_xdr(&env, creator_whitelist_key()), + )); keys.push(("compliance_key", hex_xdr(&env, compliance_key()))); keys.push(("rate_limit_key", hex_xdr(&env, rate_limit_key()))); keys.push(("rate_window_key", hex_xdr(&env, rate_window_key()))); keys.push(("max_cancel_bps_key", hex_xdr(&env, max_cancel_bps_key()))); keys.push(("receipt_factory_key", hex_xdr(&env, receipt_factory_key()))); - keys.push(("dashboard_contract_key", hex_xdr(&env, dashboard_contract_key()))); + keys.push(( + "dashboard_contract_key", + hex_xdr(&env, dashboard_contract_key()), + )); keys.push(("nft_gate_key", hex_xdr(&env, nft_gate_key()))); keys.push(("timelock_secs_key", hex_xdr(&env, timelock_secs_key()))); - keys.push(("timelock_action_counter_key", hex_xdr(&env, timelock_action_counter_key()))); + keys.push(( + "timelock_action_counter_key", + hex_xdr(&env, timelock_action_counter_key()), + )); keys.push(("fee_tiers_key", hex_xdr(&env, fee_tiers_key()))); keys.push(("pending_admin_key", hex_xdr(&env, pending_admin_key()))); - keys.push(("governance_contract_key", hex_xdr(&env, governance_contract_key()))); + keys.push(( + "governance_contract_key", + hex_xdr(&env, governance_contract_key()), + )); keys.push(("factories_key", hex_xdr(&env, factories_key()))); keys.push(("total_invoices_key", hex_xdr(&env, total_invoices_key()))); keys.push(("total_volume_key", hex_xdr(&env, total_volume_key()))); keys.push(("total_released_key", hex_xdr(&env, total_released_key()))); keys.push(("total_refunded_key", hex_xdr(&env, total_refunded_key()))); - keys.push(("treasury_group_counter_key", hex_xdr(&env, treasury_group_counter_key()))); + keys.push(( + "treasury_group_counter_key", + hex_xdr(&env, treasury_group_counter_key()), + )); keys.push(("circuit_breaker_key", hex_xdr(&env, circuit_breaker_key()))); - keys.push(("circuit_breaker_reason_key", hex_xdr(&env, circuit_breaker_reason_key()))); - keys.push(("archive_after_ledgers_key", hex_xdr(&env, archive_after_ledgers_key()))); - keys.push(("platform_vol_thresh_key", hex_xdr(&env, platform_vol_thresh_key()))); - keys.push(("platform_vol_mile_key", hex_xdr(&env, platform_vol_mile_key()))); - keys.push(("creator_vol_thresh_key", hex_xdr(&env, creator_vol_thresh_key()))); + keys.push(( + "circuit_breaker_reason_key", + hex_xdr(&env, circuit_breaker_reason_key()), + )); + keys.push(( + "archive_after_ledgers_key", + hex_xdr(&env, archive_after_ledgers_key()), + )); + keys.push(( + "platform_vol_thresh_key", + hex_xdr(&env, platform_vol_thresh_key()), + )); + keys.push(( + "platform_vol_mile_key", + hex_xdr(&env, platform_vol_mile_key()), + )); + keys.push(( + "creator_vol_thresh_key", + hex_xdr(&env, creator_vol_thresh_key()), + )); keys.push(("kyc_contract_key", hex_xdr(&env, kyc_contract_key()))); - keys.push(("upgrade_proposal_key", hex_xdr(&env, upgrade_proposal_key()))); + keys.push(( + "upgrade_proposal_key", + hex_xdr(&env, upgrade_proposal_key()), + )); // ----------------------------------------------------------------------- // Persistent-tier keys (per-entity) @@ -87,12 +139,21 @@ fn storage_key_snapshot() { keys.push(("audit_log_key", hex_xdr(&env, audit_log_key(1)))); keys.push(("archive_marker_key", hex_xdr(&env, archive_marker_key(1)))); keys.push(("created_ledger_key", hex_xdr(&env, created_ledger_key(1)))); - keys.push(("subscription_params_key", hex_xdr(&env, subscription_params_key(1)))); - keys.push(("confidential_count_key", hex_xdr(&env, confidential_count_key(1)))); + keys.push(( + "subscription_params_key", + hex_xdr(&env, subscription_params_key(1)), + )); + keys.push(( + "confidential_count_key", + hex_xdr(&env, confidential_count_key(1)), + )); keys.push(("ext_vote_key", hex_xdr(&env, ext_vote_key(1)))); keys.push(("group_key", hex_xdr(&env, group_key(1)))); keys.push(("invoice_group_key", hex_xdr(&env, invoice_group_key(1)))); - keys.push(("invoice_treasury_key", hex_xdr(&env, invoice_treasury_key(1)))); + keys.push(( + "invoice_treasury_key", + hex_xdr(&env, invoice_treasury_key(1)), + )); keys.push(("group_treasury_key", hex_xdr(&env, group_treasury_key(1)))); keys.push(("delegate_key", hex_xdr(&env, delegate_key(1)))); keys.push(("payment_window_key", hex_xdr(&env, payment_window_key(1)))); @@ -106,25 +167,61 @@ fn storage_key_snapshot() { keys.push(("rep_key", hex_xdr(&env, rep_key(&a)))); keys.push(("credit_key", hex_xdr(&env, credit_key(&a)))); keys.push(("referral_count_key", hex_xdr(&env, referral_count_key(&a)))); - keys.push(("recipient_invoice_ids_key", hex_xdr(&env, recipient_invoice_ids_key(&a)))); + keys.push(( + "recipient_invoice_ids_key", + hex_xdr(&env, recipient_invoice_ids_key(&a)), + )); keys.push(("delegate_pay_key", hex_xdr(&env, delegate_pay_key(&a)))); keys.push(("rate_usage_key", hex_xdr(&env, rate_usage_key(&a)))); keys.push(("invoice_count_key", hex_xdr(&env, invoice_count_key(&a)))); keys.push(("cancel_count_key", hex_xdr(&env, cancel_count_key(&a)))); - keys.push(("creator_stats_count_key", hex_xdr(&env, creator_stats_count_key(&a)))); - keys.push(("creator_stats_volume_key", hex_xdr(&env, creator_stats_volume_key(&a)))); - keys.push(("creator_stats_released_key", hex_xdr(&env, creator_stats_released_key(&a)))); - keys.push(("creator_stats_refunded_key", hex_xdr(&env, creator_stats_refunded_key(&a)))); - keys.push(("creator_stats_payers_key", hex_xdr(&env, creator_stats_payers_key(&a)))); - keys.push(("creator_stats_avg_funding_key", hex_xdr(&env, creator_stats_avg_funding_key(&a)))); - keys.push(("creator_vol_mile_key", hex_xdr(&env, creator_vol_mile_key(&a)))); - keys.push(("creator_volume_cap_key", hex_xdr(&env, creator_volume_cap_key(&a)))); - keys.push(("creator_volume_used_key", hex_xdr(&env, creator_volume_used_key(&a)))); + keys.push(( + "creator_stats_count_key", + hex_xdr(&env, creator_stats_count_key(&a)), + )); + keys.push(( + "creator_stats_volume_key", + hex_xdr(&env, creator_stats_volume_key(&a)), + )); + keys.push(( + "creator_stats_released_key", + hex_xdr(&env, creator_stats_released_key(&a)), + )); + keys.push(( + "creator_stats_refunded_key", + hex_xdr(&env, creator_stats_refunded_key(&a)), + )); + keys.push(( + "creator_stats_payers_key", + hex_xdr(&env, creator_stats_payers_key(&a)), + )); + keys.push(( + "creator_stats_avg_funding_key", + hex_xdr(&env, creator_stats_avg_funding_key(&a)), + )); + keys.push(( + "creator_vol_mile_key", + hex_xdr(&env, creator_vol_mile_key(&a)), + )); + keys.push(( + "creator_volume_cap_key", + hex_xdr(&env, creator_volume_cap_key(&a)), + )); + keys.push(( + "creator_volume_used_key", + hex_xdr(&env, creator_volume_used_key(&a)), + )); // (Symbol, u64, Address) - keys.push(("confidential_pay_key", hex_xdr(&env, confidential_pay_key(1, &a)))); + keys.push(( + "confidential_pay_key", + hex_xdr(&env, confidential_pay_key(1, &a)), + )); keys.push(("reminder_key", hex_xdr(&env, reminder_key(1, &a)))); - keys.push(("pending_payout_key", hex_xdr(&env, pending_payout_key(1, &a)))); + keys.push(( + "pending_payout_key", + hex_xdr(&env, pending_payout_key(1, &a)), + )); keys.push(("channel_key", hex_xdr(&env, channel_key(1, &a)))); keys.push(("nonce_key", hex_xdr(&env, nonce_key(1, &a)))); keys.push(("vel_key", hex_xdr(&env, vel_key(1, &a)))); @@ -132,7 +229,10 @@ fn storage_key_snapshot() { keys.push(("accum_key", hex_xdr(&env, accum_key(1, &a)))); // (Symbol, u64, Address) where Address is owned - keys.push(("payer_cooldown_key", hex_xdr(&env, payer_cooldown_key(1, a.clone())))); + keys.push(( + "payer_cooldown_key", + hex_xdr(&env, payer_cooldown_key(1, a.clone())), + )); // (Symbol, u64, u64) keys.push(("pay_shard_key", hex_xdr(&env, pay_shard_key(1, 1)))); @@ -147,14 +247,23 @@ fn storage_key_snapshot() { // Issue #334: compact XDR overlay keys (Symbol, u64) keys.push(("compact_status_key", hex_xdr(&env, compact_status_key(1)))); - keys.push(("compact_deadline_ledger_key", hex_xdr(&env, compact_deadline_ledger_key(1)))); + keys.push(( + "compact_deadline_ledger_key", + hex_xdr(&env, compact_deadline_ledger_key(1)), + )); // (Symbol, Address, Symbol) keys.push(("template_key", hex_xdr(&env, template_key(&a, &s)))); - keys.push(("template_version_count_key", hex_xdr(&env, template_version_count_key(&a, &s)))); + keys.push(( + "template_version_count_key", + hex_xdr(&env, template_version_count_key(&a, &s)), + )); // (Symbol, Address, Symbol, u32) - keys.push(("template_version_key", hex_xdr(&env, template_version_key(&a, &s, 1)))); + keys.push(( + "template_version_key", + hex_xdr(&env, template_version_key(&a, &s, 1)), + )); // Sort by key name for deterministic output keys.sort_by(|a, b| a.0.cmp(b.0)); diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 4d01a26..fd2736b 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -1,10 +1,12 @@ #![cfg(test)] +#![allow(clippy::all)] +#![allow(unused_comparisons)] use super::*; use soroban_sdk::{ testutils::{Address as _, Events as _, Ledger}, token::{Client as TokenClient, StellarAssetClient}, - Address, Bytes, BytesN, Env, String, Symbol, Vec, + Address, Bytes, BytesN, Env, String, Symbol, TryFromVal, Vec, }; use types::InvoiceOptions; @@ -76,6 +78,7 @@ fn default_options(env: &Env) -> InvoiceOptions { priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + min_payer_rep: None, } } @@ -125,6 +128,7 @@ fn invoice_options( priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + min_payer_rep: None, } } @@ -141,7 +145,14 @@ fn single_recipient_invoice( recipients.push_back(recipient.clone()); let mut amounts = Vec::new(env); amounts.push_back(amount); - c.create_invoice(&creator, &recipients, &amounts, token_id, &9_999_u64, &options) + c.create_invoice( + &creator, + &recipients, + &amounts, + token_id, + &9_999_u64, + &options, + ) } /// Create a basic single-recipient invoice with default optional params. @@ -158,7 +169,14 @@ fn make_invoice( recipients.push_back(recipient.clone()); let mut amounts = Vec::new(env); amounts.push_back(amount); - c.create_invoice(creator, &recipients, &amounts, token_id, &deadline, &default_options(env)) + c.create_invoice( + creator, + &recipients, + &amounts, + token_id, + &deadline, + &default_options(env), + ) } // --------------------------------------------------------------------------- @@ -314,7 +332,12 @@ fn test_multi_recipient_release() { amounts.push_back(300_i128); let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), ); c.pay(&payer, &id, &600_i128, &0_u64, &false, &false); @@ -410,7 +433,14 @@ fn test_partial_release_distributes_and_decrements_funded() { amounts.push_back(100_i128); amounts.push_back(300_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env)); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); // Payer funds 200 c.pay(&payer, &id, &200_i128, &0_u64, &false, &false); @@ -446,7 +476,14 @@ fn test_forward_to_invoice_credits_target_invoice() { recipients.push_back(recipient.clone()); let mut amounts = Vec::new(&env); amounts.push_back(100_i128); - let id_child = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id_child = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); assert_eq!(id_child, 2); // Verify the field is stored correctly. @@ -606,7 +643,12 @@ fn test_adjust_split_updates_amounts_and_pays_new_total() { amounts.push_back(100_i128); amounts.push_back(200_i128); let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), ); // Rebalance before any payment: r1=150, r2=250 (total 400). @@ -930,7 +972,9 @@ fn test_pause_blocks_pay() { env.ledger().set_timestamp(1_000); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); c.pause(&admin); @@ -952,7 +996,9 @@ fn test_unpause_restores_pay() { env.ledger().set_timestamp(1_000); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); c.pause(&admin); c.unpause(&admin); @@ -1105,7 +1151,9 @@ fn test_bonus_pool_zero_behaves_identically() { env.ledger().set_timestamp(1_000); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); // Create a v1 invoice (bonus_pool = 0, identical to no-bonus). let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); @@ -1445,8 +1493,14 @@ fn test_tranches_partial_then_full_release() { // Two tranches: 50% unlocks at t=1_500, remaining 50% at t=2_500. let mut tranches = Vec::new(&env); - tranches.push_back(types::Tranche { timestamp: 1_500, basis_points: 5_000 }); - tranches.push_back(types::Tranche { timestamp: 2_500, basis_points: 5_000 }); + tranches.push_back(types::Tranche { + timestamp: 1_500, + basis_points: 5_000, + }); + tranches.push_back(types::Tranche { + timestamp: 2_500, + basis_points: 5_000, + }); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); @@ -1511,7 +1565,10 @@ fn test_release_before_any_tranche_unlocked_panics() { env.ledger().set_timestamp(1_000); let mut tranches = Vec::new(&env); - tranches.push_back(types::Tranche { timestamp: 5_000, basis_points: 10_000 }); + tranches.push_back(types::Tranche { + timestamp: 5_000, + basis_points: 10_000, + }); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); @@ -1618,6 +1675,209 @@ fn test_reputation_is_per_address() { assert_eq!(c.get_reputation(&other), 0); } +// --------------------------------------------------------------------------- +// Issue #349 — On-chain reputation scoring (RepScore) +// --------------------------------------------------------------------------- + +#[test] +fn test_reputation_new_address_default() { + let (env, contract_id, _token_id) = setup(); + let c = client(&env, &contract_id); + + let new_addr = Address::generate(&env); + let score = c.get_rep(&new_addr); + assert_eq!( + score, + types::RepScore { + paid_on_time: 0, + late_pays: 0, + invoices_released: 0, + invoices_refunded: 0, + } + ); +} + +#[test] +fn test_reputation_release_updates_creator_and_payer() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &10_000); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 9_999); + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); + + // After release (auto-triggered on full payment): creator reputation updated with invoices_released = 1 + let creator_rep = c.get_rep(&creator); + assert_eq!(creator_rep.invoices_released, 1); + assert_eq!(creator_rep.invoices_refunded, 0); + + let payer_rep = c.get_rep(&payer); + assert_eq!(payer_rep.paid_on_time, 1); +} + +#[test] +fn test_reputation_refund_updates_creator_penalty() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + let id = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 2_000); + + // Advance time past deadline + env.ledger().set_timestamp(3_000); + c.refund(&id); + + let creator_rep = c.get_rep(&creator); + assert_eq!(creator_rep.invoices_refunded, 1); + assert_eq!(creator_rep.invoices_released, 0); +} + +#[test] +fn test_reputation_repeated_releases_accumulate() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &10_000); + env.ledger().set_timestamp(1_000); + + let id1 = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 9_999); + c.pay(&payer, &id1, &500_i128, &0_u64, &false, &false); + + let id2 = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 9_999); + c.pay(&payer, &id2, &500_i128, &0_u64, &false, &false); + + let creator_rep = c.get_rep(&creator); + assert_eq!(creator_rep.invoices_released, 2); + + let payer_rep = c.get_rep(&payer); + assert_eq!(payer_rep.paid_on_time, 2); +} + +#[test] +fn test_reputation_repeated_refunds_accumulate() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + let id1 = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 2_000); + let id2 = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 2_000); + + env.ledger().set_timestamp(3_000); + c.refund(&id1); + c.refund(&id2); + + let creator_rep = c.get_rep(&creator); + assert_eq!(creator_rep.invoices_refunded, 2); +} + +#[test] +fn test_reputation_min_payer_rep_gate_succeeds() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &10_000); + env.ledger().set_timestamp(1_000); + + // Build 1 reputation score + let id1 = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 9_999); + c.pay(&payer, &id1, &500_i128, &0_u64, &false, &false); + assert_eq!(c.get_rep(&payer).paid_on_time, 1); + + // Create invoice requiring min_payer_rep = 1 + let mut opts = default_options(&env); + opts.min_payer_rep = Some(1); + let id2 = c.create_invoice( + &creator, + &Vec::from_array(&env, [recipient]), + &Vec::from_array(&env, [500]), + &token_id, + &9_999, + &opts, + ); + + // Payment should succeed since payer has reputation 1 >= 1 + c.pay(&payer, &id2, &500_i128, &0_u64, &false, &false); + assert_eq!(c.get_rep(&payer).paid_on_time, 2); +} + +#[test] +#[should_panic(expected = "insufficient payer reputation")] +fn test_reputation_min_payer_rep_gate_rejects_low_reputation() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let low_rep_payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&low_rep_payer, &10_000); + env.ledger().set_timestamp(1_000); + + // Require min_payer_rep = 3 + let mut opts = default_options(&env); + opts.min_payer_rep = Some(3); + let id = c.create_invoice( + &creator, + &Vec::from_array(&env, [recipient]), + &Vec::from_array(&env, [500]), + &token_id, + &9_999, + &opts, + ); + + // low_rep_payer has 0 reputation, should fail with panic + c.pay(&low_rep_payer, &id, &500_i128, &0_u64, &false, &false); +} + +#[test] +fn test_reputation_event_emission() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &10_000); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 9_999); + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); + + // Check emitted events for rep_upd + let events = env.events().all(); + let has_rep_event = events.iter().any(|e| { + let topics = e.1; + if topics.len() >= 2 { + if let Ok(sym) = Symbol::try_from_val(&env, &topics.get(1).unwrap()) { + return sym == Symbol::new(&env, "rep_upd"); + } + } + false + }); + assert!(has_rep_event, "rep_upd event should be emitted"); +} + // --------------------------------------------------------------------------- // Creation fee // --------------------------------------------------------------------------- @@ -1638,7 +1898,9 @@ fn test_creation_fee_charged_to_treasury() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &50_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &50_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); assert_eq!(c.get_creation_fee(), 50); assert_eq!(c.get_treasury(), treasury); @@ -1669,7 +1931,9 @@ fn test_creation_fee_zero_by_default() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); @@ -1687,7 +1951,9 @@ fn test_set_creation_fee_updates_fee() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &10_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &10_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); assert_eq!(c.get_creation_fee(), 10); c.set_creation_fee(&admin, &25_i128); @@ -1703,7 +1969,9 @@ fn test_set_treasury_updates_treasury() { let treasury1 = Address::generate(&env); let treasury2 = Address::generate(&env); - c.initialize(&admin, &10_i128, &treasury1, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &10_i128, &treasury1, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); assert_eq!(c.get_treasury(), treasury1); c.set_treasury(&admin, &treasury2); @@ -1726,7 +1994,9 @@ fn test_creation_fee_charged_per_invoice_in_batch() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &10_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &10_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); // create_batch creates 2 invoices, each should incur a 10 unit fee. let mut recipients = Vec::new(&env); @@ -1765,7 +2035,9 @@ fn test_batch_create_3_invoices() { StellarAssetClient::new(&env, &token_id).mint(&creator, &10_000); env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); @@ -1810,7 +2082,9 @@ fn test_batch_create_10_invoices() { StellarAssetClient::new(&env, &token_id).mint(&creator, &100_000); env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); @@ -1849,7 +2123,9 @@ fn test_batch_create_exceeds_limit() { StellarAssetClient::new(&env, &token_id).mint(&creator, &100_000); env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); @@ -1885,7 +2161,9 @@ fn test_batch_create_with_invalid_item_rejected() { StellarAssetClient::new(&env, &token_id).mint(&creator, &10_000); env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); // Valid params let mut recipients = Vec::new(&env); @@ -2144,7 +2422,12 @@ fn test_rollover_invoice_preserves_recipients_and_amounts() { amounts.push_back(300_i128); let id1 = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &2_000_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &2_000_u64, + &default_options(&env), ); c.pay(&payer, &id1, &150_i128, &0_u64, &false, &false); @@ -2234,7 +2517,12 @@ fn test_recipient_invoice_ids_multi_recipient_invoice() { env.ledger().set_timestamp(1_000); let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), ); let r1_ids = c.get_recipient_invoice_ids(&r1); @@ -2284,7 +2572,9 @@ fn test_platform_fee_bps_defaults_to_zero() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); assert_eq!(c.get_platform_fee_bps(), 0); } @@ -2306,7 +2596,9 @@ fn test_platform_fee_bps_deducted_on_release() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64); // 10% + c.initialize( + &admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64, + ); // 10% let id = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 9_999); c.pay(&payer, &id, &500_i128, &0_u64, &false, &false); @@ -2337,7 +2629,9 @@ fn test_platform_fee_bps_multi_recipient() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &500_u32, &None, &0_u32, &0_u32, &0_u64); // 5% + c.initialize( + &admin, &0_i128, &treasury, &token_id, &500_u32, &None, &0_u32, &0_u32, &0_u64, + ); // 5% let mut recipients = Vec::new(&env); recipients.push_back(r1.clone()); @@ -2349,7 +2643,12 @@ fn test_platform_fee_bps_multi_recipient() { amounts.push_back(500_i128); let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), ); c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); @@ -2379,11 +2678,19 @@ fn test_platform_fee_bps_with_tranches() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64); // 10% + c.initialize( + &admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64, + ); // 10% let mut tranches = Vec::new(&env); - tranches.push_back(types::Tranche { timestamp: 1_500, basis_points: 5_000 }); - tranches.push_back(types::Tranche { timestamp: 2_500, basis_points: 5_000 }); + tranches.push_back(types::Tranche { + timestamp: 1_500, + basis_points: 5_000, + }); + tranches.push_back(types::Tranche { + timestamp: 2_500, + basis_points: 5_000, + }); let mut recipients = Vec::new(&env); recipients.push_back(recipient.clone()); @@ -2926,7 +3233,14 @@ fn test_stage_release_3_stages() { let mut opts = default_options(&env); opts.release_stages = stages; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); // Fully fund the invoice. c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); @@ -2979,7 +3293,14 @@ fn test_stage_release_after_all_stages_panics() { let mut opts = default_options(&env); opts.release_stages = stages; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); c.stage_release(&id, &creator); @@ -3013,7 +3334,14 @@ fn test_stage_release_non_creator_panics() { let mut opts = default_options(&env); opts.release_stages = stages; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); // Non-creator should not be able to call stage_release. @@ -3044,7 +3372,14 @@ fn test_stage_release_not_fully_funded_panics() { let mut opts = default_options(&env); opts.release_stages = stages; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); // Only partially fund. c.pay(&payer, &id, &500_i128, &0_u64, &false, &false); @@ -3076,7 +3411,14 @@ fn test_create_invoice_invalid_release_stages_panics() { let mut opts = default_options(&env); opts.release_stages = stages; - c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); } // --------------------------------------------------------------------------- @@ -3297,7 +3639,15 @@ fn test_analytics_pay_and_release_increments_volume() { env.ledger().set_timestamp(1_000); let invoice_amount = 250i128; - let id = make_invoice(&env, &c, &creator, &recipient, invoice_amount, &token_id, 9_999); + let id = make_invoice( + &env, + &c, + &creator, + &recipient, + invoice_amount, + &token_id, + 9_999, + ); // Pay and auto-release (full payment) c.pay(&payer, &id, &invoice_amount, &0_u64, &false, &false); @@ -3327,9 +3677,17 @@ fn test_analytics_partial_pay_then_release() { env.ledger().set_timestamp(1_000); let total_amount = 300i128; - let id = make_invoice(&env, &c, &creator, &recipient, total_amount, &token_id, 9_999); - - // Partial payment from payer1 + let id = make_invoice( + &env, + &c, + &creator, + &recipient, + total_amount, + &token_id, + 9_999, + ); + + // Partial payment from payer1 c.pay(&payer1, &id, &150_i128, &0_u64, &false, &false); let (total_invoices, total_volume, total_released, total_refunded) = c.get_stats(); assert_eq!(total_invoices, 1); @@ -3361,7 +3719,15 @@ fn test_analytics_refund_increments_counter() { env.ledger().set_timestamp(1_000); let invoice_amount = 200i128; - let id = make_invoice(&env, &c, &creator, &recipient, invoice_amount, &token_id, 2_000); + let id = make_invoice( + &env, + &c, + &creator, + &recipient, + invoice_amount, + &token_id, + 2_000, + ); // Pay but don't complete c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); @@ -3574,7 +3940,8 @@ fn test_invoice_created_with_swap_tokens_field() { let mut opts = default_options(&env); // Set a swap token for the single recipient. - let mut swap_tokens: soroban_sdk::Vec> = soroban_sdk::Vec::new(&env); + let mut swap_tokens: soroban_sdk::Vec> = + soroban_sdk::Vec::new(&env); swap_tokens.push_back(Some(token_id.clone())); opts.swap_tokens = swap_tokens; @@ -3583,7 +3950,14 @@ fn test_invoice_created_with_swap_tokens_field() { let mut amounts = soroban_sdk::Vec::new(&env); amounts.push_back(100_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); let ext = c.get_invoice_ext(&id); assert_eq!(ext.swap_tokens.len(), 1); assert_eq!(ext.swap_tokens.get(0).unwrap(), Some(token_id.clone())); @@ -3608,7 +3982,12 @@ fn test_cross_chain_ref() { amounts.push_back(100); let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &2_000_u64, &options, + &creator, + &recipients, + &amounts, + &token_id, + &2_000_u64, + &options, ); assert_eq!( @@ -3676,7 +4055,17 @@ fn test_governance_approval() { let gov_id = env.register(MockGovernance, ()); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &Some(gov_id), &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, + &0_i128, + &treasury, + &token_id, + &0_u32, + &Some(gov_id), + &0_u32, + &0_u32, + &0_u64, + ); env.ledger().set_timestamp(1_000); @@ -3698,7 +4087,17 @@ fn test_governance_rejection() { let gov_id = env.register(MockGovernance, ()); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &Some(gov_id), &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, + &0_i128, + &treasury, + &token_id, + &0_u32, + &Some(gov_id), + &0_u32, + &0_u32, + &0_u64, + ); env.ledger().set_timestamp(1_000); @@ -3769,9 +4168,15 @@ struct MockStream; impl MockStream { pub fn create_stream(env: Env, recipient: Address, amount: i128, duration: u64) { // Store the last call args so tests can verify. - env.storage().persistent().set(&soroban_sdk::symbol_short!("s_rec"), &recipient); - env.storage().persistent().set(&soroban_sdk::symbol_short!("s_amt"), &amount); - env.storage().persistent().set(&soroban_sdk::symbol_short!("s_dur"), &duration); + env.storage() + .persistent() + .set(&soroban_sdk::symbol_short!("s_rec"), &recipient); + env.storage() + .persistent() + .set(&soroban_sdk::symbol_short!("s_amt"), &amount); + env.storage() + .persistent() + .set(&soroban_sdk::symbol_short!("s_dur"), &duration); } } @@ -3786,7 +4191,9 @@ fn test_convert_to_stream_calls_stream_contract() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let stream_id = env.register(MockStream, ()); c.set_stream_contract(&admin, &stream_id); @@ -3863,10 +4270,7 @@ impl MockNotification { pub fn was_notified(env: Env, invoice_id: u64, event: Symbol) -> bool { let key = (symbol_short!("notif"), invoice_id, event.clone()); - env.storage() - .persistent() - .get(&key) - .unwrap_or(false) + env.storage().persistent().get(&key).unwrap_or(false) } } @@ -3934,7 +4338,14 @@ fn test_overflow_behavior_refund_accepts_excess() { let mut opts = default_options(&env); opts.overflow_behavior = types::OverflowBehavior::Refund; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &200_i128, &0_u64, &false, &false); let invoice = c.get_invoice(&id); @@ -3954,7 +4365,9 @@ fn test_overflow_behavior_donate_sends_excess_to_treasury() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); StellarAssetClient::new(&env, &token_id).mint(&payer, &200); env.ledger().set_timestamp(1_000); @@ -3966,7 +4379,14 @@ fn test_overflow_behavior_donate_sends_excess_to_treasury() { let mut opts = default_options(&env); opts.overflow_behavior = types::OverflowBehavior::Donate; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &200_i128, &0_u64, &false, &false); let invoice = c.get_invoice(&id); @@ -3985,7 +4405,9 @@ fn test_bridge_pay_credits_invoice_after_swap() { let recipient = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let alt_token_admin = Address::generate(&env); let alt_token_id = env @@ -4030,13 +4452,27 @@ fn test_notification_contract_receives_pay_release_and_refund() { let mut opts = default_options(&env); opts.notification_contract = Some(notifier_id.clone()); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); assert!(notifier.was_notified(&id, &symbol_short!("pay"))); assert!(notifier.was_notified(&id, &symbol_short!("release"))); - let id2 = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id2 = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); env.ledger().set_timestamp(12_000); c.refund(&id2); assert!(notifier.was_notified(&id2, &symbol_short!("refund"))); @@ -4053,7 +4489,9 @@ fn test_pay_with_token_accepted_token_credited() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); // Register alternate token and DEX. let alt_token_admin = Address::generate(&env); @@ -4139,9 +4577,18 @@ fn test_pool_pay_three_invoices_funded_correctly() { let id3 = make_invoice(&env, &c, &creator, &r3, 300, &token_id, 9_999); let mut payments = Vec::new(&env); - payments.push_back(types::InvoicePayment { invoice_id: id1, amount: 100 }); - payments.push_back(types::InvoicePayment { invoice_id: id2, amount: 200 }); - payments.push_back(types::InvoicePayment { invoice_id: id3, amount: 300 }); + payments.push_back(types::InvoicePayment { + invoice_id: id1, + amount: 100, + }); + payments.push_back(types::InvoicePayment { + invoice_id: id2, + amount: 200, + }); + payments.push_back(types::InvoicePayment { + invoice_id: id3, + amount: 300, + }); // Payer balance before: 1000; total payment: 600 → balance after: 400. c.pool_pay(&payer, &payments); @@ -4177,8 +4624,14 @@ fn test_pool_pay_invalid_invoice_reverts_all() { let id2 = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); let mut payments = Vec::new(&env); - payments.push_back(types::InvoicePayment { invoice_id: id1, amount: 50 }); // id1 no longer Pending - payments.push_back(types::InvoicePayment { invoice_id: id2, amount: 50 }); + payments.push_back(types::InvoicePayment { + invoice_id: id1, + amount: 50, + }); // id1 no longer Pending + payments.push_back(types::InvoicePayment { + invoice_id: id2, + amount: 50, + }); c.pool_pay(&payer, &payments); // should panic } @@ -4197,7 +4650,9 @@ fn test_whitelist_empty_allows_any_creator() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); // No whitelist set — any creator may create. @@ -4217,13 +4672,23 @@ fn test_non_whitelisted_creator_rejected() { let not_whitelisted = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.whitelist_creator(&admin, &whitelisted); env.ledger().set_timestamp(1_000); // not_whitelisted is not on the list — must panic. - make_invoice(&env, &c, ¬_whitelisted, &recipient, 100, &token_id, 9_999); + make_invoice( + &env, + &c, + ¬_whitelisted, + &recipient, + 100, + &token_id, + 9_999, + ); } #[test] @@ -4236,7 +4701,9 @@ fn test_whitelisted_creator_can_create() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.whitelist_creator(&admin, &creator); env.ledger().set_timestamp(1_000); @@ -4255,7 +4722,9 @@ fn test_remove_creator_from_whitelist() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.whitelist_creator(&admin, &creator); c.remove_creator(&admin, &creator); @@ -4266,7 +4735,6 @@ fn test_remove_creator_from_whitelist() { assert_eq!(id, 1); } - #[test] fn test_creator_stats_increments_on_operations() { let (env, contract_id, token_id) = setup(); @@ -4298,7 +4766,6 @@ fn test_creator_stats_increments_on_operations() { assert_eq!(stats.total_invoices, 2); } - #[test] #[should_panic(expected = "payment cooldown active")] fn test_cooldown_blocks_same_payer_within_window() { @@ -4444,7 +4911,15 @@ fn invariant_funded_never_exceeds_total() { env.ledger().set_timestamp(1_000); - let id = make_invoice(&env, &c, &creator, &recipient, *total_amount, &token_id, 9_999_999); + let id = make_invoice( + &env, + &c, + &creator, + &recipient, + *total_amount, + &token_id, + 9_999_999, + ); let total = invoice_total(&c.get_invoice(&id)); let mut nonce: u64 = 0; @@ -4579,7 +5054,15 @@ fn invariant_balance_matches_funded() { StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000_000); env.ledger().set_timestamp(1_000); - let id = make_invoice(&env, &c, &creator, &recipient, *total_amount, &token_id, 9_999_999); + let id = make_invoice( + &env, + &c, + &creator, + &recipient, + *total_amount, + &token_id, + 9_999_999, + ); // Before any payment: both funded and contract balance are 0. assert_eq!(c.get_invoice(&id).funded, 0); @@ -4848,7 +5331,10 @@ fn test_clone_with_overrides_replaces_fields() { assert_eq!(clone.deadline, 19_999); let clone_ext2 = c.get_invoice_ext2(&clone_id); - assert_eq!(clone_ext2.overflow_behavior, types::OverflowBehavior::Refund); + assert_eq!( + clone_ext2.overflow_behavior, + types::OverflowBehavior::Refund + ); } #[test] @@ -4938,7 +5424,7 @@ fn test_sharded_payment_storage() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - + // Create invoice for 2000 total (so 16 payers paying 100 each doesn't auto-release it) env.ledger().set_timestamp(1_000); let invoice_id = make_invoice(&env, &c, &creator, &recipient, 2000, &token_id, 9_999); @@ -4979,7 +5465,10 @@ fn test_sharded_payment_storage() { } } }); - assert!(populated_shards > 0, "At least some shards should be populated"); + assert!( + populated_shards > 0, + "At least some shards should be populated" + ); // Test refund reads all shards correctly env.ledger().set_timestamp(20_000); // Past deadline @@ -5044,7 +5533,7 @@ fn test_donate_on_failure_mixed_payers() { // Invoice needs 500; partially funded by a donor and a normal payer. let id = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 2_000); - c.pay(&donor, &id, &100_i128, &0_u64, &false, &true); // donate + c.pay(&donor, &id, &100_i128, &0_u64, &false, &true); // donate c.pay(&refundee, &id, &100_i128, &0_u64, &false, &false); // normal env.ledger().set_timestamp(3_000); @@ -5159,19 +5648,29 @@ fn test_all_or_nothing_group_still_requires_all_funded() { fn topic1_is(env: &Env, topics: &soroban_sdk::Vec, name: &str) -> bool { use soroban_sdk::TryIntoVal; - topics.len() >= 2 && topics - .get(1) - .and_then(|v| { let r: Result = v.try_into_val(env); r.ok() }) - .map(|s: Symbol| s == Symbol::new(env, name)) - .unwrap_or(false) + topics.len() >= 2 + && topics + .get(1) + .and_then(|v| { + let r: Result = v.try_into_val(env); + r.ok() + }) + .map(|s: Symbol| s == Symbol::new(env, name)) + .unwrap_or(false) } fn has_platform_milestone_event(env: &Env) -> bool { - env.events().all().iter().any(|(_c, topics, _d)| topic1_is(env, &topics, "plt_v_ms")) + env.events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(env, &topics, "plt_v_ms")) } fn has_creator_milestone_event(env: &Env) -> bool { - env.events().all().iter().any(|(_c, topics, _d)| topic1_is(env, &topics, "cr_v_ms")) + env.events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(env, &topics, "cr_v_ms")) } #[test] @@ -5185,7 +5684,9 @@ fn test_platform_volume_milestone_emitted() { let recipient = Address::generate(&env); let payer = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.set_platform_vol_threshold(&admin, &100_i128); StellarAssetClient::new(&env, &token_id).mint(&payer, &200); @@ -5195,7 +5696,10 @@ fn test_platform_volume_milestone_emitted() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); // total_volume = 100, milestone 1 crossed - assert!(has_platform_milestone_event(&env), "platform volume milestone event not emitted"); + assert!( + has_platform_milestone_event(&env), + "platform volume milestone event not emitted" + ); } #[test] @@ -5209,7 +5713,9 @@ fn test_platform_volume_milestone_not_emitted_below_threshold() { let recipient = Address::generate(&env); let payer = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.set_platform_vol_threshold(&admin, &500_i128); StellarAssetClient::new(&env, &token_id).mint(&payer, &200); @@ -5219,7 +5725,10 @@ fn test_platform_volume_milestone_not_emitted_below_threshold() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); // total_volume = 100, threshold = 500 → no milestone yet - assert!(!has_platform_milestone_event(&env), "unexpected platform volume milestone event"); + assert!( + !has_platform_milestone_event(&env), + "unexpected platform volume milestone event" + ); } #[test] @@ -5231,7 +5740,9 @@ fn test_platform_volume_milestone_fires_multiple_times() { let treasury = Address::generate(&env); let payer = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.set_platform_vol_threshold(&admin, &100_i128); StellarAssetClient::new(&env, &token_id).mint(&payer, &600); @@ -5245,7 +5756,8 @@ fn test_platform_volume_milestone_fires_multiple_times() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); assert!( has_platform_milestone_event(&env), - "expected platform milestone {} to fire", expected_milestone + "expected platform milestone {} to fire", + expected_milestone ); } } @@ -5261,7 +5773,9 @@ fn test_creator_volume_milestone_emitted() { let recipient = Address::generate(&env); let payer = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.set_creator_vol_threshold(&admin, &100_i128); StellarAssetClient::new(&env, &token_id).mint(&payer, &200); @@ -5270,7 +5784,10 @@ fn test_creator_volume_milestone_emitted() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); - assert!(has_creator_milestone_event(&env), "creator volume milestone event not emitted"); + assert!( + has_creator_milestone_event(&env), + "creator volume milestone event not emitted" + ); } #[test] @@ -5284,7 +5801,9 @@ fn test_milestone_disabled_when_threshold_zero() { let recipient = Address::generate(&env); let payer = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); // Disable both milestone types. c.set_platform_vol_threshold(&admin, &0_i128); c.set_creator_vol_threshold(&admin, &0_i128); @@ -5295,11 +5814,16 @@ fn test_milestone_disabled_when_threshold_zero() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); - assert!(!has_platform_milestone_event(&env), "platform milestone should be suppressed when threshold is 0"); - assert!(!has_creator_milestone_event(&env), "creator milestone should be suppressed when threshold is 0"); + assert!( + !has_platform_milestone_event(&env), + "platform milestone should be suppressed when threshold is 0" + ); + assert!( + !has_creator_milestone_event(&env), + "creator milestone should be suppressed when threshold is 0" + ); } - // --------------------------------------------------------------------------- // Issue #298: simulate_release compute cost estimation // --------------------------------------------------------------------------- @@ -5317,9 +5841,18 @@ fn test_simulate_release_returns_result_for_small_invoice() { let result = c.simulate_release(&id); // A single-recipient invoice should be well within budget. - assert!(result.would_succeed, "single-recipient invoice should succeed"); - assert!(result.estimated_instructions > 0, "instructions must be positive"); - assert!(result.estimated_fee_stroops >= 0, "fee must be non-negative"); + assert!( + result.would_succeed, + "single-recipient invoice should succeed" + ); + assert!( + result.estimated_instructions > 0, + "instructions must be positive" + ); + assert!( + result.estimated_fee_stroops >= 0, + "fee must be non-negative" + ); } #[test] @@ -5346,7 +5879,14 @@ fn test_simulate_release_at_limit_succeeds() { recipients.push_back(Address::generate(&env)); amounts.push_back(1_i128); } - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env)); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); let result = c.simulate_release(&id); assert!(result.would_succeed, "invoice at limit should succeed"); } @@ -5369,9 +5909,19 @@ fn test_simulate_release_over_limit_fails() { recipients.push_back(Address::generate(&env)); amounts.push_back(1_i128); } - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env)); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); let result = c.simulate_release(&id); - assert!(!result.would_succeed, "invoice over limit should not succeed"); + assert!( + !result.would_succeed, + "invoice over limit should not succeed" + ); } // --------------------------------------------------------------------------- @@ -5379,7 +5929,10 @@ fn test_simulate_release_over_limit_fails() { // --------------------------------------------------------------------------- fn has_circuit_breaker_event(env: &Env, topic_name: &str) -> bool { - env.events().all().iter().any(|(_c, topics, _d)| topic1_is(env, &topics, topic_name)) + env.events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(env, &topics, topic_name)) } #[test] @@ -5388,7 +5941,10 @@ fn test_circuit_breaker_defaults_inactive() { let c = client(&env, &contract_id); let status = c.get_circuit_breaker_status(); assert!(!status.active, "circuit breaker should default to inactive"); - assert!(status.reason.is_none(), "reason should be None when inactive"); + assert!( + status.reason.is_none(), + "reason should be None when inactive" + ); } #[test] @@ -5403,7 +5959,9 @@ fn test_activate_circuit_breaker_blocks_pay() { env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let _ = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); let reason = String::from_str(&env, "vulnerability discovered"); @@ -5429,7 +5987,9 @@ fn test_circuit_breaker_blocks_pay() { StellarAssetClient::new(&env, &token_id).mint(&payer, &500); env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); let reason = String::from_str(&env, "vulnerability discovered"); @@ -5445,12 +6005,17 @@ fn test_activate_circuit_breaker_emits_event() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let reason = String::from_str(&env, "emergency"); c.activate_circuit_breaker(&admin, &reason); - assert!(has_circuit_breaker_event(&env, "cb_act"), "cb_act event not emitted"); + assert!( + has_circuit_breaker_event(&env, "cb_act"), + "cb_act event not emitted" + ); } #[test] @@ -5461,14 +6026,19 @@ fn test_deactivate_circuit_breaker_restores_operations() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let reason = String::from_str(&env, "emergency"); c.activate_circuit_breaker(&admin, &reason); c.deactivate_circuit_breaker(&admin); let status = c.get_circuit_breaker_status(); - assert!(!status.active, "circuit breaker should be inactive after deactivation"); + assert!( + !status.active, + "circuit breaker should be inactive after deactivation" + ); } // --------------------------------------------------------------------------- @@ -5483,7 +6053,9 @@ fn test_set_fee_tiers() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &100_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &100_u32, &None, &0_u32, &0_u32, &0_u64, + ); let mut tiers = Vec::new(&env); tiers.push_back(types::FeeTier { @@ -5514,7 +6086,9 @@ fn test_get_applicable_fee_no_tiers() { let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &100_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &100_u32, &None, &0_u32, &0_u32, &0_u64, + ); // No tiers set, should return platform fee let fee = c.get_applicable_fee(&creator); @@ -5529,10 +6103,12 @@ fn test_get_applicable_fee_with_tiers() { let admin = Address::generate(&env); let treasury = Address::generate(&env); let creator = Address::generate(&env); - let recipient = Address::generate(&env); - let payer = Address::generate(&env); + let _recipient = Address::generate(&env); + let _payer = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &100_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &100_u32, &None, &0_u32, &0_u32, &0_u64, + ); let mut tiers = Vec::new(&env); tiers.push_back(types::FeeTier { @@ -5548,7 +6124,10 @@ fn test_get_applicable_fee_with_tiers() { // Creator has no accumulated volume yet — fee should remain at platform rate. let fee = c.get_applicable_fee(&creator); - assert_eq!(fee, 100_u32, "fee should be platform rate when volume is below threshold"); + assert_eq!( + fee, 100_u32, + "fee should be platform rate when volume is below threshold" + ); } // --------------------------------------------------------------------------- @@ -5556,7 +6135,10 @@ fn test_get_applicable_fee_with_tiers() { // --------------------------------------------------------------------------- fn has_state_changed_event(env: &Env) -> bool { - env.events().all().iter().any(|(_c, topics, _d)| topic1_is(env, &topics, "st_chg")) + env.events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(env, &topics, "st_chg")) } fn state_changed_count(env: &Env) -> usize { @@ -5582,7 +6164,10 @@ fn test_state_changed_event_emitted_on_release() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); - assert!(has_state_changed_event(&env), "invoice_state_changed not emitted on release"); + assert!( + has_state_changed_event(&env), + "invoice_state_changed not emitted on release" + ); assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); } @@ -5628,7 +6213,10 @@ fn test_state_changed_event_emitted_on_refund() { env.ledger().set_timestamp(3_000); c.refund(&id); - assert!(has_state_changed_event(&env), "invoice_state_changed not emitted on refund"); + assert!( + has_state_changed_event(&env), + "invoice_state_changed not emitted on refund" + ); } #[test] @@ -5658,13 +6246,18 @@ fn test_deactivate_circuit_breaker_emits_event() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let reason = String::from_str(&env, "emergency"); c.activate_circuit_breaker(&admin, &reason); c.deactivate_circuit_breaker(&admin); - assert!(has_circuit_breaker_event(&env, "cb_dact"), "cb_dact event not emitted"); + assert!( + has_circuit_breaker_event(&env, "cb_dact"), + "cb_dact event not emitted" + ); } #[test] @@ -5678,7 +6271,9 @@ fn test_get_invoice_unaffected_by_circuit_breaker() { let recipient = Address::generate(&env); env.ledger().set_timestamp(1_000); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); let reason = String::from_str(&env, "emergency"); @@ -5701,9 +6296,14 @@ fn test_add_fee_waiver_grants_waiver() { let admin = Address::generate(&env); let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); - assert!(!c.has_fee_waiver(&creator), "should not have waiver before grant"); + assert!( + !c.has_fee_waiver(&creator), + "should not have waiver before grant" + ); c.add_fee_waiver(&admin, &creator); assert!(c.has_fee_waiver(&creator), "should have waiver after grant"); } @@ -5716,11 +6316,16 @@ fn test_remove_fee_waiver_revokes_waiver() { let admin = Address::generate(&env); let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.add_fee_waiver(&admin, &creator); c.remove_fee_waiver(&admin, &creator); - assert!(!c.has_fee_waiver(&creator), "waiver should be gone after revocation"); + assert!( + !c.has_fee_waiver(&creator), + "waiver should be gone after revocation" + ); } #[test] @@ -5731,10 +6336,16 @@ fn test_fee_waiver_grants_event_emitted() { let admin = Address::generate(&env); let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.add_fee_waiver(&admin, &creator); - let granted = env.events().all().iter().any(|(_c, topics, _d)| topic1_is(&env, &topics, "fw_grant")); + let granted = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "fw_grant")); assert!(granted, "fw_grant event should be emitted"); } @@ -5746,11 +6357,17 @@ fn test_fee_waiver_revoke_event_emitted() { let admin = Address::generate(&env); let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.add_fee_waiver(&admin, &creator); c.remove_fee_waiver(&admin, &creator); - let revoked = env.events().all().iter().any(|(_c, topics, _d)| topic1_is(&env, &topics, "fw_rev")); + let revoked = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "fw_rev")); assert!(revoked, "fw_rev event should be emitted"); } @@ -5766,7 +6383,9 @@ fn test_fee_waiver_zeroes_platform_fee_at_release() { let recipient = Address::generate(&env); // 10% platform fee, but creator has a waiver - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.add_fee_waiver(&admin, &creator); StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); @@ -5774,7 +6393,11 @@ fn test_fee_waiver_zeroes_platform_fee_at_release() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); - assert_eq!(tk.balance(&recipient), 100, "fee waiver means recipient gets full amount"); + assert_eq!( + tk.balance(&recipient), + 100, + "fee waiver means recipient gets full amount" + ); } #[test] @@ -5789,7 +6412,10 @@ fn test_state_changed_event_emitted_on_cancel() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); c.cancel_invoice(&creator, &id); - assert!(has_state_changed_event(&env), "invoice_state_changed not emitted on cancel"); + assert!( + has_state_changed_event(&env), + "invoice_state_changed not emitted on cancel" + ); assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Cancelled); } @@ -5871,7 +6497,9 @@ fn test_308_claim_refund_after_expiry() { let recipient = Address::generate(&env); // 1000 bps (10%) platform fee - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.add_fee_waiver(&admin, &creator); StellarAssetClient::new(&env, &token_id).mint(&payer, &100); @@ -5881,7 +6509,11 @@ fn test_308_claim_refund_after_expiry() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); // With fee waiver the recipient should receive the full 100 (no 10% deducted). - assert_eq!(tk.balance(&recipient), 100, "waived creator should result in zero platform fee"); + assert_eq!( + tk.balance(&recipient), + 100, + "waived creator should result in zero platform fee" + ); } #[test] @@ -5897,7 +6529,9 @@ fn test_no_fee_waiver_deducts_platform_fee_normally() { let recipient = Address::generate(&env); // 1000 bps (10%) platform fee, no waiver - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64, + ); StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); @@ -5906,7 +6540,11 @@ fn test_no_fee_waiver_deducts_platform_fee_normally() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); // 10% fee deducted → recipient gets 90 - assert_eq!(tk.balance(&recipient), 90, "non-waived creator should pay platform fee"); + assert_eq!( + tk.balance(&recipient), + 90, + "non-waived creator should pay platform fee" + ); } #[test] @@ -5988,8 +6626,20 @@ fn test_pay_confidential_increments_counter() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); - c.pay_confidential(&payer1, &id, &make_commitment(&env, 1), &make_range_proof(&env, 1), &make_encrypted_amount(&env, 1)); - c.pay_confidential(&payer2, &id, &make_commitment(&env, 2), &make_range_proof(&env, 2), &make_encrypted_amount(&env, 2)); + c.pay_confidential( + &payer1, + &id, + &make_commitment(&env, 1), + &make_range_proof(&env, 1), + &make_encrypted_amount(&env, 1), + ); + c.pay_confidential( + &payer2, + &id, + &make_commitment(&env, 2), + &make_range_proof(&env, 2), + &make_encrypted_amount(&env, 2), + ); assert_eq!(c.get_confidential_payment_count(&id), 2); } @@ -6025,7 +6675,14 @@ fn test_refund_available_after_deadline() { recipients.push_back(recipient.clone()); let mut amounts = Vec::new(&env); amounts.push_back(500_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64, &default_options(&env)); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &2_000_u64, + &default_options(&env), + ); c.pay(&payer, &id, &200_i128, &0_u64, &false, &false); assert_eq!(tk.balance(&payer), 0); @@ -6042,7 +6699,7 @@ fn test_refund_available_after_deadline() { fn test_308_claim_refund_idempotent() { let (env, contract_id, token_id) = setup(); let c = client(&env, &contract_id); - let tk = token_client(&env, &token_id); + let _tk = token_client(&env, &token_id); let creator = Address::generate(&env); let payer = Address::generate(&env); @@ -6097,7 +6754,13 @@ fn test_reveal_confidential_total_triggers_release() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); // Submit a confidential payment for the payer (off-chain funds already moved separately). - c.pay_confidential(&payer, &id, &make_commitment(&env, 7), &make_range_proof(&env, 7), &make_encrypted_amount(&env, 7)); + c.pay_confidential( + &payer, + &id, + &make_commitment(&env, 7), + &make_range_proof(&env, 7), + &make_encrypted_amount(&env, 7), + ); // Credit actual token funds so contract can pay out on reveal. StellarAssetClient::new(&env, &token_id).mint(&contract_id, &100); @@ -6122,7 +6785,13 @@ fn test_reveal_confidential_total_rejects_zero_proof() { env.ledger().set_timestamp(1_000); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); - c.pay_confidential(&payer, &id, &make_commitment(&env, 5), &make_range_proof(&env, 5), &make_encrypted_amount(&env, 5)); + c.pay_confidential( + &payer, + &id, + &make_commitment(&env, 5), + &make_range_proof(&env, 5), + &make_encrypted_amount(&env, 5), + ); StellarAssetClient::new(&env, &token_id).mint(&contract_id, &100); // Zero proof should be rejected @@ -6178,7 +6847,6 @@ fn test_payment_shards_sum_correctly() { assert_eq!(c.get_invoice(&id).funded, 50); } - // --------------------------------------------------------------------------- // Issue #297: Circuit breaker tests // --------------------------------------------------------------------------- @@ -6190,7 +6858,9 @@ fn test_circuit_breaker_activate_deactivate() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); // Initially circuit breaker is inactive @@ -6221,7 +6891,9 @@ fn test_circuit_breaker_blocks_create_invoice() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); let reason = String::from_str(&env, "emergency"); @@ -6231,7 +6903,14 @@ fn test_circuit_breaker_blocks_create_invoice() { recipients.push_back(recipient); let mut amounts = Vec::new(&env); amounts.push_back(100_i128); - c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env)); + c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); } // --------------------------------------------------------------------------- @@ -6240,7 +6919,7 @@ fn test_circuit_breaker_blocks_create_invoice() { #[test] fn test_get_creator_stats_empty() { - let (env, contract_id, token_id) = setup(); + let (env, contract_id, _token_id) = setup(); let c = client(&env, &contract_id); let creator = Address::generate(&env); @@ -6269,7 +6948,14 @@ fn test_308_partial_payments_refunded_correctly() { recipients.push_back(recipient.clone()); let mut amounts = Vec::new(&env); amounts.push_back(500_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &2_000_u64, &default_options(&env)); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &2_000_u64, + &default_options(&env), + ); c.pay(&payer1, &id, &100_i128, &0_u64, &false, &false); c.pay(&payer2, &id, &150_i128, &0_u64, &false, &false); @@ -6301,7 +6987,14 @@ fn test_scheduled_release_blocked_before_timestamp() { recipients.push_back(recipient.clone()); let mut amounts = Vec::new(&env); amounts.push_back(100_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); // Before scheduled time — should panic @@ -6338,7 +7031,9 @@ fn test_circuit_breaker_allows_read_operations() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); @@ -6366,7 +7061,9 @@ fn test_add_fee_waiver() { let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); assert!(!c.has_fee_waiver(&creator)); @@ -6382,7 +7079,9 @@ fn test_remove_fee_waiver() { let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); c.add_fee_waiver(&admin, &creator); @@ -6402,7 +7101,9 @@ fn test_fee_waiver_exempts_from_fees() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &1_000_u32, &None, &0_u32, &0_u32, &0_u64, + ); c.add_fee_waiver(&admin, &creator); StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); @@ -6410,7 +7111,11 @@ fn test_fee_waiver_exempts_from_fees() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); - assert_eq!(tk.balance(&recipient), 100, "waived creator should not pay platform fee"); + assert_eq!( + tk.balance(&recipient), + 100, + "waived creator should not pay platform fee" + ); } #[test] @@ -6421,7 +7126,9 @@ fn test_fee_waiver_max_entries_enforced() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); for _ in 0..100 { @@ -6471,7 +7178,12 @@ fn test_simulate_release_multiple_recipients() { } let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), ); let result = c.simulate_release(&id); @@ -6496,7 +7208,12 @@ fn test_simulate_release_instruction_budget_calculation() { amounts.push_back(100_i128); let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), ); let result = c.simulate_release(&id); @@ -6524,7 +7241,10 @@ fn test_simulate_release_would_succeed_at_budget_limit() { let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); let result = c.simulate_release(&id); - assert!(result.would_succeed, "single recipient should fit in budget"); + assert!( + result.would_succeed, + "single recipient should fit in budget" + ); assert!(result.estimated_instructions < 100_000_000); // INSTRUCTION_BUDGET_LIMIT } @@ -6545,7 +7265,8 @@ fn test_get_confidential_payment_count() { assert_eq!(c.get_confidential_payment_count(&id), 0); c.pay_confidential( - &payer, &id, + &payer, + &id, &make_commitment(&env, 1), &make_range_proof(&env, 1), &make_encrypted_amount(&env, 1), @@ -6581,7 +7302,14 @@ fn test_multisig_release_requires_threshold() { opts.co_signers = co_signers; opts.required_signatures = 2; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); c.sign_release(&id, &signer1); // Only 1 of 2 — still pending @@ -6613,10 +7341,17 @@ fn test_multisig_release_panics_below_threshold() { opts.co_signers = co_signers; opts.required_signatures = 2; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); c.sign_release(&id, &signer1); // only 1 of 2 - c.release(&id); // should panic: not enough co-signer approvals + c.release(&id); // should panic: not enough co-signer approvals } // --------------------------------------------------------------------------- @@ -6643,7 +7378,14 @@ fn test_309_allowlist_restricts_payers() { let mut amounts = Vec::new(&env); amounts.push_back(100_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&allowed, &id, &100_i128, &0_u64, &false, &false); assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); } @@ -6659,7 +7401,10 @@ fn test_creator_stats_on_invoice_creation() { let _id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); let stats = c.get_creator_stats(&creator); - assert_eq!(stats.total_invoices, 1, "invoice creation should increment total_invoices"); + assert_eq!( + stats.total_invoices, 1, + "invoice creation should increment total_invoices" + ); assert_eq!(stats.total_raised, 0, "no payments yet"); } @@ -6685,7 +7430,14 @@ fn test_309_blocked_payer_rejected() { let mut amounts = Vec::new(&env); amounts.push_back(100_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&blocked, &id, &100_i128, &0_u64, &false, &false); // should panic } @@ -6705,7 +7457,10 @@ fn test_creator_stats_on_payment() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); let stats = c.get_creator_stats(&creator); - assert_eq!(stats.total_raised, 100, "total_raised should reflect payment amount"); + assert_eq!( + stats.total_raised, 100, + "total_raised should reflect payment amount" + ); } #[test] @@ -6723,7 +7478,10 @@ fn test_creator_stats_on_release() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); let stats = c.get_creator_stats(&creator); - assert_eq!(stats.total_released, 100, "total_released should equal released amount"); + assert_eq!( + stats.total_released, 100, + "total_released should equal released amount" + ); } #[test] @@ -6750,7 +7508,14 @@ fn test_multisig_release_succeeds_at_threshold() { opts.co_signers = co_signers; opts.required_signatures = 2; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); c.sign_release(&id, &signer1); c.sign_release(&id, &signer2); @@ -6787,7 +7552,14 @@ fn test_309_add_allowed_payer_initializes_list() { opts.co_signers = co_signers; opts.required_signatures = 2; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); // Both signers sign — release should succeed. @@ -6823,7 +7595,14 @@ fn test_multisig_non_signer_cannot_sign() { opts.co_signers = co_signers; opts.required_signatures = 1; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); c.sign_release(&id, &imposter); // not in co_signers — should panic } @@ -6844,7 +7623,10 @@ fn test_309_remove_allowed_payer_emits_event() { c.pay(&payer, &id, &250_i128, &0_u64, &false, &false); let stats = c.get_creator_stats(&creator); - assert_eq!(stats.total_released, 250, "total_released should equal released amount"); + assert_eq!( + stats.total_released, 250, + "total_released should equal released amount" + ); } #[test] @@ -6865,11 +7647,23 @@ fn test_creator_stats_unique_payers() { assert_eq!(c.get_confidential_payment_count(&id), 0); // Add first confidential payment - c.pay_confidential(&payer1, &id, &make_commitment(&env, 1), &make_range_proof(&env, 1), &make_encrypted_amount(&env, 1)); + c.pay_confidential( + &payer1, + &id, + &make_commitment(&env, 1), + &make_range_proof(&env, 1), + &make_encrypted_amount(&env, 1), + ); assert_eq!(c.get_confidential_payment_count(&id), 1); // Add second from different payer - c.pay_confidential(&payer2, &id, &make_commitment(&env, 2), &make_range_proof(&env, 2), &make_encrypted_amount(&env, 2)); + c.pay_confidential( + &payer2, + &id, + &make_commitment(&env, 2), + &make_range_proof(&env, 2), + &make_encrypted_amount(&env, 2), + ); assert_eq!(c.get_confidential_payment_count(&id), 2); } @@ -6887,12 +7681,28 @@ fn test_confidential_payment_overwrite() { let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); // Submit first payment from payer - c.pay_confidential(&payer, &id, &make_commitment(&env, 5), &make_range_proof(&env, 5), &make_encrypted_amount(&env, 5)); + c.pay_confidential( + &payer, + &id, + &make_commitment(&env, 5), + &make_range_proof(&env, 5), + &make_encrypted_amount(&env, 5), + ); assert_eq!(c.get_confidential_payment_count(&id), 1); // Same payer submits again (overwrites) - c.pay_confidential(&payer, &id, &make_commitment(&env, 10), &make_range_proof(&env, 10), &make_encrypted_amount(&env, 10)); - assert_eq!(c.get_confidential_payment_count(&id), 1, "same payer should overwrite, not increment"); + c.pay_confidential( + &payer, + &id, + &make_commitment(&env, 10), + &make_range_proof(&env, 10), + &make_encrypted_amount(&env, 10), + ); + assert_eq!( + c.get_confidential_payment_count(&id), + 1, + "same payer should overwrite, not increment" + ); } #[test] @@ -6912,7 +7722,13 @@ fn test_pay_confidential_rejects_zero_range_proof() { // Try to submit with all-zero proof (should fail) let commitment = make_commitment(&env, 5); let zero_proof = Bytes::from_array(&env, &[0u8; 32]); - c.pay_confidential(&payer, &id, &commitment, &zero_proof, &make_encrypted_amount(&env, 5)); + c.pay_confidential( + &payer, + &id, + &commitment, + &zero_proof, + &make_encrypted_amount(&env, 5), + ); } #[test] @@ -6931,13 +7747,15 @@ fn test_reveal_confidential_total_partial_funding() { let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); c.pay_confidential( - &payer1, &id, + &payer1, + &id, &make_commitment(&env, 3), &make_range_proof(&env, 3), &make_encrypted_amount(&env, 3), ); c.pay_confidential( - &payer2, &id, + &payer2, + &id, &make_commitment(&env, 5), &make_range_proof(&env, 5), &make_encrypted_amount(&env, 5), @@ -6973,7 +7791,14 @@ fn test_sign_release_imposter_rejected() { opts.co_signers = co_signers; opts.required_signatures = 1; - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.sign_release(&id, &imposter); // not in co_signers — should panic } @@ -7004,8 +7829,8 @@ fn test_dex_swap_credits_correct_amount() { let c = client(&env, &contract_id); let tk = token_client(&env, &token_id); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); + let _admin = Address::generate(&env); + let _treasury = Address::generate(&env); let creator = Address::generate(&env); let payer = Address::generate(&env); let recipient = Address::generate(&env); @@ -7016,7 +7841,13 @@ fn test_dex_swap_credits_correct_amount() { let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); // Submit confidential payment of 100 - c.pay_confidential(&payer, &id, &make_commitment(&env, 7), &make_range_proof(&env, 7), &make_encrypted_amount(&env, 7)); + c.pay_confidential( + &payer, + &id, + &make_commitment(&env, 7), + &make_range_proof(&env, 7), + &make_encrypted_amount(&env, 7), + ); // Mint funds to contract for payout StellarAssetClient::new(&env, &token_id).mint(&contract_id, &100); @@ -7028,7 +7859,11 @@ fn test_dex_swap_credits_correct_amount() { // Invoice should still be pending (not fully funded) assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); assert_eq!(c.get_invoice(&id).funded, 100); - assert_eq!(tk.balance(&recipient), 0, "should not distribute on partial reveal"); + assert_eq!( + tk.balance(&recipient), + 0, + "should not distribute on partial reveal" + ); } #[test] @@ -7059,7 +7894,9 @@ fn test_pay_confidential_blocked_by_circuit_breaker() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); @@ -7067,7 +7904,13 @@ fn test_pay_confidential_blocked_by_circuit_breaker() { let reason = String::from_str(&env, "emergency"); c.activate_circuit_breaker(&admin, &reason); - c.pay_confidential(&payer, &id, &make_commitment(&env, 5), &make_range_proof(&env, 5), &make_encrypted_amount(&env, 5)); + c.pay_confidential( + &payer, + &id, + &make_commitment(&env, 5), + &make_range_proof(&env, 5), + &make_encrypted_amount(&env, 5), + ); } #[test] @@ -7081,12 +7924,20 @@ fn test_reveal_confidential_blocked_by_circuit_breaker() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); - c.pay_confidential(&payer, &id, &make_commitment(&env, 7), &make_range_proof(&env, 7), &make_encrypted_amount(&env, 7)); + c.pay_confidential( + &payer, + &id, + &make_commitment(&env, 7), + &make_range_proof(&env, 7), + &make_encrypted_amount(&env, 7), + ); let reason = String::from_str(&env, "emergency"); c.activate_circuit_breaker(&admin, &reason); @@ -7116,7 +7967,12 @@ fn test_simulate_release_estimate_for_large_invoice() { } let id = c.create_invoice( - &creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env), + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), ); let result = c.simulate_release(&id); @@ -7134,7 +7990,9 @@ fn test_fee_waiver_persists_across_operations() { let treasury = Address::generate(&env); let creator = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); env.ledger().set_timestamp(1_000); c.add_fee_waiver(&admin, &creator); @@ -7156,7 +8014,9 @@ fn test_circuit_breaker_prevents_refund() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); StellarAssetClient::new(&env, &token_id).mint(&payer, &100); env.ledger().set_timestamp(1_000); @@ -7183,7 +8043,9 @@ fn test_dex_pay_with_alternate_token() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let alt_token_admin = Address::generate(&env); let alt_token_id = env @@ -7209,7 +8071,14 @@ fn test_dex_pay_with_alternate_token() { let mut amounts = Vec::new(&env); amounts.push_back(200_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.pay_with_token(&payer, &id, &alt_token_id, &200_i128, &0); @@ -7229,7 +8098,9 @@ fn test_dex_unregistered_token_rejected() { let payer = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let unknown_admin = Address::generate(&env); let unknown_token = env @@ -7257,7 +8128,9 @@ fn test_nft_gate_allows_holder_to_create_invoice() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let nft_id = env.register(MockNftGate, ()); let nft = MockNftGateClient::new(&env, &nft_id); @@ -7273,7 +8146,14 @@ fn test_nft_gate_allows_holder_to_create_invoice() { amounts.push_back(100_i128); // Creator holds NFT — should succeed. - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env)); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); } @@ -7288,7 +8168,9 @@ fn test_nft_gate_rejects_non_holder() { let creator = Address::generate(&env); let recipient = Address::generate(&env); - c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); let nft_id = env.register(MockNftGate, ()); c.set_nft_gate(&admin, &Some(nft_id)); @@ -7299,7 +8181,14 @@ fn test_nft_gate_rejects_non_holder() { let mut amounts = Vec::new(&env); amounts.push_back(100_i128); - c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &default_options(&env)); + c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &default_options(&env), + ); } #[test] @@ -7321,15 +8210,26 @@ fn test_remove_allowed_payer_emits_event() { recipients.push_back(recipient.clone()); let mut amounts = Vec::new(&env); amounts.push_back(300_i128); - let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &opts, + ); c.remove_allowed_payer(&creator, &id, &payer); let found = env.events().all().iter().any(|(_c, topics, _d)| { use soroban_sdk::TryIntoVal; topics.len() >= 2 - && topics.get(1) - .and_then(|v| { let r: Result = v.try_into_val(&env); r.ok() }) + && topics + .get(1) + .and_then(|v| { + let r: Result = v.try_into_val(&env); + r.ok() + }) .map(|s: Symbol| s == Symbol::new(&env, "al_upd")) .unwrap_or(false) }); @@ -7344,7 +8244,9 @@ fn init_contract(env: &Env, contract_id: &Address, token_id: &Address) { let c = SplitContractClient::new(env, contract_id); let admin = Address::generate(env); let treasury = Address::generate(env); - c.initialize(&admin, &0_i128, &treasury, token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.initialize( + &admin, &0_i128, &treasury, token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64, + ); } #[test] diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 367b54d..a291d24 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Bytes, BytesN, Env, Symbol, Vec, String}; +use soroban_sdk::{contracttype, Address, Bytes, BytesN, Env, String, Symbol, Vec}; #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -187,6 +187,16 @@ pub struct Tranche { pub basis_points: u32, } +/// On-chain reputation scoring metrics for an address (issue #349). +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct RepScore { + pub paid_on_time: u32, + pub late_pays: u32, + pub invoices_released: u32, + pub invoices_refunded: u32, +} + /// Optional parameters for `create_invoice`, grouped to keep the function /// within Soroban's 10-parameter limit. #[contracttype] @@ -261,6 +271,8 @@ pub struct InvoiceOptions { pub scheduled_release_at: Option, /// KYC verification requirement. pub require_kyc: bool, + /// Minimum required payer reputation score to pay this invoice (issue #349). + pub min_payer_rep: Option, } /// Overflow options for `create_invoice`, split off from [`InvoiceOptions`] to stay within @@ -407,6 +419,8 @@ pub struct InvoiceExt2 { pub target_usd_cents: Option, /// Issue #308: addresses that have already claimed a per-payer refund on this invoice. pub refunded_addresses: Vec
, + /// Issue #349: minimum required payer reputation score. + pub min_payer_rep: Option, } /// Issue #211: A single escalating penalty tier (seconds_after_deadline, bps). @@ -521,6 +535,8 @@ pub struct Invoice { pub target_usd_cents: Option, /// Issue #308: addresses that have already claimed a per-payer refund on this invoice. pub refunded_addresses: Vec
, + /// Issue #349: minimum required payer reputation score. + pub min_payer_rep: Option, } impl Invoice { @@ -608,6 +624,7 @@ impl Invoice { priorities: self.priorities, target_usd_cents: self.target_usd_cents, refunded_addresses: self.refunded_addresses, + min_payer_rep: self.min_payer_rep, }, ) } @@ -691,6 +708,7 @@ impl Invoice { priorities: ext2.priorities, target_usd_cents: ext2.target_usd_cents, refunded_addresses: ext2.refunded_addresses, + min_payer_rep: ext2.min_payer_rep, } } } @@ -763,7 +781,7 @@ impl Invoice { /// Convert Invoice to compact byte representation. pub fn to_compact(&self, env: &Env) -> CompactInvoice { let mut bytes = Bytes::new(env); - + // Pack status as 1 byte let status_byte: u8 = match self.status { InvoiceStatus::Pending => 0, @@ -772,26 +790,31 @@ impl Invoice { InvoiceStatus::Cancelled => 3, }; bytes.push_back(status_byte); - + // Pack funded as 16 bytes (i128) let funded_bytes = self.funded.to_be_bytes(); for byte in funded_bytes.iter() { bytes.push_back(*byte); } - + // Pack deadline as 8 bytes (u64) let deadline_bytes = self.deadline.to_be_bytes(); for byte in deadline_bytes.iter() { bytes.push_back(*byte); } - + CompactInvoice { data: bytes } } - + /// Restore Invoice from compact byte representation. - pub fn from_compact(compact: &CompactInvoice, core: InvoiceCore, ext: InvoiceExt, ext2: InvoiceExt2) -> Self { + pub fn from_compact( + compact: &CompactInvoice, + core: InvoiceCore, + ext: InvoiceExt, + ext2: InvoiceExt2, + ) -> Self { let bytes = &compact.data; - + // Unpack status (1 byte) let status_byte = bytes.get(0).unwrap(); let status = match status_byte { @@ -801,21 +824,21 @@ impl Invoice { 3 => InvoiceStatus::Cancelled, _ => InvoiceStatus::Pending, }; - + // Unpack funded (16 bytes) let mut funded_bytes = [0u8; 16]; - for i in 0..16 { - funded_bytes[i] = bytes.get((1 + i) as u32).unwrap(); + for (i, byte) in funded_bytes.iter_mut().enumerate() { + *byte = bytes.get((1 + i) as u32).unwrap(); } let funded = i128::from_be_bytes(funded_bytes); - + // Unpack deadline (8 bytes) let mut deadline_bytes = [0u8; 8]; - for i in 0..8 { - deadline_bytes[i] = bytes.get((17 + i) as u32).unwrap(); + for (i, byte) in deadline_bytes.iter_mut().enumerate() { + *byte = bytes.get((17 + i) as u32).unwrap(); } let deadline = u64::from_be_bytes(deadline_bytes); - + // Reconstruct full invoice with updated fields let mut invoice = Invoice::assemble(core, ext, ext2); invoice.status = status; @@ -905,11 +928,11 @@ impl Invoice { priorities: Vec::new(env), target_usd_cents: None, refunded_addresses: Vec::new(env), + min_payer_rep: None, } } } - /// Issue #327 / #329 / #330: Extended invoice fields for new features. /// Stored in separate persistent storage (key: inv_ex3 + invoice_id) so existing /// InvoiceCore / InvoiceExt / InvoiceExt2 XDR layouts are not disturbed. @@ -972,14 +995,13 @@ pub struct ProtocolFeeConfig { pub treasury: Address, } -/// Issue #316: Compute budget estimate for a contract function. +/// Issue #316 / #351: Compute budget estimate for a contract function. #[contracttype] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ComputeEstimate { - pub instructions: u64, + pub cpu_insns: u64, pub mem_bytes: u64, - pub read_entries: u32, - pub write_entries: u32, + pub fee_stroops: i128, } /// Issue #297: Circuit breaker status returned by get_circuit_breaker_status(). @@ -1047,9 +1069,9 @@ impl InvoiceStatus { /// Encode as a single byte — saves XDR overhead vs. the full enum variant. pub fn to_u8(&self) -> u8 { match self { - InvoiceStatus::Pending => 0, - InvoiceStatus::Released => 1, - InvoiceStatus::Refunded => 2, + InvoiceStatus::Pending => 0, + InvoiceStatus::Released => 1, + InvoiceStatus::Refunded => 2, InvoiceStatus::Cancelled => 3, } } diff --git a/docs/COMPUTE_BUDGETS.md b/docs/COMPUTE_BUDGETS.md index a0870b7..39a4ea4 100644 --- a/docs/COMPUTE_BUDGETS.md +++ b/docs/COMPUTE_BUDGETS.md @@ -1,31 +1,91 @@ -# Compute Budget Reference +# Compute Budget Estimation Reference (#351) -Measured instruction counts for typical inputs using `estimate_compute()`. -Soroban instruction limit: **100,000,000** per transaction. +This document describes the design, methodology, measurement process, and resource ranges for the read-only simulation-only compute budget estimation API (`estimate_compute`). -## Function Budget Table +--- -| Function | 1 Recipient | 5 Recipients | 20 Recipients | % of Limit (20) | -|---|---|---|---|---| -| `create_invoice` | 1,200,000 | 2,000,000 | 5,000,000 | 5.0% | -| `pay` | 1,800,000 | 1,800,000 | 1,800,000 | 1.8% | -| `pay_invoice_delegated` | 1,800,000 | 1,800,000 | 1,800,000 | 1.8% | -| `release` | 2,300,000 | 3,300,000 | 11,300,000 | 11.3% | -| `get_invoice` | 250,000 | 250,000 | 250,000 | 0.25% | -| `get_leaderboard` | 500,000 | 500,000 | 500,000 | 0.5% | -| `get_stats` | 500,000 | 500,000 | 500,000 | 0.5% | +## 1. Estimation API Overview -## Notes +The `estimate_compute` entry point allows off-chain callers and client SDKs to simulate and estimate required Soroban resource limits before submitting transactions to the Stellar network. -- Values produced by `estimate_compute(function_name, recipient_count)`. -- A **warning event** (`split/bdgt_w`) is emitted when a function exceeds 80,000,000 instructions (80% of limit). -- CI benchmark runs `estimate_compute` on each PR and posts a budget table as a comment (see `.github/workflows/compute-budget.yml`). +### Function Signature +```rust +pub fn estimate_compute( + env: Env, + operation: Symbol, + params: Map, +) -> Result +``` -## Formula +### Return Data Model (`ComputeEstimate`) +```rust +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComputeEstimate { + pub cpu_insns: u64, + pub mem_bytes: u64, + pub fee_stroops: i128, +} +``` -| Stage | Cost | -|---|---| -| Base overhead | 1,000,000 instructions | -| Per recipient (release) | +500,000 instructions | -| Per payment shard (8 shards) | +100,000 instructions each | -| Per recipient (create) | +200,000 instructions | +--- + +## 2. Supported Operations & Measurement Methodology + +`estimate_compute` supports estimation for 6 major contract operations: + +1. **`create_invoice`** (`"create_invoice"`, `"create"`) + - **Input Parameters**: `recipients` (`Vec
`) or `recipient_count` (`u32`/`u64`). + - **Methodology**: Scales linearly with the number of recipients and optional configuration flags. + +2. **`pay`** (`"pay"`) + - **Input Parameters**: `invoice_id` (`u64`) or `recipient_count` (`u32`/`u64`). + - **Methodology**: Accounts for base payment cost, shard storage updates, and auto-release payout overhead when an invoice is fully funded. + +3. **`release`** (`"release"`) + - **Input Parameters**: `invoice_id` (`u64`) or `recipient_count` (`u32`/`u64`). + - **Methodology**: Accounts for base release overhead, per-recipient token transfers, platform fee deduction, and payment shard aggregation. + +4. **`refund`** (`"refund"`) + - **Input Parameters**: `invoice_id` (`u64`) or `payer_count` (`u32`/`u64`). + - **Methodology**: Accounts for multi-shard payer iteration and proportional token refund transfers. + +5. **`open_dispute`** (`"open_dispute"`, `"raise_dispute"`, `"dispute"`) + - **Input Parameters**: `invoice_id` (`u64`). + - **Methodology**: Estimates dispute status updates, audit log entries, and persistent dispute records. + +6. **`approve_release`** (`"approve_release"`, `"approve_invoice"`, `"approve"`) + - **Input Parameters**: `invoice_id` (`u64`). + - **Methodology**: Estimates governance approval status update and audit log recording. + +--- + +## 3. Resource Ranges + +The table below lists measured typical resource consumption ranges across supported operations: + +| Operation | CPU Instructions Range | Memory Range (Bytes) | Typical Fee Range (Stroops) | +|---|---|---|---| +| `create_invoice` (1-20 recipients) | 1,200,000 – 5,000,000 | 196,608 – 1,433,600 | 120 – 500 | +| `pay` (1-20 recipients) | 1,800,000 – 6,800,000 | 262,144 – 896,000 | 180 – 680 | +| `release` (1-20 recipients) | 2,300,000 – 11,800,000 | 294,912 – 917,504 | 230 – 1,180 | +| `refund` (1-10 payers) | 2,150,000 – 5,300,000 | 294,912 – 589,824 | 215 – 530 | +| `open_dispute` | 1,200,000 | 131,072 | 120 | +| `approve_release` | 1,150,000 | 131,072 | 115 | + +--- + +## 4. Fee Formula & Warnings + +- **Fee Calculation**: + $$\text{fee\_stroops} = \left(\frac{\text{cpu\_insns}}{10,000}\right) \times \text{STROOPS\_PER\_10K\_INSTRUCTIONS}$$ +- **High-Resource Warning**: + When estimated CPU instructions exceed **80% of the Soroban transaction budget limit** (80,000,000 / 100,000,000 instructions), the contract publishes a structured warning event topic `("split", "bdgt_w", operation)`. + +--- + +## 5. Assumptions & Limitations + +1. **Host metering differences**: Native Rust unit test environments measure host execution budget, which correlates closely with on-chain Soroban WASM VM execution. +2. **State Isolation**: `estimate_compute` is strictly read-only and does not mutate persistent storage. +3. **Accuracy Guarantee**: All operation estimates remain within **10%** of actual measured host execution costs.