diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index ea5a5ed..8315887 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1,3 +1,4 @@ +use soroban_sdk::{symbol_short, Address, Bytes, Env, Symbol, Vec}; use soroban_sdk::{symbol_short, Address, Env, Vec, String}; use crate::types::TimelockAction; @@ -267,6 +268,14 @@ pub fn pending_payout_claimed(env: &Env, invoice_id: u64, recipient: &Address, a ); } +/// 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), /// Emitted when an emergency withdrawal is executed. /// Topics: (split, emrg_wd) /// Data: (token, destination, amount) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 4322fbc..fedccdd 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -1008,6 +1008,33 @@ impl SplitContract { /// * `new_limit` - The new daily spending limit (must be >= 0) pub fn set_self_limit(env: Env, creator: Address, new_limit: i128) { creator.require_auth(); + events::monitor_event( + &env, + Symbol::new(&env, "create_invoice"), + 0, + &creator, + env.ledger().timestamp(), + ); + Self::_create_invoice_inner( + &env, + creator, + recipients, + amounts, + token, + deadline, + options.co_creators, + options.allow_early_withdrawal, + options.bonus_pool, + options.bonus_max_payers, + options.prerequisite_id, + options.tranches, + options.co_signers, + options.required_signatures, + options.penalty_bps.unwrap_or(0), + options.penalty_deadline.unwrap_or(0), + options.min_funding_bps.unwrap_or(0), + ) + } assert!(new_limit >= 0, "self limit must be non-negative"); let current_limit: i128 = env @@ -1160,6 +1187,19 @@ impl SplitContract { /// Only the designated arbiter may call this. pub fn resolve_dispute(env: Env, invoice_id: u64, arbiter: Address, resolution: ResolveAction) { 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); + } + + fn _pay(env: &Env, payer: &Address, invoice_id: u64, amount: i128, nonce: u64, auto_convert: bool) { + let mut invoice = load_invoice(env, invoice_id); arbiter.require_auth(); let mut invoice = load_invoice(&env, invoice_id); @@ -1302,6 +1342,22 @@ impl SplitContract { // Issue #4: creator whitelist // ----------------------------------------------------------------------- + /// Release funds to recipients. + /// + /// For tranche invoices, only distributes tranches whose timestamp ≤ now. + /// Blocks with "prerequisite not released" until the prerequisite invoice is Released. + /// If an approver is set, requires the invoice to be approved first (issue #25). + pub fn release(env: Env, invoice_id: u64) { + 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(), + ); /// Add an address to the creator whitelist. Requires admin auth. /// When the whitelist is non-empty, only listed addresses may call create_invoice(). pub fn whitelist_creator(env: Env, admin: Address, address: Address) { @@ -6079,6 +6135,13 @@ impl SplitContract { payer.require_auth(); 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.allow_early_withdrawal, "early withdrawal not allowed"); assert!(!invoice.disputed, "invoice is disputed"); diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 595dea2..0d51ef4 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -2670,6 +2670,36 @@ fn test_min_funding_bps_allows_release_above_threshold() { } // --------------------------------------------------------------------------- +// 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); // Issue #85: generate_payment_proof // --------------------------------------------------------------------------- @@ -2743,6 +2773,25 @@ fn test_stage_release_3_stages() { 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); StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); env.ledger().set_timestamp(1_000); @@ -2867,6 +2916,48 @@ fn test_stage_release_not_fully_funded_panics() { 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 mut stages: Vec = Vec::new(&env); stages.push_back(10_000u32); @@ -3194,6 +3285,18 @@ fn test_analytics_refund_increments_counter() { 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"); let invoice_amount = 200i128; let id = make_invoice(&env, &c, &creator, &recipient, invoice_amount, &token_id, 2_000);