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
41 changes: 38 additions & 3 deletions contracts/split/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
27 changes: 20 additions & 7 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand All @@ -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.
Expand All @@ -8350,13 +8358,15 @@ 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<Address> = Vec::new(&env);
for addr in old_list.iter() {
if addr != contributor {
new_list.push_back(addr);
}
}
became_disabled = new_list.is_empty();
invoice.contributor_allowlist = if new_list.is_empty() {
None
} else {
Expand All @@ -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);
}
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
158 changes: 158 additions & 0 deletions contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<i128> = 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();
Expand Down