Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions contracts/split/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ pub fn invoice_released(env: &Env, invoice_id: u64, recipients: &Vec<Address>) {

/// Emitted when an invoice is refunded after deadline.
/// Topics: (split, refunded, invoice_id)
/// Data: total_amount (sum returned to all payers, excluding any creator payout)
pub fn invoice_refunded(env: &Env, invoice_id: u64, total_amount: i128) {
/// Data: (event_seq)
pub fn invoice_refunded(env: &Env, invoice_id: u64) {
let event_seq = next_seq(env, invoice_id);
Expand All @@ -118,6 +120,7 @@ pub fn invoice_refunded(env: &Env, invoice_id: u64) {
symbol_short!("refunded"),
invoice_id,
),
total_amount,
(event_seq,),
);
}
Expand All @@ -135,6 +138,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, 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, 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);
Expand Down
21 changes: 16 additions & 5 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3683,7 +3683,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,
Expand Down Expand Up @@ -8630,6 +8630,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);
}
}

Expand Down Expand Up @@ -10672,7 +10675,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,
Expand Down Expand Up @@ -10765,7 +10768,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,
Expand Down Expand Up @@ -10882,7 +10891,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,
Expand Down Expand Up @@ -13889,8 +13898,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);
}

Expand All @@ -13899,7 +13910,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,
Expand Down
179 changes: 179 additions & 0 deletions contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use super::*;
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;
Expand Down Expand Up @@ -8017,3 +8018,181 @@ fn test_cancel_invoice_on_deleted_invoice_panics() {
c.delete_invoice(&creator, &id);
c.cancel_invoice(&creator, &id);
}

// ---------------------------------------------------------------------------
// 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::<Val>::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"
);
}
Loading