diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index bf14243..39ec7af 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -243,6 +243,18 @@ fn creator_stats_refunded_key(creator: &Address) -> (Symbol, Address) { (symbol_short!("cr_ref"), creator.clone()) } +/// Per-payer last-payment timestamp key for cooldown enforcement (issue #168). +fn payer_cooldown_key(invoice_id: u64, payer: Address) -> (Symbol, u64, Address) { + (symbol_short!("pyr_cd"), invoice_id, payer) +} + +/// Sliding-window payment timestamp list key for rate limiting (issue #168). +fn payment_window_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("pay_win"), invoice_id) +} + +const PAYMENT_WINDOW_CAP: u32 = 100; + // --------------------------------------------------------------------------- // Invoice storage helpers // --------------------------------------------------------------------------- @@ -712,6 +724,9 @@ impl SplitContract { options.cross_chain_ref, options.allowed_payers, options.min_funding_amount.unwrap_or(0), + options.payment_cooldown_secs, + options.max_payments_per_window, + options.payment_window_secs, ) } @@ -756,6 +771,9 @@ impl SplitContract { cross_chain_ref: Option, allowed_payers: Option>, min_funding_amount: i128, + payment_cooldown_secs: Option, + max_payments_per_window: Option, + payment_window_secs: Option, ) -> u64 { assert!( recipients.len() == amounts.len(), @@ -973,6 +991,11 @@ impl SplitContract { creator_cosigner, velocity_limit, velocity_window, + pause_reason: None, + auto_resume_at: None, + payment_cooldown_secs, + max_payments_per_window, + payment_window_secs, cross_chain_ref, require_kyc: false, auction_on_expiry: false, @@ -1107,6 +1130,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ); ids.push_back(id); } @@ -1175,6 +1201,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ); if months > 1 { @@ -1490,6 +1519,19 @@ impl SplitContract { ); assert!(amount > 0, "payment amount must be positive"); + // Lazy auto-resume: clear frozen if the auto-resume timestamp has passed. + if invoice.frozen { + if let Some(auto_at) = invoice.auto_resume_at { + if env.ledger().timestamp() >= auto_at { + invoice.frozen = false; + invoice.pause_reason = None; + invoice.auto_resume_at = None; + save_invoice(env, invoice_id, &invoice); + } + } + } + assert!(!invoice.frozen, "invoice is frozen"); + // Check allowed_payers allowlist. if let Some(ref whitelist) = invoice.allowed_payers { assert!(whitelist.contains(payer), "payer not allowed"); @@ -1544,6 +1586,10 @@ impl SplitContract { amount }; + // Payment rate limiting: cooldown and per-window cap (issue #168). + let now_ts = env.ledger().timestamp(); + Self::enforce_payment_limits(env, invoice_id, payer, &invoice, now_ts); + // Validate and increment per-payer per-invoice nonce (issue #21). let stored_nonce: u64 = env .storage() @@ -1668,6 +1714,9 @@ impl SplitContract { events::payment_received(env, invoice_id, payer, credited_amount); 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); + // Issue: mint a receipt token to the payer via the receipt factory if configured. if let Some(factory) = env .storage() @@ -2094,6 +2143,89 @@ impl SplitContract { append_audit_entry(&env, invoice_id, symbol_short!("aprv"), approver); } + // ----------------------------------------------------------------------- + // Invoice pause / resume (creator-controlled) + // ----------------------------------------------------------------------- + + /// Freeze an invoice with an on-chain reason string and an optional auto-resume timestamp. + /// + /// Only the creator (or a co-creator) may call this. Sets `frozen = true`, + /// stores `pause_reason` and `auto_resume_at`, and emits a paused event. + pub fn pause_invoice( + env: Env, + creator: Address, + invoice_id: u64, + reason: String, + auto_resume_at: Option, + ) { + require_not_paused(&env); + creator.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + assert!( + invoice.creator == creator + || invoice.co_creators.iter().any(|c| c == creator), + "only creator can pause invoice" + ); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + assert!(!invoice.frozen, "invoice is already frozen"); + + invoice.frozen = true; + invoice.pause_reason = Some(reason.clone()); + invoice.auto_resume_at = auto_resume_at; + save_invoice(&env, invoice_id, &invoice); + + append_audit_entry(&env, invoice_id, symbol_short!("paused"), &creator); + events::invoice_paused(&env, invoice_id, &creator, &reason, &auto_resume_at); + } + + /// Unfreeze a paused invoice. Clears the stored reason and auto-resume time. + /// + /// Only the creator (or a co-creator) may call this. + pub fn resume_invoice(env: Env, creator: Address, invoice_id: u64) { + require_not_paused(&env); + creator.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + assert!( + invoice.creator == creator + || invoice.co_creators.iter().any(|c| c == creator), + "only creator can resume invoice" + ); + assert!(invoice.frozen, "invoice is not frozen"); + + invoice.frozen = false; + invoice.pause_reason = None; + invoice.auto_resume_at = None; + save_invoice(&env, invoice_id, &invoice); + + append_audit_entry(&env, invoice_id, symbol_short!("resumed"), &creator); + events::invoice_resumed(&env, invoice_id, &creator); + } + + /// Admin override: force-resume any paused invoice regardless of who paused it. + /// + /// Requires admin auth. Clears the frozen flag, reason, and auto-resume time, + /// and emits a force_resumed event with the admin address. + pub fn admin_force_resume(env: Env, admin: Address, invoice_id: u64) { + let admin_addr = require_admin(&env); + let _ = admin; + + let mut invoice = load_invoice(&env, invoice_id); + assert!(invoice.frozen, "invoice is not frozen"); + + invoice.frozen = false; + invoice.pause_reason = None; + invoice.auto_resume_at = None; + save_invoice(&env, invoice_id, &invoice); + + append_audit_entry(&env, invoice_id, symbol_short!("frc_rsm"), &admin_addr); + events::invoice_force_resumed(&env, invoice_id, &admin_addr); + } + /// Oracle confirms a condition for a gated invoice. /// Requires the configured oracle address to authenticate. pub fn confirm_condition(env: Env, invoice_id: u64) { @@ -2904,6 +3036,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ); env.storage() .persistent() @@ -3433,6 +3568,9 @@ impl SplitContract { old_invoice.cross_chain_ref.clone(), None, old_invoice.min_funding_amount, + old_invoice.payment_cooldown_secs, + old_invoice.max_payments_per_window, + old_invoice.payment_window_secs, ); // Load the newly created invoice and copy over the payments. @@ -3646,6 +3784,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ) } @@ -4067,7 +4208,8 @@ impl SplitContract { 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, + velocity_window: 0, pause_reason: None, auto_resume_at: None, + payment_cooldown_secs: None, max_payments_per_window: None, payment_window_secs: None, }); let ext2: InvoiceExt2 = env.storage().persistent() .get(&invoice_ext2_key(invoice_id)) @@ -4206,10 +4348,10 @@ impl SplitContract { env: &Env, invoice_id: u64, payer: &Address, - ext: &InvoiceExt, + invoice: &Invoice, now: u64, ) { - if let Some(cooldown_secs) = ext.payment_cooldown_secs { + if let Some(cooldown_secs) = invoice.payment_cooldown_secs { let last_payment: Option = env .storage() .persistent() @@ -4224,7 +4366,7 @@ impl SplitContract { } if let (Some(max_payments), Some(window_secs)) = - (ext.max_payments_per_window, ext.payment_window_secs) + (invoice.max_payments_per_window, invoice.payment_window_secs) { let recent = Self::active_payment_window(env, invoice_id, now, window_secs); assert!( @@ -4238,17 +4380,17 @@ impl SplitContract { env: &Env, invoice_id: u64, payer: &Address, - ext: &InvoiceExt, + invoice: &Invoice, now: u64, ) { - if ext.payment_cooldown_secs.is_some() { + if invoice.payment_cooldown_secs.is_some() { env.storage() .persistent() .set(&payer_cooldown_key(invoice_id, payer.clone()), &now); } if let (Some(_), Some(window_secs)) = - (ext.max_payments_per_window, ext.payment_window_secs) + (invoice.max_payments_per_window, invoice.payment_window_secs) { let mut recent = Self::active_payment_window(env, invoice_id, now, window_secs); while recent.len() >= PAYMENT_WINDOW_CAP { diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 02b5313..6447ce5 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -70,9 +70,74 @@ fn default_options(env: &Env) -> InvoiceOptions { cross_chain_ref: None, allowed_payers: None, min_funding_amount: None, + payment_cooldown_secs: None, + max_payments_per_window: None, + payment_window_secs: None, } } +fn invoice_options( + env: &Env, + cooldown_secs: Option, + max_payments: Option, + window_secs: Option, +) -> InvoiceOptions { + InvoiceOptions { + co_creators: Vec::new(env), + allow_early_withdrawal: false, + bonus_pool: 0, + bonus_max_payers: 0, + creator_cosigner: None, + velocity_limit: 0, + velocity_window: 0, + prerequisite_id: None, + tranches: Vec::new(env), + co_signers: Vec::new(env), + required_signatures: 0, + penalty_bps: None, + penalty_deadline: None, + min_funding_bps: None, + release_stages: Vec::new(env), + price_oracle: None, + swap_tokens: Vec::new(env), + tax_bps: None, + tax_authority: None, + insurance_premium_bps: None, + smart_route: None, + notification_contract: None, + overflow_behavior: types::OverflowBehavior::Reject, + 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), + oracle_address: None, + cross_chain_ref: None, + allowed_payers: None, + min_funding_amount: None, + payment_cooldown_secs: cooldown_secs, + max_payments_per_window: max_payments, + payment_window_secs: window_secs, + } +} + +fn single_recipient_invoice( + env: &Env, + c: &SplitContractClient, + token_id: &Address, + amount: i128, + options: InvoiceOptions, +) -> u64 { + let creator = Address::generate(env); + let recipient = Address::generate(env); + let mut recipients = Vec::new(env); + 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) +} + /// Create a basic single-recipient invoice with default optional params. fn make_invoice( env: &Env, @@ -4196,12 +4261,12 @@ fn test_cooldown_blocks_same_payer_within_window() { &c, &token_id, 500, - invoice_options(Some(60), None, None), + invoice_options(&env, Some(60), None, None), ); - c.pay(&payer, &id, &100_i128); - c.pay(&other_payer, &id, &100_i128); - c.pay(&payer, &id, &100_i128); + c.pay(&payer, &id, &100_i128, &0_u64, &false); + c.pay(&other_payer, &id, &100_i128, &0_u64, &false); + c.pay(&payer, &id, &100_i128, &1_u64, &false); } #[test] @@ -4217,13 +4282,13 @@ fn test_rate_limit_blocks_after_n_payments() { &c, &token_id, 500, - invoice_options(None, Some(2), Some(60)), + invoice_options(&env, None, Some(2), Some(60)), ); for _ in 0..3 { let payer = Address::generate(&env); stellar_asset.mint(&payer, &100); - c.pay(&payer, &id, &100_i128); + c.pay(&payer, &id, &100_i128, &0_u64, &false); } } @@ -4239,19 +4304,19 @@ fn test_rate_limit_window_resets() { &c, &token_id, 500, - invoice_options(None, Some(2), Some(60)), + invoice_options(&env, None, Some(2), Some(60)), ); for _ in 0..2 { let payer = Address::generate(&env); stellar_asset.mint(&payer, &100); - c.pay(&payer, &id, &100_i128); + c.pay(&payer, &id, &100_i128, &0_u64, &false); } env.ledger().set_timestamp(1_061); let payer = Address::generate(&env); stellar_asset.mint(&payer, &100); - c.pay(&payer, &id, &100_i128); + c.pay(&payer, &id, &100_i128, &0_u64, &false); } #[test] @@ -4272,7 +4337,7 @@ fn test_cooldown_and_rate_limit_independent() { &c, &token_id, 500, - invoice_options(Some(120), Some(1), Some(60)), + invoice_options(&env, Some(120), Some(1), Some(60)), ); let ext = c.get_invoice_ext(&id); @@ -4280,8 +4345,148 @@ fn test_cooldown_and_rate_limit_independent() { assert_eq!(ext.max_payments_per_window, Some(1)); assert_eq!(ext.payment_window_secs, Some(60)); - c.pay(&payer, &id, &100_i128); - c.pay(&other_payer, &id, &100_i128); + c.pay(&payer, &id, &100_i128, &0_u64, &false); + c.pay(&other_payer, &id, &100_i128, &0_u64, &false); +} + +// --------------------------------------------------------------------------- +// Pause mechanism tests +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "invoice is frozen")] +fn test_pause_blocks_payment_with_reason() { + 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, &500); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + + let reason = soroban_sdk::String::from_str(&env, "legal review pending"); + c.pause_invoice(&creator, &id, &reason, &None); + + let ext = c.get_invoice_ext(&id); + assert_eq!(ext.pause_reason, Some(reason)); + assert_eq!(ext.auto_resume_at, None); + assert!(c.get_invoice(&id).frozen); + + // This should panic with "invoice is frozen" + c.pay(&payer, &id, &100_i128, &0_u64, &false); +} + +#[test] +fn test_auto_resume_allows_payment_after_timestamp() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &500); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + + let reason = soroban_sdk::String::from_str(&env, "scheduled maintenance"); + c.pause_invoice(&creator, &id, &reason, &Some(2_000_u64)); + + assert!(c.get_invoice(&id).frozen); + + // Advance ledger past auto-resume timestamp. + env.ledger().set_timestamp(2_000); + + // Payment should succeed because lazy auto-resume fires. + c.pay(&payer, &id, &200_i128, &0_u64, &false); + + let invoice = c.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Released); + assert_eq!(tk.balance(&recipient), 200); +} + +#[test] +fn test_admin_force_resume_overrides_creator_pause() { + 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); + let admin = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &500); + env.ledger().set_timestamp(1_000); + + // Initialize with a custom admin so admin_force_resume can authenticate. + c.initialize( + &admin, + &0_i128, + &Address::generate(&env), + &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 = soroban_sdk::String::from_str(&env, "compliance hold"); + c.pause_invoice(&creator, &id, &reason, &None); + + assert!(c.get_invoice(&id).frozen); + + // Admin force-resumes. + c.admin_force_resume(&admin, &id); + + let invoice = c.get_invoice(&id); + assert!(!invoice.frozen); + + let ext = c.get_invoice_ext(&id); + assert_eq!(ext.pause_reason, None); + assert_eq!(ext.auto_resume_at, None); + + // Payment now succeeds. + c.pay(&payer, &id, &200_i128, &0_u64, &false); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); +} + +#[test] +fn test_resume_clears_stored_reason() { + 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, 200, &token_id, 9_999); + + let reason = soroban_sdk::String::from_str(&env, "temporary hold"); + c.pause_invoice(&creator, &id, &reason, &Some(5_000_u64)); + + // Verify stored on chain. + let ext = c.get_invoice_ext(&id); + assert_eq!(ext.pause_reason, Some(reason)); + assert_eq!(ext.auto_resume_at, Some(5_000_u64)); + + // Creator manually resumes. + c.resume_invoice(&creator, &id); + + // Reason and auto_resume_at must be cleared. + let ext = c.get_invoice_ext(&id); + assert_eq!(ext.pause_reason, None); + assert_eq!(ext.auto_resume_at, None); + assert!(!c.get_invoice(&id).frozen); } // --------------------------------------------------------------------------- diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 374e87f..f92428b 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -214,6 +214,12 @@ pub struct InvoiceOptions { pub allowed_payers: Option>, /// Absolute minimum funded amount required before auto-release triggers. pub min_funding_amount: Option, + /// Per-payer cooldown window in seconds (issue #168). + pub payment_cooldown_secs: Option, + /// Maximum payments allowed per window (issue #168). + pub max_payments_per_window: Option, + /// Window duration in seconds for payment rate limiting (issue #168). + pub payment_window_secs: Option, } /// Legacy invoice layout used by stored invoices created before the `version` @@ -383,6 +389,11 @@ pub struct Invoice { pub creator_cosigner: Option
, pub velocity_limit: i128, pub velocity_window: u64, + pub pause_reason: Option, + pub auto_resume_at: Option, + pub payment_cooldown_secs: Option, + pub max_payments_per_window: Option, + pub payment_window_secs: Option, pub notification_contract: Option
, pub overflow_behavior: OverflowBehavior, pub cross_chain_ref: Option, @@ -615,6 +626,11 @@ impl Invoice { creator_cosigner: None, velocity_limit: 0, velocity_window: 0, + pause_reason: None, + auto_resume_at: None, + payment_cooldown_secs: None, + max_payments_per_window: None, + payment_window_secs: None, forward_to: None, forward_invoice_id: None, notification_contract: None,