From 2d7f0c88db20d54f141db07dec1805ab948bcd2d Mon Sep 17 00:00:00 2001 From: GenesisPray <125413943+GenesisPray@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:28:30 +0000 Subject: [PATCH] Enrich refund/expiry events and fix funds_unlocked gap - invoice_refunded now carries the total amount returned to all payers instead of an empty payload, so indexers can reconcile refunds without cross-referencing individual payer_refunded events. Updated all four emission sites (arbiter resolve-refund, auto-resolve refund, deadline refund, dispute-refund) to pass the summed total. - invoice_expired now includes the invoice creator's address alongside (deadline, funded), letting indexers attribute expired invoices to creators without a follow-up get_invoice call. - release_to_recipient enforced the InvoiceExt3::release_delay_ledgers time-lock but never emitted funds_unlocked when the lock cleared, unlike release_invoice. Added the missing emission so the event fires consistently across both release paths at the exact unlock ledger. No InvoiceExt2::creator_fee_bps field or mutation path exists in this contract (fees are governed by a global platform_fee_bps plus a boolean per-creator waiver list, which already emits fee_waiver_granted/revoked), so no changes were needed there. Added test coverage: invoice_expired creator payload, invoice_refunded total payload, and funds_unlocked firing at the exact unlock ledger via release_to_recipient (plus a should_panic guard for the still-locked case). --- contracts/split/src/events.rs | 12 +-- contracts/split/src/lib.rs | 21 +++- contracts/split/src/test.rs | 180 +++++++++++++++++++++++++++++++++- 3 files changed, 201 insertions(+), 12 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 025cdcd..38ee0e8 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -76,15 +76,15 @@ pub fn invoice_released(env: &Env, invoice_id: u64, recipients: &Vec
) { /// Emitted when an invoice is refunded after deadline. /// Topics: (split, refunded, invoice_id) -/// Data: () -pub fn invoice_refunded(env: &Env, invoice_id: u64) { +/// Data: total_amount (sum returned to all payers, excluding any creator payout) +pub fn invoice_refunded(env: &Env, invoice_id: u64, total_amount: i128) { env.events().publish( ( symbol_short!("split"), symbol_short!("refunded"), invoice_id, ), - (), + total_amount, ); } @@ -104,15 +104,15 @@ pub fn condition_verified(env: &Env, invoice_id: u64, preimage_hash: &BytesN<32> /// Emitted when an invoice expires. /// Topics: (split, expired, invoice_id) -/// Data: (deadline, funded) -pub fn invoice_expired(env: &Env, invoice_id: u64, deadline: u64, funded: i128) { +/// Data: (deadline, funded, creator) +pub fn invoice_expired(env: &Env, invoice_id: u64, deadline: u64, funded: i128, creator: &Address) { env.events().publish( ( symbol_short!("split"), symbol_short!("expired"), invoice_id, ), - (deadline, funded), + (deadline, funded, creator.clone()), ); } diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index be40701..6ea3b14 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -2419,7 +2419,7 @@ impl SplitContract { invoice.completion_time = Some(env.ledger().timestamp()); save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("resolve"), &arbiter); - events::invoice_refunded(&env, invoice_id); + events::invoice_refunded(&env, invoice_id, total_refunded_amount); events::invoice_state_changed( &env, invoice_id, @@ -6364,6 +6364,9 @@ impl SplitContract { { let unlock_at = funded_at.saturating_add(delay_ledgers); assert!(env.ledger().sequence() >= unlock_at, "FundsLockedUntil"); + // Issue #327: emit the same unlock event as `release_invoice` so + // indexers observe funds_unlocked regardless of which release path is used. + events::funds_unlocked(&env, invoice_id, unlock_at); } } @@ -8035,7 +8038,7 @@ impl SplitContract { save_invoice(&env, invoice_id, &invoice); let actor = env.current_contract_address(); append_audit_entry(&env, invoice_id, symbol_short!("auto_ref"), &actor); - events::invoice_refunded(&env, invoice_id); + events::invoice_refunded(&env, invoice_id, total_refunded_amount); events::invoice_state_changed( &env, invoice_id, @@ -8128,7 +8131,13 @@ impl SplitContract { invoice.status = InvoiceStatus::Expired; save_invoice(&env, invoice_id, &invoice); - events::invoice_expired(&env, invoice_id, invoice.deadline, invoice.funded); + events::invoice_expired( + &env, + invoice_id, + invoice.deadline, + invoice.funded, + &invoice.creator, + ); append_audit_entry( &env, invoice_id, @@ -8220,7 +8229,7 @@ impl SplitContract { save_invoice(&env, invoice_id, &invoice); let actor = env.current_contract_address(); append_audit_entry(&env, invoice_id, symbol_short!("refund"), &actor); - events::invoice_refunded(&env, invoice_id); + events::invoice_refunded(&env, invoice_id, total_refunded_amount); events::invoice_state_changed( &env, invoice_id, @@ -11104,8 +11113,10 @@ impl SplitContract { let prev = totals.get(payment.payer.clone()).unwrap_or(0); totals.set(payment.payer.clone(), prev + payment.amount); } + let mut total_refunded_amount: i128 = 0; for (payer, amount) in totals.iter() { token_client.transfer(&env.current_contract_address(), &payer, &amount); + total_refunded_amount += amount; events::payer_refunded(&env, invoice_id, &payer, amount); } @@ -11114,7 +11125,7 @@ impl SplitContract { invoice.completion_time = Some(env.ledger().timestamp()); save_invoice(&env, invoice_id, &invoice); events::dispute_resolved(&env, invoice_id, &admin_addr, &DisputeOutcome::Refunded); - events::invoice_refunded(&env, invoice_id); + events::invoice_refunded(&env, invoice_id, total_refunded_amount); events::invoice_state_changed( &env, invoice_id, diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 78ff5b0..dae7301 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -6,7 +6,7 @@ use super::*; use soroban_sdk::{ testutils::{Address as _, Events as _, Ledger, LedgerInfo}, token::{Client as TokenClient, StellarAssetClient}, - Address, Bytes, BytesN, Env, String, Symbol, TryFromVal, Vec, + Address, Bytes, BytesN, Env, String, Symbol, TryFromVal, Val, Vec, }; use types::{InvoiceOptions, InvoiceOptions2}; @@ -9521,3 +9521,181 @@ fn test_milestones_auto_release() { assert_eq!(tk.balance(&recipient), 100); assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); } + +// --------------------------------------------------------------------------- +// invoice_expired: creator address included in the event payload +// --------------------------------------------------------------------------- + +#[test] +fn test_invoice_expired_event_includes_creator() { + let (env, contract_id, token_id) = setup(); + 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, &100); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 2_000); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); + + env.ledger().set_timestamp(3_000); + c.notify_expired(&id); + + let event_data = env + .events() + .all() + .iter() + .find_map(|(_contract, topics, data)| { + if topic1_is(&env, &topics, "expired") { + Some(data) + } else { + None + } + }) + .expect("invoice_expired event not emitted"); + + let items = Vec::::try_from_val(&env, &event_data).expect("expired data not a tuple"); + assert_eq!(items.len(), 3, "expired event should carry (deadline, funded, creator)"); + let deadline = u64::try_from_val(&env, &items.get(0).unwrap()).unwrap(); + let funded = i128::try_from_val(&env, &items.get(1).unwrap()).unwrap(); + let event_creator = Address::try_from_val(&env, &items.get(2).unwrap()).unwrap(); + + assert_eq!(deadline, 2_000); + assert_eq!(funded, 100); + assert_eq!(event_creator, creator); +} + +// --------------------------------------------------------------------------- +// invoice_refunded: total amount returned to all payers included in the event +// --------------------------------------------------------------------------- + +#[test] +fn test_invoice_refunded_event_includes_total_amount() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer_a = Address::generate(&env); + let payer_b = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer_a, &100); + StellarAssetClient::new(&env, &token_id).mint(&payer_b, &50); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 500, &token_id, 2_000); + c.pay(&payer_a, &id, &100_i128, &0_u64, &false, &false); + c.pay(&payer_b, &id, &50_i128, &0_u64, &false, &false); + + env.ledger().set_timestamp(3_000); + c.notify_expired(&id); + c.refund(&id); + + let event_data = env + .events() + .all() + .iter() + .find_map(|(_contract, topics, data)| { + if topic1_is(&env, &topics, "refunded") { + Some(data) + } else { + None + } + }) + .expect("invoice_refunded event not emitted"); + + let total_amount = i128::try_from_val(&env, &event_data).expect("refunded data not i128"); + assert_eq!(total_amount, 150, "refunded event should total all payer refunds"); +} + +// --------------------------------------------------------------------------- +// Issue #327: funds_unlocked event consistency across release paths +// --------------------------------------------------------------------------- + +fn release_delay_invoice( + env: &Env, + c: &SplitContractClient, + token_id: &Address, + creator: &Address, + recipient: &Address, + amount: i128, + delay_ledgers: u32, +) -> u64 { + let mut options = default_options(env); + options.ext.release_delay_ledgers = Some(delay_ledgers); + c.create_invoice( + creator, + &one_address_vec(env, recipient), + &one_amount_vec(env, amount), + token_id, + &9_999_u64, + &options, + ) +} + +fn has_funds_unlocked_event(env: &Env) -> bool { + env.events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(env, &topics, "fnd_unlk")) +} + +#[test] +#[should_panic(expected = "FundsLockedUntil")] +fn test_release_to_recipient_respects_time_lock() { + let (env, contract_id, token_id) = setup(); + 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); + set_ledger(&env, 100, 1_000); + + let id = release_delay_invoice(&env, &c, &token_id, &creator, &recipient, 500, 5); + c.pay(&payer, &id, &500_i128, &0_u64, &false, &false); + + // Unlock is at sequence 105; still locked at 104. + set_ledger(&env, 104, 1_000); + c.release_to_recipient(&id, &recipient); +} + +#[test] +fn test_funds_unlocked_emitted_at_exact_unlock_on_release_to_recipient() { + let (env, contract_id, token_id) = setup(); + 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, &500); + set_ledger(&env, 100, 1_000); + + let id = release_delay_invoice(&env, &c, &token_id, &creator, &recipient, 500, 5); + c.pay(&payer, &id, &500_i128, &0_u64, &false, &false); + + // Fully funded but time-locked: release_to_recipient's own guard prevented the + // automatic release path, so the invoice stays Pending and no unlock event fires yet. + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + assert!( + !has_funds_unlocked_event(&env), + "funds_unlocked must not fire before the delay elapses" + ); + + // Advance to the exact unlock ledger (funded_at=100 + delay=5) and release. + set_ledger(&env, 105, 1_000); + c.release_to_recipient(&id, &recipient); + + assert_eq!(tk.balance(&recipient), 500); + assert!( + has_funds_unlocked_event(&env), + "release_to_recipient must emit funds_unlocked at the exact moment the lock expires, \ + matching release_invoice's behavior" + ); +}