Skip to content
Merged
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
9 changes: 9 additions & 0 deletions contracts/split/src/events.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down
103 changes: 103 additions & 0 deletions contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<u32> = Vec::new(&env);
stages.push_back(10_000u32);

Expand Down Expand Up @@ -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);

Expand Down
Loading