From 1f68e67c7204ebec16a4287ef6eec58a7012d613 Mon Sep 17 00:00:00 2001 From: obedebuka41-dotcom Date: Thu, 18 Jun 2026 14:31:18 +0100 Subject: [PATCH] Implement invoice payment rate limits --- .gitignore | 25 ++++++ contracts/split/src/lib.rs | 161 +++++++++++++++++++++++++++++++++-- contracts/split/src/test.rs | 143 +++++++++++++++++++++++++++++++ contracts/split/src/types.rs | 73 +++++++++++++++- 4 files changed, 395 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index c56dddb..735a749 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,32 @@ target/ Cargo.lock *.wasm + +# Local environment .env .env.local +.env.* +!.env.example + +# OS and editor files .DS_Store Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo + +# Test and coverage artifacts +coverage/ +lcov.info +*.profraw +*.profdata +*.snap +snapshots/ +test-snapshots/ +test_snapshots/ +testsnapshot/ +testsnapshots/ + +# Soroban/Stellar local artifacts +.stellar/ diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 9bf6632..dbffa5a 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -13,7 +13,9 @@ mod types; mod test; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; -use types::{Invoice, InvoiceStatus, Payment}; +use types::{Invoice, InvoiceCore, InvoiceExt, InvoiceOptions, InvoiceStatus, Payment}; + +const PAYMENT_WINDOW_CAP: u32 = 50; // --------------------------------------------------------------------------- // Storage helpers @@ -29,19 +31,44 @@ fn invoice_key(id: u64) -> (Symbol, u64) { (symbol_short!("inv"), id) } -fn load_invoice(env: &Env, id: u64) -> Invoice { +fn invoice_ext_key(id: u64) -> (Symbol, u64) { + (symbol_short!("invext"), id) +} + +fn payer_cooldown_key(id: u64, payer: Address) -> (Symbol, u64, Address) { + (symbol_short!("pcd"), id, payer) +} + +fn payment_window_key(id: u64) -> (Symbol, u64) { + (symbol_short!("pwin"), id) +} + +fn load_invoice(env: &Env, id: u64) -> InvoiceCore { env.storage() .persistent() .get(&invoice_key(id)) .expect("invoice not found") } -fn save_invoice(env: &Env, id: u64, invoice: &Invoice) { +fn save_invoice(env: &Env, id: u64, invoice: &InvoiceCore) { env.storage() .persistent() .set(&invoice_key(id), invoice); } +fn save_invoice_ext(env: &Env, id: u64, ext: &InvoiceExt) { + env.storage() + .persistent() + .set(&invoice_ext_key(id), ext); +} + +fn load_invoice_ext(env: &Env, id: u64) -> InvoiceExt { + env.storage() + .persistent() + .get(&invoice_ext_key(id)) + .expect("invoice extension not found") +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -69,6 +96,31 @@ impl SplitContract { amounts: Vec, token: Address, deadline: u64, + ) -> u64 { + Self::create_invoice_with_options( + env, + creator, + recipients, + amounts, + token, + deadline, + InvoiceOptions { + payment_cooldown_secs: None, + max_payments_per_window: None, + payment_window_secs: None, + }, + ) + } + + /// Create a new invoice with optional payment rate limits. + pub fn create_invoice_with_options( + env: Env, + creator: Address, + recipients: Vec
, + amounts: Vec, + token: Address, + deadline: u64, + options: InvoiceOptions, ) -> u64 { creator.require_auth(); @@ -97,7 +149,7 @@ impl SplitContract { let total: i128 = amounts.iter().sum(); - let invoice = Invoice { + let invoice = InvoiceCore { creator: creator.clone(), recipients: recipients.clone(), amounts, @@ -109,6 +161,7 @@ impl SplitContract { }; save_invoice(&env, id, &invoice); + save_invoice_ext(&env, id, &InvoiceExt::from(options)); events::invoice_created(&env, id, &creator, total); id @@ -142,10 +195,16 @@ impl SplitContract { let remaining = total - invoice.funded; assert!(amount <= remaining, "payment exceeds remaining balance"); + let now = env.ledger().timestamp(); + let ext = load_invoice_ext(&env, invoice_id); + Self::enforce_payment_limits(&env, invoice_id, &payer, &ext, now); + // Transfer tokens from payer to this contract. let token_client = token::Client::new(&env, &invoice.token); token_client.transfer(&payer, &env.current_contract_address(), &amount); + Self::record_payment_limits(&env, invoice_id, &payer, &ext, now); + invoice.payments.push_back(Payment { payer: payer.clone(), amount, @@ -211,7 +270,12 @@ impl SplitContract { /// Retrieve an invoice by ID. pub fn get_invoice(env: Env, invoice_id: u64) -> Invoice { - load_invoice(&env, invoice_id) + Invoice::from(load_invoice(&env, invoice_id)) + } + + /// Retrieve extension settings for an invoice by ID. + pub fn get_invoice_ext(env: Env, invoice_id: u64) -> InvoiceExt { + load_invoice_ext(&env, invoice_id) } // ----------------------------------------------------------------------- @@ -219,7 +283,7 @@ impl SplitContract { // ----------------------------------------------------------------------- /// Route funds to all recipients and mark the invoice as released. - fn _release(env: &Env, invoice_id: u64, invoice: &mut Invoice) { + fn _release(env: &Env, invoice_id: u64, invoice: &mut InvoiceCore) { let token_client = token::Client::new(env, &invoice.token); for (recipient, amount) in invoice.recipients.iter().zip(invoice.amounts.iter()) { @@ -230,4 +294,89 @@ impl SplitContract { save_invoice(env, invoice_id, invoice); events::invoice_released(env, invoice_id, &invoice.recipients); } + + fn enforce_payment_limits( + env: &Env, + invoice_id: u64, + payer: &Address, + ext: &InvoiceExt, + now: u64, + ) { + if let Some(cooldown_secs) = ext.payment_cooldown_secs { + let last_payment: Option = env + .storage() + .persistent() + .get(&payer_cooldown_key(invoice_id, payer.clone())); + + if let Some(last_payment_at) = last_payment { + assert!( + last_payment_at.saturating_add(cooldown_secs) <= now, + "payment cooldown active" + ); + } + } + + if let (Some(max_payments), Some(window_secs)) = + (ext.max_payments_per_window, ext.payment_window_secs) + { + let recent = Self::active_payment_window(env, invoice_id, now, window_secs); + assert!( + recent.len() < max_payments, + "payment rate limit exceeded" + ); + } + } + + fn record_payment_limits( + env: &Env, + invoice_id: u64, + payer: &Address, + ext: &InvoiceExt, + now: u64, + ) { + if ext.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) + { + let mut recent = Self::active_payment_window(env, invoice_id, now, window_secs); + while recent.len() >= PAYMENT_WINDOW_CAP { + recent.pop_front(); + } + recent.push_back(now); + env.storage() + .persistent() + .set(&payment_window_key(invoice_id), &recent); + } + } + + fn active_payment_window( + env: &Env, + invoice_id: u64, + now: u64, + window_secs: u64, + ) -> Vec { + let stored: Vec = env + .storage() + .persistent() + .get(&payment_window_key(invoice_id)) + .unwrap_or(Vec::new(env)); + let mut active = Vec::new(env); + + for paid_at in stored.iter() { + if paid_at.saturating_add(window_secs) > now { + active.push_back(paid_at); + } + } + + while active.len() > PAYMENT_WINDOW_CAP { + active.pop_front(); + } + + active + } } diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d326a2..7427a1e 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -35,6 +35,43 @@ fn token_client<'a>(env: &'a Env, token_id: &Address) -> TokenClient<'a> { TokenClient::new(env, token_id) } +fn invoice_options( + payment_cooldown_secs: Option, + max_payments_per_window: Option, + payment_window_secs: Option, +) -> InvoiceOptions { + InvoiceOptions { + payment_cooldown_secs, + max_payments_per_window, + payment_window_secs, + } +} + +fn single_recipient_invoice( + env: &Env, + c: &SplitContractClient, + token_id: &Address, + total: i128, + options: InvoiceOptions, +) -> u64 { + let creator = Address::generate(env); + let recipient = Address::generate(env); + + let mut recipients = Vec::new(env); + recipients.push_back(recipient); + let mut amounts = Vec::new(env); + amounts.push_back(total); + + c.create_invoice_with_options( + &creator, + &recipients, + &amounts, + token_id, + &9_999_u64, + &options, + ) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -250,3 +287,109 @@ fn test_multi_recipient_release() { assert_eq!(tk.balance(&r2), 200); assert_eq!(tk.balance(&r3), 300); } + +#[test] +#[should_panic(expected = "payment cooldown active")] +fn test_cooldown_blocks_same_payer_within_window() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let stellar_asset = StellarAssetClient::new(&env, &token_id); + + let payer = Address::generate(&env); + let other_payer = Address::generate(&env); + stellar_asset.mint(&payer, &500); + stellar_asset.mint(&other_payer, &500); + + env.ledger().set_timestamp(1_000); + let id = single_recipient_invoice( + &env, + &c, + &token_id, + 500, + 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); +} + +#[test] +#[should_panic(expected = "payment rate limit exceeded")] +fn test_rate_limit_blocks_after_n_payments() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let stellar_asset = StellarAssetClient::new(&env, &token_id); + + env.ledger().set_timestamp(1_000); + let id = single_recipient_invoice( + &env, + &c, + &token_id, + 500, + invoice_options(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); + } +} + +#[test] +fn test_rate_limit_window_resets() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let stellar_asset = StellarAssetClient::new(&env, &token_id); + + env.ledger().set_timestamp(1_000); + let id = single_recipient_invoice( + &env, + &c, + &token_id, + 500, + invoice_options(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); + } + + env.ledger().set_timestamp(1_061); + let payer = Address::generate(&env); + stellar_asset.mint(&payer, &100); + c.pay(&payer, &id, &100_i128); +} + +#[test] +#[should_panic(expected = "payment rate limit exceeded")] +fn test_cooldown_and_rate_limit_independent() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let stellar_asset = StellarAssetClient::new(&env, &token_id); + + let payer = Address::generate(&env); + let other_payer = Address::generate(&env); + stellar_asset.mint(&payer, &500); + stellar_asset.mint(&other_payer, &500); + + env.ledger().set_timestamp(1_000); + let id = single_recipient_invoice( + &env, + &c, + &token_id, + 500, + invoice_options(Some(120), Some(1), Some(60)), + ); + + let ext = c.get_invoice_ext(&id); + assert_eq!(ext.payment_cooldown_secs, Some(120)); + 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); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 2f3d615..dc34b65 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -22,7 +22,53 @@ pub struct Payment { pub amount: i128, } -/// An on-chain invoice splitting payment among multiple recipients. +/// Optional invoice-level payment controls. +#[contracttype] +#[derive(Clone, Debug)] +pub struct InvoiceOptions { + /// Minimum seconds between payments from the same payer on this invoice. + pub payment_cooldown_secs: Option, + /// Maximum number of payments allowed inside a rolling window. + pub max_payments_per_window: Option, + /// Rolling window size in seconds for the global invoice payment limit. + pub payment_window_secs: Option, +} + +/// Storage-efficient invoice data kept separately from extension settings. +#[contracttype] +#[derive(Clone, Debug)] +pub struct InvoiceCore { + /// Address that created the invoice. + pub creator: Address, + /// Ordered list of recipient addresses. + pub recipients: Vec
, + /// Amounts owed to each recipient (parallel to `recipients`). + pub amounts: Vec, + /// USDC token contract address. + pub token: Address, + /// Unix timestamp after which unfunded invoices can be refunded. + pub deadline: u64, + /// Total amount collected so far. + pub funded: i128, + /// Current lifecycle status. + pub status: InvoiceStatus, + /// All payments made toward this invoice. + pub payments: Vec, +} + +/// Less frequently used invoice extension settings. +#[contracttype] +#[derive(Clone, Debug)] +pub struct InvoiceExt { + /// Minimum seconds between payments from the same payer on this invoice. + pub payment_cooldown_secs: Option, + /// Maximum number of payments allowed inside a rolling window. + pub max_payments_per_window: Option, + /// Rolling window size in seconds for the global invoice payment limit. + pub payment_window_secs: Option, +} + +/// Public invoice view returned by `get_invoice`. #[contracttype] #[derive(Clone, Debug)] pub struct Invoice { @@ -43,3 +89,28 @@ pub struct Invoice { /// All payments made toward this invoice. pub payments: Vec, } + +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, + } + } +}