diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 6f82aef..768ed87 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -410,6 +410,19 @@ fn load_treasury_record(env: &Env, group_id: u64) -> TreasuryRecord { .expect("treasury record not found") } +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 // --------------------------------------------------------------------------- @@ -4041,4 +4054,89 @@ impl SplitContract { save_invoice(&env, invoice_id, &invoice); } } + + 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 0213e92..daa4961 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -4177,3 +4177,109 @@ fn test_min_funding_amount_releases_when_met() { assert_eq!(invoice.funded, 100); assert_eq!(invoice.status, InvoiceStatus::Released); } + +#[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 d251307..7542623 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -607,3 +607,28 @@ 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, + } + } +}