From 1c50a6fa6f91fd56c0cb5240eae890dc0bdac349 Mon Sep 17 00:00:00 2001 From: Timothy Okpalaoka Date: Fri, 28 Aug 2026 08:08:26 +0000 Subject: [PATCH] feat: include token in payment event, add invoice_frozen/allowlist_removed events - payment_received event now carries the funding token address alongside payer/amount/seq, updated across all seven call sites (standard pay, multi-recipient pay, token pay, bridge pay, pool pay, delegated pay, and pay-on-behalf-of). - invoice_cloned event now emits the ledger sequence number instead of empty data, so listeners can correlate clones to a point in time. - pause_invoice now also emits a new invoice_frozen event (creator, ledger) alongside the existing invoice_paused event. - Add remove_allowlist entrypoint to clear an invoice's entire payer allowlist in one call (reopening it to any payer) and emit a new allowlist_removed event; mirrors the existing add/remove_allowed_payer auth and pause checks. --- contracts/split/src/events.rs | 35 ++++++++++--- contracts/split/src/lib.rs | 38 +++++++++++--- contracts/split/src/test.rs | 94 +++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 46db8b6..3c88d50 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -41,12 +41,12 @@ pub fn invoice_created( /// Emitted when a payment is received toward an invoice. /// Topics: (split, paid, invoice_id) -/// Data: (payer, amount, event_seq) -pub fn payment_received(env: &Env, invoice_id: u64, payer: &Address, amount: i128) { +/// Data: (payer, amount, token, event_seq) +pub fn payment_received(env: &Env, invoice_id: u64, payer: &Address, amount: i128, token: &Address) { let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("paid"), invoice_id), - (payer.clone(), amount, event_seq), + (payer.clone(), amount, token.clone(), event_seq), ); } @@ -313,10 +313,12 @@ pub fn payment_matched(env: &Env, invoice_id: u64, memo: u64, payer: &Address) { /// Emitted when an invoice is cloned. /// Topics: (cloned, source_id, new_id) -/// Data: () +/// Data: ledger_sequence pub fn invoice_cloned(env: &Env, source_id: u64, new_id: u64) { - env.events() - .publish((symbol_short!("cloned"), source_id, new_id), ()); + env.events().publish( + (symbol_short!("cloned"), source_id, new_id), + (env.ledger().sequence(),), + ); } /// Emitted when an invoice is paused. @@ -335,6 +337,16 @@ pub fn invoice_paused( ); } +/// Emitted whenever an invoice's `frozen` flag transitions to true. +/// Topics: (split, frozen, invoice_id) +/// Data: (creator, ledger) +pub fn invoice_frozen(env: &Env, invoice_id: u64, creator: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("frozen"), invoice_id), + (creator.clone(), env.ledger().sequence()), + ); +} + /// Emitted when an invoice is resumed. /// Topics: (split, resumed, invoice_id) /// Data: creator @@ -672,6 +684,17 @@ pub fn allowlist_updated( ); } +/// Emitted when a creator clears an invoice's entire payer allowlist, +/// transitioning it from restricted to open (allowed_payers set to None). +/// Topics: (split, al_open, invoice_id) +/// Data: (creator, ledger) +pub fn allowlist_removed(env: &Env, invoice_id: u64, creator: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("al_open"), invoice_id), + (creator.clone(), env.ledger().sequence()), + ); +} + /// Issue #308: Emitted when a payer claims their per-payer refund. /// Topics: (split, ref_clm, invoice_id) /// Data: (payer, amount) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index b2fe796..5e96292 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -6469,7 +6469,7 @@ impl SplitContract { .set(&cumulative_key, &(cumulative + net_paid)); // In real app we might handle penalty/oracle, but for simplicity: - events::payment_received(&env, invoice_id, &payer, net_paid); + events::payment_received(&env, invoice_id, &payer, net_paid, &funding_token_for(&invoice)); let total: i128 = invoice.amounts.iter().sum(); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); @@ -7259,7 +7259,7 @@ impl SplitContract { .set(&credit_key(payer), &(credit + 1)); append_audit_entry(env, invoice_id, symbol_short!("pay"), payer); - events::payment_received(env, invoice_id, payer, credited_amount); + events::payment_received(env, invoice_id, payer, credited_amount, &funding_token_for(&invoice)); // Issue #333: emit milestone events for any thresholds crossed by this payment. { let total_for_milestone: i128 = total; // already computed above @@ -7485,7 +7485,7 @@ impl SplitContract { .set(&cumulative_key, &(cumulative + credited_amount)); append_audit_entry(&env, invoice_id, symbol_short!("pay_tok"), &payer); - events::payment_received(&env, invoice_id, &payer, credited_amount); + events::payment_received(&env, invoice_id, &payer, credited_amount, &funding_token_for(&invoice)); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); Self::record_invoice_rate_limit(&env, invoice_id, &payer); notify_invoice( @@ -7592,7 +7592,7 @@ impl SplitContract { .set(&cumulative_key, &(cumulative + converted)); append_audit_entry(&env, invoice_id, symbol_short!("brdg_pay"), &payer); - events::payment_received(&env, invoice_id, &payer, converted); + events::payment_received(&env, invoice_id, &payer, converted, &invoice_token); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); Self::record_invoice_rate_limit(&env, invoice_id, &payer); notify_invoice( @@ -7710,7 +7710,7 @@ impl SplitContract { .set(&cumulative_key, &(cumulative + p.amount)); append_audit_entry(&env, p.invoice_id, symbol_short!("pool_pay"), &payer); - events::payment_received(&env, p.invoice_id, &payer, p.amount); + events::payment_received(&env, p.invoice_id, &payer, p.amount, &shared_token); let inv_total: i128 = inv.amounts.iter().sum(); if inv.funded >= inv_total { @@ -8403,6 +8403,7 @@ impl SplitContract { save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("paused"), &creator); + events::invoice_frozen(&env, invoice_id, &creator); events::invoice_paused(&env, invoice_id, &creator, &reason, &auto_resume_at); } @@ -8494,6 +8495,29 @@ impl SplitContract { } } + /// Remove the invoice's entire payer allowlist, reopening it to any payer. + /// + /// Only the creator (or a co-creator) may call this. Sets `allowed_payers` + /// to `None`. If the invoice is already open, this is a no-op and does not + /// emit an event. + pub fn remove_allowlist(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 modify allowlist" + ); + + if invoice.allowed_payers.is_some() { + invoice.allowed_payers = None; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("al_open"), &creator); + events::allowlist_removed(&env, invoice_id, &creator); + } + } + /// Issue #329: Update the off-chain metadata hash for an invoice. /// /// Only the creator may call this. Emits `MetadataUpdated` with old and new hash. @@ -13054,7 +13078,7 @@ impl SplitContract { .set(&cumulative_key, &(cumulative + amount)); append_audit_entry(&env, invoice_id, symbol_short!("del_pay"), &delegate); - events::payment_received(&env, invoice_id, &beneficiary, amount); + events::payment_received(&env, invoice_id, &beneficiary, amount, &funding_token_for(&invoice)); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); Self::record_invoice_rate_limit(&env, invoice_id, &beneficiary); notify_invoice( @@ -13760,7 +13784,7 @@ impl SplitContract { .set(&cumulative_key, &(cumulative + amount)); events::delegated_payment(&env, invoice_id, &on_behalf_of, &executor, amount); - events::payment_received(&env, invoice_id, &on_behalf_of, amount); + events::payment_received(&env, invoice_id, &on_behalf_of, amount, &funding_token_for(&invoice)); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); Self::record_invoice_rate_limit(&env, invoice_id, &on_behalf_of); append_audit_entry(&env, invoice_id, symbol_short!("dlgt_pay"), &executor); diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index cca8b97..7d361f8 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -4786,6 +4786,28 @@ fn test_pause_blocks_payment_with_reason() { c.pay(&payer, &id, &100_i128, &0_u64, &false, &false, &None); } +#[test] +fn test_pause_invoice_emits_frozen_event() { + 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, 200, &token_id, 9_999); + + let reason = soroban_sdk::String::from_str(&env, "legal review pending"); + c.pause_invoice(&creator, &id, &reason, &None); + + let has_frozen_event = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "frozen")); + assert!(has_frozen_event, "expected an invoice_frozen event on pause"); +} + #[test] fn test_auto_resume_allows_payment_after_timestamp() { let (env, contract_id, token_id) = setup_initialized(); @@ -4944,6 +4966,42 @@ fn test_clone_copies_recipients_and_amounts() { assert_eq!(clone_ext.parent_invoice_id, Some(source_id)); } +#[test] +fn test_clone_invoice_emits_ledger_sequence_in_event_data() { + 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 source_id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + + env.ledger().set_sequence_number(42); + let overrides = types::CloneOverrides { + new_deadline: None, + new_amounts: None, + new_recipients: None, + new_overflow_behavior: None, + new_metadata_hash: None, + }; + let _clone_id = c.clone_invoice(&creator, &source_id, &overrides); + + let cloned_event = env + .events() + .all() + .iter() + .find(|(_c, topics, _d)| topic0_is(&env, topics, "cloned")) + .expect("expected an invoice_cloned event"); + + let (_contract, _topics, data) = cloned_event; + let (ledger_seq,): (u32,) = data.try_into_val(&env).unwrap(); + assert_eq!(ledger_seq, 42); +} + #[test] fn test_clone_with_overrides_replaces_fields() { let (env, contract_id, token_id) = setup_initialized(); @@ -7121,6 +7179,42 @@ fn test_309_remove_allowed_payer_emits_event() { assert_eq!(payers.len(), 0, "allowed_payers should be empty after removal"); } +#[test] +fn test_remove_allowlist_opens_invoice_and_emits_event() { + 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 allowed_payer = Address::generate(&env); + let other_payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&other_payer, &300); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 300, &token_id, 9_999); + c.add_allowed_payer(&creator, &id, &allowed_payer); + assert!(c.get_invoice_ext(&id).allowed_payers.is_some()); + + c.remove_allowlist(&creator, &id); + assert!(c.get_invoice_ext(&id).allowed_payers.is_none()); + + let has_allowlist_removed_event = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "al_open")); + assert!( + has_allowlist_removed_event, + "expected an allowlist_removed event" + ); + + // The invoice is now open — a previously non-allowed payer can pay. + c.pay(&other_payer, &id, &300_i128, &0_u64, &false, &false, &None); + assert_eq!(tk.balance(&recipient), 300); +} + #[test] fn test_creator_stats_unique_payers() { let (env, contract_id, token_id) = setup_initialized();