From 7d7e34f92fd816ddfd282dcfd7ae648bd4ad226f Mon Sep 17 00:00:00 2001 From: Victoria Idowu Date: Fri, 28 Aug 2026 09:43:06 +0000 Subject: [PATCH] feat: add missing indexer events for allowlist toggle, auto-resume, tip, and forward_to Four indexer-visible state changes were previously silent: - InvoiceExt2::contributor_allowlist could be toggled on/off (via add_contributor_to_allowlist / remove_contributor_allowlist) with no event, so indexers enforcing compliance rules couldn't observe it. Now emits contributor_allowlist_toggled(creator, enabled) exactly on the None <-> Some transition (first add / last remove), not on every list edit. - Automatic (timer-triggered) invoice resume via auto_resume_at emitted no event at all (the lazy check lives in pay()'s internal _pay path). Now emits invoice_auto_resumed(auto_resume_at), distinct from the existing invoice_resumed event which remains manual-resume-only. - Payment.tip was tracked in storage but never surfaced on the payment_received event, forcing indexers to re-parse Invoice.payments to see tipping behaviour. payment_received now carries tip in its data tuple. - InvoiceOptions.forward_to, when set at invoice creation, had no dedicated event and was invisible until funds were actually forwarded at release time. Now emits forward_configured(forward_to) right after invoice_created when forward_to is Some. Adds one test per feature following the existing topic0_is/topic1_is event-assertion pattern in test.rs. --- contracts/split/src/events.rs | 41 ++++++++- contracts/split/src/lib.rs | 27 ++++-- contracts/split/src/test.rs | 158 ++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 10 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 46db8b6..07aa3f3 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -39,14 +39,25 @@ pub fn invoice_created( ); } +/// Emitted at invoice creation when `forward_to` is configured, making +/// surplus-forwarding visible to indexers without waiting for a release. +/// Topics: (split, fwd_cfg, invoice_id) +/// Data: forward_to +pub fn forward_configured(env: &Env, invoice_id: u64, forward_to: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("fwd_cfg"), invoice_id), + forward_to.clone(), + ); +} + /// 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, tip, event_seq) +pub fn payment_received(env: &Env, invoice_id: u64, payer: &Address, amount: i128, tip: i128) { 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, tip, event_seq), ); } @@ -345,6 +356,18 @@ pub fn invoice_resumed(env: &Env, invoice_id: u64, creator: &Address) { ); } +/// Emitted when a paused invoice is automatically resumed because +/// `auto_resume_at` has passed (checked lazily on the next `pay()` call). +/// Distinct from `invoice_resumed`, which is only for manual `resume_invoice`. +/// Topics: (split, auto_res, invoice_id) +/// Data: auto_resume_at +pub fn invoice_auto_resumed(env: &Env, invoice_id: u64, auto_resume_at: u64) { + env.events().publish( + (symbol_short!("split"), symbol_short!("auto_res"), invoice_id), + auto_resume_at, + ); +} + /// Emitted when an invoice is force resumed. /// Topics: (split, forced, invoice_id) /// Data: admin_addr @@ -355,6 +378,18 @@ pub fn invoice_force_resumed(env: &Env, invoice_id: u64, admin_addr: &Address) { ); } +/// Emitted when the per-invoice contributor allowlist gating is toggled on +/// (first entry added, list goes None -> Some) or off (last entry removed, +/// list goes Some -> None). +/// Topics: (split, al_tog, invoice_id) +/// Data: (creator, enabled) +pub fn contributor_allowlist_toggled(env: &Env, invoice_id: u64, creator: &Address, enabled: bool) { + env.events().publish( + (symbol_short!("split"), symbol_short!("al_tog"), invoice_id), + (creator.clone(), enabled), + ); +} + /// Emitted when a pending payout is claimed by a recipient (issue #209). /// Topics: (split, pend_pay, invoice_id) /// Data: (recipient, amount) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index b2fe796..1873254 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -5729,6 +5729,9 @@ impl SplitContract { } events::invoice_created(env, id, &creator, total, &invoice.cross_chain_ref); + if let Some(ref addr) = invoice.forward_to { + events::forward_configured(env, id, addr); + } maybe_record_created(env, &creator, total); update_creator_stats_on_creation(env, &creator); @@ -6469,7 +6472,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, 0); let total: i128 = invoice.amounts.iter().sum(); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); @@ -6866,6 +6869,7 @@ impl SplitContract { invoice.pause_reason = None; invoice.auto_resume_at = None; save_invoice(env, invoice_id, &invoice); + events::invoice_auto_resumed(env, invoice_id, auto_at); } } } @@ -7259,7 +7263,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, 0); // Issue #333: emit milestone events for any thresholds crossed by this payment. { let total_for_milestone: i128 = total; // already computed above @@ -7485,7 +7489,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, 0); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); Self::record_invoice_rate_limit(&env, invoice_id, &payer); notify_invoice( @@ -7592,7 +7596,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, 0); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); Self::record_invoice_rate_limit(&env, invoice_id, &payer); notify_invoice( @@ -7710,7 +7714,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, 0); let inv_total: i128 = inv.amounts.iter().sum(); if inv.funded >= inv_total { @@ -8323,6 +8327,7 @@ impl SplitContract { invoice.creator == creator || invoice.co_creators.contains(&creator), "NotAuthorized" ); + let was_disabled = invoice.contributor_allowlist.is_none(); let mut list = invoice .contributor_allowlist .unwrap_or_else(|| Vec::new(&env)); @@ -8332,6 +8337,9 @@ impl SplitContract { invoice.contributor_allowlist = Some(list); save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("al_add"), &creator); + if was_disabled { + events::contributor_allowlist_toggled(&env, invoice_id, &creator, true); + } } /// Remove `contributor` from the per-invoice contributor allowlist. @@ -8350,6 +8358,7 @@ impl SplitContract { invoice.creator == creator || invoice.co_creators.contains(&creator), "NotAuthorized" ); + let mut became_disabled = false; if let Some(old_list) = invoice.contributor_allowlist { let mut new_list: Vec
= Vec::new(&env); for addr in old_list.iter() { @@ -8357,6 +8366,7 @@ impl SplitContract { new_list.push_back(addr); } } + became_disabled = new_list.is_empty(); invoice.contributor_allowlist = if new_list.is_empty() { None } else { @@ -8365,6 +8375,9 @@ impl SplitContract { } save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("al_rm"), &creator); + if became_disabled { + events::contributor_allowlist_toggled(&env, invoice_id, &creator, false); + } } // ----------------------------------------------------------------------- @@ -13054,7 +13067,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, 0); check_and_emit_funding_checkpoints(&env, invoice_id, invoice.funded, total); Self::record_invoice_rate_limit(&env, invoice_id, &beneficiary); notify_invoice( @@ -13760,7 +13773,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, 0); 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..4e47e9a 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -546,6 +546,61 @@ fn test_forward_to_invoice_credits_target_invoice() { assert_eq!(c.get_invoice(&id_parent).funded, 0); } +#[test] +fn test_forward_configured_event_emitted_when_forward_to_set() { + 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 forward_target = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let mut opts = default_options(&env); + opts.forward_to = Some(forward_target.clone()); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + let has_forward_configured_event = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "fwd_cfg")); + assert!( + has_forward_configured_event, + "forward_configured event should be emitted when forward_to is set at creation" + ); +} + +#[test] +fn test_forward_configured_event_absent_when_forward_to_unset() { + 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); + + // default_options() leaves forward_to as None. + let _id = make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 9_999); + + let has_forward_configured_event = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "fwd_cfg")); + assert!( + !has_forward_configured_event, + "forward_configured event should not fire when forward_to is not set" + ); +} + #[test] fn test_template_overwrite() { let (env, contract_id, token_id) = setup_initialized(); @@ -4815,6 +4870,28 @@ fn test_auto_resume_allows_payment_after_timestamp() { let invoice = c.get_invoice(&id); assert_eq!(invoice.status, InvoiceStatus::Released); assert_eq!(tk.balance(&recipient), 200); + + // A distinct `invoice_auto_resumed` event fires for the timer-triggered + // resume; the manual `resumed` event must NOT fire (it wasn't a manual resume). + let has_auto_resumed_event = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "auto_res")); + assert!( + has_auto_resumed_event, + "invoice_auto_resumed event should be emitted on lazy auto-resume" + ); + + let has_manual_resumed_event = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "resumed")); + assert!( + !has_manual_resumed_event, + "manual invoice_resumed should not fire for an automatic resume" + ); } #[test] @@ -6883,6 +6960,87 @@ fn test_309_allowlist_restricts_payers() { let _ = blocked_payer; } +#[test] +fn test_contributor_allowlist_toggle_events() { + 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 contributor = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + + let ext_before = c.get_invoice_ext2(&id); + assert!(ext_before.contributor_allowlist.is_none()); + + // Adding the first contributor turns gating ON (None -> Some). + c.add_contributor_to_allowlist(&creator, &id, &contributor); + + let ext_enabled = c.get_invoice_ext2(&id); + assert!(ext_enabled.contributor_allowlist.is_some()); + + let toggled_on_count = env + .events() + .all() + .iter() + .filter(|(_c, topics, _d)| topic1_is(&env, topics, "al_tog")) + .count(); + assert_eq!( + toggled_on_count, 1, + "contributor_allowlist_toggled(enabled=true) should fire exactly once on first add" + ); + + // Removing the only contributor turns gating OFF (Some -> None). + c.remove_contributor_allowlist(&creator, &id, &contributor); + + let ext_disabled = c.get_invoice_ext2(&id); + assert!(ext_disabled.contributor_allowlist.is_none()); + + let toggled_total_count = env + .events() + .all() + .iter() + .filter(|(_c, topics, _d)| topic1_is(&env, topics, "al_tog")) + .count(); + assert_eq!( + toggled_total_count, 2, + "one toggle event for enabling, one for disabling" + ); +} + +#[test] +fn test_payment_received_event_includes_tip() { + 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, &500); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + c.pay(&payer, &id, &200_i128, &0_u64, &false, &false, &None); + + use soroban_sdk::TryIntoVal; + let mut found_tip: Option = None; + for (_contract, topics, data) in env.events().all().iter() { + if topic1_is(&env, &topics, "paid") { + let decoded: (Address, i128, i128, u64) = data.try_into_val(&env).unwrap(); + found_tip = Some(decoded.2); + } + } + assert_eq!( + found_tip, + Some(0), + "payment_received event data should include the tip amount" + ); +} + #[test] fn test_creator_stats_on_invoice_creation() { let (env, contract_id, token_id) = setup_initialized();