From e3cbaf743be191ec9421cd31049b9c78bfd3e53e Mon Sep 17 00:00:00 2001 From: Lawrence Ahaneku Date: Sat, 20 Jun 2026 15:06:27 +0000 Subject: [PATCH 1/2] feat(contracts): add creator-controlled invoice pause mechanism Implements pause_invoice, resume_invoice, and admin_force_resume entry points with lazy auto-resume in _pay. Also fixes compilation errors left by the merged rate-limiter PR (#168): missing fields, storage keys, broken From impls, and incorrect test call sites. Co-Authored-By: Claude Sonnet 4.6 --- contracts/split/src/events.rs | 30 +++++ contracts/split/src/lib.rs | 161 +++++++++++++++++++++++-- contracts/split/src/test.rs | 220 ++++++++++++++++++++++++++++++++-- contracts/split/src/types.rs | 55 +++++---- 4 files changed, 427 insertions(+), 39 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 7ef9594..3f60fd4 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -129,3 +129,33 @@ pub fn payment_matched(env: &Env, invoice_id: u64, memo: u64, payer: &Address) { (memo, payer.clone()), ); } + +/// Emitted when a creator pauses an invoice. +/// Topics: (split, paused, invoice_id) +/// Data: (creator, reason, auto_resume_at) +pub fn invoice_paused(env: &Env, invoice_id: u64, creator: &Address, reason: &String, auto_resume_at: &Option) { + env.events().publish( + (symbol_short!("split"), symbol_short!("paused"), invoice_id), + (creator.clone(), reason.clone(), auto_resume_at.clone()), + ); +} + +/// Emitted when a creator resumes a paused invoice. +/// Topics: (split, resumed, invoice_id) +/// Data: creator +pub fn invoice_resumed(env: &Env, invoice_id: u64, creator: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("resumed"), invoice_id), + creator.clone(), + ); +} + +/// Emitted when an admin force-resumes a paused invoice. +/// Topics: (split, frc_rsm, invoice_id) +/// Data: admin +pub fn invoice_force_resumed(env: &Env, invoice_id: u64, admin: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("frc_rsm"), invoice_id), + admin.clone(), + ); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 768ed87..2cdcf5f 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 // --------------------------------------------------------------------------- @@ -291,6 +303,11 @@ fn load_invoice(env: &Env, id: u64) -> 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, }); let ext2: InvoiceExt2 = env.storage().persistent() .get(&invoice_ext2_key(id)) @@ -711,6 +728,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, ) } @@ -755,6 +775,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(), @@ -972,6 +995,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, @@ -1104,6 +1132,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ); ids.push_back(id); } @@ -1172,6 +1203,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ); if months > 1 { @@ -1343,6 +1377,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"); @@ -1397,6 +1444,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() @@ -1521,6 +1572,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() @@ -1947,6 +2001,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) { @@ -2757,6 +2894,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ); env.storage() .persistent() @@ -3286,6 +3426,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. @@ -3499,6 +3642,9 @@ impl SplitContract { None, None, 0, + None, + None, + None, ) } @@ -3920,7 +4066,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)) @@ -4059,10 +4206,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() @@ -4077,7 +4224,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!( @@ -4091,17 +4238,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 daa4961..f27e44c 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -70,9 +70,73 @@ 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( + cooldown_secs: Option, + max_payments: Option, + window_secs: Option, +) -> InvoiceOptions { + InvoiceOptions { + co_creators: Vec::new(&Env::default()), + 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::default()), + co_signers: Vec::new(&Env::default()), + required_signatures: 0, + penalty_bps: None, + penalty_deadline: None, + min_funding_bps: None, + release_stages: Vec::new(&Env::default()), + price_oracle: None, + swap_tokens: Vec::new(&Env::default()), + 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::default()), + forward_to: None, + forward_invoice_id: None, + split_rules: Vec::new(&Env::default()), + auto_resolve_rules: Vec::new(&Env::default()), + 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, @@ -4199,9 +4263,9 @@ fn test_cooldown_blocks_same_payer_within_window() { invoice_options(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] @@ -4223,7 +4287,7 @@ fn test_rate_limit_blocks_after_n_payments() { 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); } } @@ -4245,13 +4309,13 @@ fn test_rate_limit_window_resets() { 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] @@ -4280,6 +4344,146 @@ 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 7542623..515879e 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -205,6 +205,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` @@ -301,6 +307,11 @@ pub struct InvoiceExt { 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, } #[contracttype] @@ -372,6 +383,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, @@ -440,6 +456,11 @@ impl Invoice { creator_cosigner: self.creator_cosigner, velocity_limit: self.velocity_limit, velocity_window: self.velocity_window, + pause_reason: self.pause_reason, + auto_resume_at: self.auto_resume_at, + payment_cooldown_secs: self.payment_cooldown_secs, + max_payments_per_window: self.max_payments_per_window, + payment_window_secs: self.payment_window_secs, }, InvoiceExt2 { notification_contract: self.notification_contract, @@ -508,6 +529,11 @@ impl Invoice { creator_cosigner: ext.creator_cosigner, velocity_limit: ext.velocity_limit, velocity_window: ext.velocity_window, + pause_reason: ext.pause_reason, + auto_resume_at: ext.auto_resume_at, + payment_cooldown_secs: ext.payment_cooldown_secs, + max_payments_per_window: ext.max_payments_per_window, + payment_window_secs: ext.payment_window_secs, notification_contract: ext2.notification_contract, overflow_behavior: ext2.overflow_behavior, cross_chain_ref: ext2.cross_chain_ref, @@ -598,6 +624,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, @@ -608,27 +639,3 @@ impl Invoice { } } -impl From for InvoiceExt { - fn from(options: InvoiceOptions) -> Self { - Self { - payment_cooldown_secs: options.payment_cooldown_secs, - max_payments_per_window: options.max_payments_per_window, - payment_window_secs: options.payment_window_secs, - } - } -} - -impl From for Invoice { - fn from(core: InvoiceCore) -> Self { - Self { - creator: core.creator, - recipients: core.recipients, - amounts: core.amounts, - token: core.token, - deadline: core.deadline, - funded: core.funded, - status: core.status, - payments: core.payments, - } - } -} From 8d1573508b50e671ec919e58ec4c98101de02fac Mon Sep 17 00:00:00 2001 From: Lawrence Ahaneku Date: Sat, 20 Jun 2026 15:13:57 +0000 Subject: [PATCH 2/2] fix(tests): pass test env to invoice_options to avoid mis-tagged object reference Vec::new(&Env::default()) creates objects bound to a throwaway host; passing them to a contract call on a different env caused the Soroban "mis-tagged object reference" panic in all four rate-limiter tests. Co-Authored-By: Claude Sonnet 4.6 --- contracts/split/src/test.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index f27e44c..44300b7 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -77,12 +77,13 @@ fn default_options(env: &Env) -> InvoiceOptions { } fn invoice_options( + env: &Env, cooldown_secs: Option, max_payments: Option, window_secs: Option, ) -> InvoiceOptions { InvoiceOptions { - co_creators: Vec::new(&Env::default()), + co_creators: Vec::new(env), allow_early_withdrawal: false, bonus_pool: 0, bonus_max_payers: 0, @@ -90,15 +91,15 @@ fn invoice_options( velocity_limit: 0, velocity_window: 0, prerequisite_id: None, - tranches: Vec::new(&Env::default()), - co_signers: Vec::new(&Env::default()), + 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::default()), + release_stages: Vec::new(env), price_oracle: None, - swap_tokens: Vec::new(&Env::default()), + swap_tokens: Vec::new(env), tax_bps: None, tax_authority: None, insurance_premium_bps: None, @@ -106,11 +107,11 @@ fn invoice_options( notification_contract: None, overflow_behavior: types::OverflowBehavior::Reject, convert_to_stream: false, - accepted_tokens: Vec::new(&Env::default()), + accepted_tokens: Vec::new(env), forward_to: None, forward_invoice_id: None, - split_rules: Vec::new(&Env::default()), - auto_resolve_rules: Vec::new(&Env::default()), + split_rules: Vec::new(env), + auto_resolve_rules: Vec::new(env), oracle_address: None, cross_chain_ref: None, allowed_payers: None, @@ -4260,7 +4261,7 @@ 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, &0_u64, &false); @@ -4281,7 +4282,7 @@ 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 { @@ -4303,7 +4304,7 @@ 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 { @@ -4336,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);