From a95dbe9439205d02b5b26438b15170d501a39793 Mon Sep 17 00:00:00 2001 From: Tisan1000 Date: Sat, 29 Aug 2026 13:46:18 +0000 Subject: [PATCH] feat(split): emit events for overfunding, penalty, deadline & whitelist changes Adds four indexer-facing events so payers and off-chain monitors can explain balance/state changes that were previously silent: - overfunding_triggered(invoice_id, payer, policy, surplus) (#686) Emitted from _pay when an AcceptAll / ReturnSurplus policy pushes funded past total. `surplus` is the amount refunded (ReturnSurplus) or accepted beyond the target (AcceptAll). Cap keeps its existing overflow_behavior events. - penalty_applied(invoice_id, payer, penalty_amount, penalty_bps) (#687) Emitted from the late-payment path when penalty_bps > 0 and penalty_deadline has passed. penalty_amount is the actual stroops deducted and distributed to recipients. - deadline_extended(invoice_id, old_deadline, new_deadline) (#688) Emitted from extend_deadline. Topics (split, dl_ext, invoice_id), data (old_deadline, new_deadline, event_seq). - recipient_whitelist_updated(invoice_id, enabled, added, removed) (#689) Emitted from add_to_recipient_whitelist / remove_from_recipient_whitelist whenever the whitelist mutates; added/removed are address vectors. Also fixes an unclosed-delimiter merge artifact in events.rs (a half-written duplicate invoice_expired) and a duplicated `use` line in test.rs, both of which prevented the crate from compiling at all. Tests: 8 new unit tests in test.rs covering each event's presence, absence on the negative path, and payload values. Note: `main` currently has ~39 further pre-existing compile errors from earlier bad merges (unrelated to these issues), so the full `cargo test` suite does not yet build; these events and their call sites are self-contained and follow the existing events.rs conventions. Closes #686 Closes #687 Closes #688 Closes #689 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RESwkFykGpG3KJdWhGQC2E --- contracts/split/src/events.rs | 125 +++++++++++++- contracts/split/src/lib.rs | 58 +++++++ contracts/split/src/test.rs | 297 +++++++++++++++++++++++++++++++++- 3 files changed, 472 insertions(+), 8 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 99816e8..543a803 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -25,7 +25,9 @@ //! symbol exceeds the short-macro length limit or must be constructed //! dynamically. -use crate::types::{DisputeOutcome, FeeSplit, InvoiceStatus, RepScore, TimelockAction}; +use crate::types::{ + DisputeOutcome, FeeSplit, InvoiceStatus, OverfundingPolicy, RepScore, TimelockAction, +}; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec}; // --------------------------------------------------------------------------- @@ -182,12 +184,6 @@ pub fn invoice_expired(env: &Env, invoice_id: u64, deadline: u64, funded: i128, invoice_id, ), (deadline, funded, creator.clone()), -/// Data: (deadline, funded) -pub fn invoice_expired(env: &Env, invoice_id: u64, deadline: u64, funded: i128) { - let event_seq = next_seq(env, invoice_id); - env.events().publish( - (symbol_short!("split"), symbol_short!("expired"), invoice_id), - (deadline, funded, event_seq), ); } @@ -1819,3 +1815,118 @@ pub fn admin_transfer_completed(env: &Env, new_admin: &Address) { new_admin.clone(), ); } + +// --------------------------------------------------------------------------- +// Issue #686: Overfunding policy activation +// --------------------------------------------------------------------------- + +/// Map an `OverfundingPolicy` variant to a stable, short symbol for events. +fn overfunding_policy_sym(policy: &OverfundingPolicy) -> soroban_sdk::Symbol { + match policy { + OverfundingPolicy::Cap => symbol_short!("cap"), + OverfundingPolicy::AcceptAll => symbol_short!("acceptall"), + OverfundingPolicy::ReturnSurplus => symbol_short!("retsurpls"), + } +} + +/// Issue #686: Emitted when a payment pushes `funded` past `total` and the +/// invoice's [`OverfundingPolicy`] (either `AcceptAll` or `ReturnSurplus`) +/// decides what happens to the excess. Not emitted for the default `Cap` +/// policy, which defers to `overflow_behavior` and has its own events. +/// +/// Topics: (split, ovf_trig, invoice_id) +/// Data: (payer, policy, surplus, event_seq) +/// +/// `surplus` is the amount beyond `total`: the stroops refunded to the payer +/// for `ReturnSurplus`, or the stroops accepted over the target for `AcceptAll`. +pub fn overfunding_triggered( + env: &Env, + invoice_id: u64, + payer: &Address, + policy: &OverfundingPolicy, + surplus: i128, +) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("ovf_trig"), invoice_id), + ( + payer.clone(), + overfunding_policy_sym(policy), + surplus, + event_seq, + ), + ); +} + +// --------------------------------------------------------------------------- +// Issue #687: Late-payment penalty +// --------------------------------------------------------------------------- + +/// Issue #687: Emitted from the payment path when a late payment incurs a +/// `penalty_bps` deduction (i.e. `penalty_bps > 0` and `penalty_deadline` has +/// passed). `penalty_amount` is the actual stroops taken from the payment and +/// distributed to recipients. +/// +/// Topics: (split, pen_appl, invoice_id) +/// Data: (payer, penalty_amount, penalty_bps, event_seq) +pub fn penalty_applied( + env: &Env, + invoice_id: u64, + payer: &Address, + penalty_amount: i128, + penalty_bps: u32, +) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("pen_appl"), invoice_id), + (payer.clone(), penalty_amount, penalty_bps, event_seq), + ); +} + +// --------------------------------------------------------------------------- +// Issue #688: Invoice deadline extension +// --------------------------------------------------------------------------- + +/// Issue #688: Emitted from `extend_deadline` so payers who planned around the +/// original deadline get an on-chain record of the change. +/// +/// Topics: (split, dl_ext, invoice_id) +/// Data: (old_deadline, new_deadline, event_seq) +pub fn deadline_extended(env: &Env, invoice_id: u64, old_deadline: u64, new_deadline: u64) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("dl_ext"), invoice_id), + (old_deadline, new_deadline, event_seq), + ); +} + +// --------------------------------------------------------------------------- +// Issue #689: Recipient whitelist updates +// --------------------------------------------------------------------------- + +/// Issue #689: Emitted whenever an invoice's recipient whitelist state changes +/// so off-chain monitors that gate on whitelist membership do not have to poll. +/// +/// `enabled` reflects `recipient_whitelist_enabled` for the invoice. `added` +/// and `removed` are the addresses affected by this particular change (each is +/// typically a single entry, but the vectors leave room for batch updates). +/// +/// Topics: (split, rcp_wl_up, invoice_id) +/// Data: (enabled, added, removed, event_seq) +pub fn recipient_whitelist_updated( + env: &Env, + invoice_id: u64, + enabled: bool, + added: &Vec
, + removed: &Vec
, +) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + ( + symbol_short!("split"), + symbol_short!("rcp_wl_up"), + invoice_id, + ), + (enabled, added.clone(), removed.clone(), event_seq), + ); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 874d3bf..131e0b8 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -3933,6 +3933,15 @@ impl SplitContract { .persistent() .set(&recipient_whitelist_key(invoice_id), &whitelist); events::recipient_whitelisted(&env, invoice_id, &address); + let mut added: Vec
= Vec::new(&env); + added.push_back(address.clone()); + events::recipient_whitelist_updated( + &env, + invoice_id, + invoice.recipient_whitelist_enabled, + &added, + &Vec::new(&env), + ); } } @@ -3973,6 +3982,15 @@ impl SplitContract { .persistent() .set(&recipient_whitelist_key(invoice_id), &new_wl); events::recipient_removed_from_whitelist(&env, invoice_id, &address); + let mut removed: Vec
= Vec::new(&env); + removed.push_back(address.clone()); + events::recipient_whitelist_updated( + &env, + invoice_id, + invoice.recipient_whitelist_enabled, + &Vec::new(&env), + &removed, + ); } } @@ -7325,6 +7343,36 @@ impl SplitContract { } } + // Issue #686: announce when the overfunding policy actually changes the + // outcome of this payment, i.e. it would push `funded` past `total`. + // `Cap` has its own `overflow_behavior` events, so it is excluded here. + match invoice.overfunding_policy { + OverfundingPolicy::AcceptAll => { + let surplus = (invoice.funded + credited_amount - total).max(0); + if surplus > 0 { + events::overfunding_triggered( + env, + invoice_id, + payer, + &invoice.overfunding_policy, + surplus, + ); + } + } + OverfundingPolicy::ReturnSurplus => { + if excess > 0 { + events::overfunding_triggered( + env, + invoice_id, + payer, + &invoice.overfunding_policy, + excess, + ); + } + } + OverfundingPolicy::Cap => {} + } + invoice.insurance_fund += premium; // Penalty for late payment (issues #42, #211). @@ -7360,6 +7408,14 @@ impl SplitContract { token_client.transfer(payer, &recipient, &share); } } + // Issue #687: surface the late-payment penalty deduction. + events::penalty_applied( + env, + invoice_id, + payer, + penalty_amount, + penalty_bps, + ); } } } @@ -11853,9 +11909,11 @@ impl SplitContract { assert!(is_creator_or_co || is_delegate, "not authorized"); } + let old_deadline = invoice.deadline; invoice.deadline = new_deadline; save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("extend"), &caller); + events::deadline_extended(&env, invoice_id, old_deadline, new_deadline); } /// Roll over a partially funded invoice to a new invoice with the same recipients, diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 498ea3b..2bfe2e7 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -5,7 +5,6 @@ use soroban_sdk::{ testutils::{Address as _, Events as _, Ledger}, token::{Client as TokenClient, StellarAssetClient}, Address, Bytes, BytesN, Env, String, Symbol, TryFromVal, Val, Vec, - Address, Bytes, BytesN, Env, String, Symbol, Vec, }; use types::InvoiceOptions; @@ -8362,3 +8361,299 @@ fn test_get_invoice_status_not_found() { let result = c.try_get_invoice_status(&999); assert!(result.is_err()); } + +// --------------------------------------------------------------------------- +// Issue #686 — overfunding_triggered event +// --------------------------------------------------------------------------- + +fn count_events_with_topic1(env: &Env, name: &str) -> usize { + env.events() + .all() + .iter() + .filter(|(_c, topics, _d)| topic1_is(env, topics, name)) + .count() +} + +#[test] +fn test_overfunding_triggered_event_emitted_for_return_surplus() { + use soroban_sdk::TryIntoVal; + let (env, contract_id, token_id) = setup_initialized(); + 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, &200); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + c.set_overfunding_policy(&creator, &id, &types::OverfundingPolicy::ReturnSurplus); + + // Pay 150 toward a 100 invoice: 100 is credited, 50 is returned. + c.pay(&payer, &id, &150_i128, &0_u64, &false, &false, &None); + + let mut found: Option<(Address, Symbol, i128, u64)> = None; + for (_contract, topics, data) in env.events().all().iter() { + if topic1_is(&env, &topics, "ovf_trig") { + found = Some(data.try_into_val(&env).unwrap()); + } + } + let (evt_payer, policy, surplus, _seq) = found.expect("overfunding_triggered event missing"); + assert_eq!(evt_payer, payer); + assert_eq!(policy, Symbol::new(&env, "retsurpls")); + assert_eq!(surplus, 50, "surplus is the amount refunded beyond total"); + // Payer: -150 paid, +50 refunded. + assert_eq!(tk.balance(&payer), 100); +} + +#[test] +fn test_overfunding_triggered_event_emitted_for_accept_all() { + use soroban_sdk::TryIntoVal; + let (env, contract_id, token_id) = setup_initialized(); + 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, &200); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + c.set_overfunding_policy(&creator, &id, &types::OverfundingPolicy::AcceptAll); + + c.pay(&payer, &id, &150_i128, &0_u64, &false, &false, &None); + + let mut found: Option<(Address, Symbol, i128, u64)> = None; + for (_contract, topics, data) in env.events().all().iter() { + if topic1_is(&env, &topics, "ovf_trig") { + found = Some(data.try_into_val(&env).unwrap()); + } + } + let (_p, policy, surplus, _seq) = found.expect("overfunding_triggered event missing"); + assert_eq!(policy, Symbol::new(&env, "acceptall")); + assert_eq!(surplus, 50, "surplus is the amount accepted beyond total"); +} + +#[test] +fn test_overfunding_triggered_not_emitted_when_within_total() { + let (env, contract_id, token_id) = setup_initialized(); + 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, &200); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + c.set_overfunding_policy(&creator, &id, &types::OverfundingPolicy::ReturnSurplus); + + // Exact fill: funded + payment == total, so no overfunding occurs. + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); + + assert_eq!( + count_events_with_topic1(&env, "ovf_trig"), + 0, + "overfunding_triggered must not fire unless funded + payment exceeds total" + ); +} + +// --------------------------------------------------------------------------- +// Issue #687 — penalty_applied event +// --------------------------------------------------------------------------- + +fn late_penalty_invoice( + env: &Env, + c: &SplitContractClient, + creator: &Address, + recipient: &Address, + token_id: &Address, +) -> u64 { + let mut recipients = Vec::new(env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(env); + amounts.push_back(500_i128); + c.create_invoice( + creator, + &recipients, + &amounts, + token_id, + &9_999_u64, + &InvoiceOptions { + penalty_bps: Some(1_000), // 10% + penalty_deadline: Some(2_000), + ..default_options(env) + }, + ) +} + +#[test] +fn test_penalty_applied_event_emitted_for_late_payment() { + use soroban_sdk::TryIntoVal; + let (env, contract_id, token_id) = setup_initialized(); + 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, &1_000); + env.ledger().set_timestamp(1_000); + + let id = late_penalty_invoice(&env, &c, &creator, &recipient, &token_id); + + // Pay after the penalty deadline. + env.ledger().set_timestamp(3_000); + c.pay(&payer, &id, &500_i128, &0_u64, &false, &false, &None); + + let mut found: Option<(Address, i128, u32, u64)> = None; + for (_contract, topics, data) in env.events().all().iter() { + if topic1_is(&env, &topics, "pen_appl") { + found = Some(data.try_into_val(&env).unwrap()); + } + } + let (evt_payer, penalty_amount, penalty_bps, _seq) = + found.expect("penalty_applied event missing for late payment"); + assert_eq!(evt_payer, payer); + assert_eq!(penalty_amount, 50, "500 * 10% deducted from the payment"); + assert_eq!(penalty_bps, 1_000); +} + +#[test] +fn test_penalty_applied_event_absent_for_on_time_payment() { + let (env, contract_id, token_id) = setup_initialized(); + 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, &1_000); + env.ledger().set_timestamp(1_000); + + let id = late_penalty_invoice(&env, &c, &creator, &recipient, &token_id); + + // Pay before the penalty deadline. + c.pay(&payer, &id, &500_i128, &0_u64, &false, &false, &None); + + assert_eq!( + count_events_with_topic1(&env, "pen_appl"), + 0, + "penalty_applied must not fire for an on-time payment" + ); +} + +// --------------------------------------------------------------------------- +// Issue #688 — deadline_extended event +// --------------------------------------------------------------------------- + +#[test] +fn test_deadline_extended_event_carries_old_and_new_values() { + use soroban_sdk::TryIntoVal; + let (env, contract_id, token_id) = setup_initialized(); + 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, 100, &token_id, 5_000); + + c.extend_deadline(&id, &10_000_u64, &creator); + + let mut found: Option<(u64, u64, u64)> = None; + for (_contract, topics, data) in env.events().all().iter() { + if topic1_is(&env, &topics, "dl_ext") { + found = Some(data.try_into_val(&env).unwrap()); + } + } + let (old_deadline, new_deadline, _seq) = + found.expect("deadline_extended event missing"); + assert_eq!(old_deadline, 5_000); + assert_eq!(new_deadline, 10_000); + assert_eq!(c.get_invoice(&id).deadline, 10_000); +} + +// --------------------------------------------------------------------------- +// Issue #689 — recipient_whitelist_updated event +// --------------------------------------------------------------------------- + +#[test] +fn test_recipient_whitelist_updated_event_on_enable_and_add() { + use soroban_sdk::TryIntoVal; + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let whitelisted = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut recipients = Vec::new(&env); + 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, + &InvoiceOptions { + ext: types::InvoiceOptions2 { + recipient_whitelist_enabled: true, + ..default_options(&env).ext + }, + ..default_options(&env) + }, + ); + + c.add_to_recipient_whitelist(&creator, &id, &whitelisted); + + let mut found: Option<(bool, Vec
, Vec
, u64)> = None; + for (_contract, topics, data) in env.events().all().iter() { + if topic1_is(&env, &topics, "rcp_wl_up") { + found = Some(data.try_into_val(&env).unwrap()); + } + } + let (enabled, added, removed, _seq) = + found.expect("recipient_whitelist_updated event missing"); + assert!(enabled, "invoice was created with the whitelist enabled"); + assert_eq!(added.len(), 1); + assert_eq!(added.get(0).unwrap(), whitelisted); + assert_eq!(removed.len(), 0); +} + +#[test] +fn test_recipient_whitelist_updated_event_on_remove() { + use soroban_sdk::TryIntoVal; + let (env, contract_id, token_id) = setup_initialized(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let whitelisted = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + let id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + + c.add_to_recipient_whitelist(&creator, &id, &whitelisted); + c.remove_from_recipient_whitelist(&creator, &id, &whitelisted); + + let mut last_removed: Option> = None; + for (_contract, topics, data) in env.events().all().iter() { + if topic1_is(&env, &topics, "rcp_wl_up") { + let (_enabled, _added, removed, _seq): (bool, Vec
, Vec
, u64) = + data.try_into_val(&env).unwrap(); + last_removed = Some(removed); + } + } + let removed = last_removed.expect("recipient_whitelist_updated event missing on remove"); + assert_eq!(removed.len(), 1); + assert_eq!(removed.get(0).unwrap(), whitelisted); +}