From 0573bf7b27805410aeac881d8cb4266a9832ac59 Mon Sep 17 00:00:00 2001 From: Noah Yakubu Date: Sat, 27 Jun 2026 02:56:58 +0100 Subject: [PATCH] feat(events): add monitor_event for invoice contract observability Closes #180 - Add monitor_event() to events.rs with topic (symbol_short!("monitor"), function_name) and data (invoice_id, actor, ledger_timestamp) - Emit monitor_event as the first operation in create_invoice, pay, release, and refund - Add four tests verifying monitor event is emitted with correct function name for each entry point --- contracts/split/src/events.rs | 13 +++- contracts/split/src/lib.rs | 28 ++++++++ contracts/split/src/test.rs | 124 ++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 27c06ae..5ec4c60 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{symbol_short, Address, Bytes, Env, Vec}; +use soroban_sdk::{symbol_short, Address, Bytes, Env, Symbol, Vec}; /// Emitted when a new invoice is created. pub fn invoice_created(env: &Env, invoice_id: u64, creator: &Address, total: i128, metadata: &Option) { @@ -45,3 +45,14 @@ pub fn recipient_added(env: &Env, invoice_id: u64, recipient: &Address, amount: (recipient.clone(), amount), ); } + +/// Emitted at the start of every public entry point for real-time contract health observability. +/// +/// Topic: `(symbol_short!("monitor"), function_name)` +/// Data: `(invoice_id, actor_address, ledger_timestamp)` +pub fn monitor_event(env: &Env, function: Symbol, invoice_id: u64, actor: &Address, timestamp: u64) { + env.events().publish( + (symbol_short!("monitor"), function), + (invoice_id, actor.clone(), timestamp), + ); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 90d6d79..70ed40a 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -325,6 +325,13 @@ impl SplitContract { ) -> u64 { require_not_paused(&env); creator.require_auth(); + events::monitor_event( + &env, + Symbol::new(&env, "create_invoice"), + 0, + &creator, + env.ledger().timestamp(), + ); Self::_create_invoice_inner( &env, creator, @@ -621,6 +628,13 @@ impl SplitContract { pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128, nonce: u64, auto_convert: bool) { require_not_paused(&env); payer.require_auth(); + events::monitor_event( + &env, + Symbol::new(&env, "pay"), + invoice_id, + &payer, + env.ledger().timestamp(), + ); Self::_pay(&env, &payer, invoice_id, amount, nonce, auto_convert); } @@ -785,6 +799,13 @@ impl SplitContract { require_not_paused(&env); let caller = env.current_contract_address(); let mut invoice = load_invoice(&env, invoice_id); + events::monitor_event( + &env, + Symbol::new(&env, "release"), + invoice_id, + &caller, + env.ledger().timestamp(), + ); assert!(!invoice.frozen, "invoice is frozen"); assert!( @@ -1113,6 +1134,13 @@ impl SplitContract { pub fn refund(env: Env, invoice_id: u64) { require_not_paused(&env); let mut invoice = load_invoice(&env, invoice_id); + events::monitor_event( + &env, + Symbol::new(&env, "refund"), + invoice_id, + &env.current_contract_address(), + env.ledger().timestamp(), + ); assert!( invoice.status == InvoiceStatus::Pending, diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 14804fa..0c100a4 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -2330,3 +2330,127 @@ fn test_min_funding_bps_allows_release_above_threshold() { assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); assert_eq!(tk.balance(&recipient), 900); } + +// --------------------------------------------------------------------------- +// Monitoring hooks tests (issue #180) +// --------------------------------------------------------------------------- + +#[test] +fn test_monitor_event_on_create_invoice() { + use soroban_sdk::testutils::Events; + + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + make_invoice(&env, &c, &creator, &recipient, 100, &token_id, 2_000); + + // At least one monitor event must have been emitted for "create_invoice". + let found = env.events().all().iter().any(|(_, topics, _)| { + topics.get(0) == Some(soroban_sdk::Val::from(symbol_short!("monitor"))) + && topics.get(1) + == Some(soroban_sdk::Val::from(Symbol::new(&env, "create_invoice"))) + }); + assert!(found, "monitor event for create_invoice not found"); +} + +#[test] +fn test_monitor_event_on_pay() { + use soroban_sdk::testutils::Events; + + 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, &200); + 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); + + let found = env.events().all().iter().any(|(_, topics, _)| { + topics.get(0) == Some(soroban_sdk::Val::from(symbol_short!("monitor"))) + && topics.get(1) == Some(soroban_sdk::Val::from(Symbol::new(&env, "pay"))) + }); + assert!(found, "monitor event for pay not found"); +} + +#[test] +fn test_monitor_event_on_release() { + use soroban_sdk::testutils::Events; + + 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); + env.ledger().set_timestamp(1_000); + + // Use a co-signer to prevent auto-release so we can call release() explicitly. + let co_signer = Address::generate(&env); + let mut co_signers = soroban_sdk::Vec::new(&env); + co_signers.push_back(co_signer.clone()); + + let mut recipients = soroban_sdk::Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = soroban_sdk::Vec::new(&env); + amounts.push_back(100_i128); + + let opts = InvoiceOptions { + co_creators: soroban_sdk::Vec::new(&env), + allow_early_withdrawal: false, + bonus_pool: 0, + bonus_max_payers: 0, + prerequisite_id: None, + tranches: soroban_sdk::Vec::new(&env), + co_signers, + required_signatures: 1, + penalty_bps: None, + penalty_deadline: None, + min_funding_bps: None, + }; + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999_u64, &opts); + + c.pay(&payer, &id, &100_i128, &0_u64); + c.sign_release(&id, &co_signer); + c.release(&id); + + let found = env.events().all().iter().any(|(_, topics, _)| { + topics.get(0) == Some(soroban_sdk::Val::from(symbol_short!("monitor"))) + && topics.get(1) == Some(soroban_sdk::Val::from(Symbol::new(&env, "release"))) + }); + assert!(found, "monitor event for release not found"); +} + +#[test] +fn test_monitor_event_on_refund() { + use soroban_sdk::testutils::Events; + + 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); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 2_000); + c.pay(&payer, &id, &100_i128, &0_u64); + + // Advance past deadline so refund is allowed. + env.ledger().set_timestamp(3_000); + c.refund(&id); + + let found = env.events().all().iter().any(|(_, topics, _)| { + topics.get(0) == Some(soroban_sdk::Val::from(symbol_short!("monitor"))) + && topics.get(1) == Some(soroban_sdk::Val::from(Symbol::new(&env, "refund"))) + }); + assert!(found, "monitor event for refund not found"); +}