From 6413bbbb8d3ee24e1264a56755d362114d667dc5 Mon Sep 17 00:00:00 2001 From: Idaonoli Date: Fri, 26 Jun 2026 22:27:39 +0000 Subject: [PATCH] feat: add invoice event replay (#232) and emergency withdrawal (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves two issues and cleans up pre-existing compilation errors introduced by earlier PRs that left duplicate struct fields and function definitions throughout the codebase. Issue #232 — Invoice payment event replay for indexer recovery: - Add replay_invoice_events(invoice_id) to lib.rs. Callable by anyone, pure read+emit with no state mutation. Re-emits invoice_created, one payment_received per historical payment in order, and the terminal status event (invoice_released / invoice_refunded) when the invoice is no longer Pending. All replayed events carry a fourth `replay` topic so indexers can distinguish them from live events and avoid double-counting. - Add replay_invoice_created, replay_payment_received, replay_invoice_released, replay_invoice_refunded to events.rs. Issue #233 — Contract-wide emergency withdrawal: - Add EmergencyWithdraw(Address, Address) variant to TimelockAction. - Add request_emergency_withdraw(admin, token, destination) to lib.rs. Requires SuperAdmin auth and the contract to already be paused. Queues a timelocked action and returns the action_id. - Handle EmergencyWithdraw in execute_action with a mandatory 7-day minimum delay independent of the configured timelock_secs, plus a check that the contract is still paused at execution time. Transfers the full token balance to destination and emits emergency_withdrawal_executed (token, destination, amount). - Add emergency_withdrawal_executed to events.rs. Pre-existing compilation fixes: - types.rs: remove duplicate fields (min_funding_amount, priorities, admin_frozen) from Invoice struct; add missing creation_timestamp and min_payment_increment to InvoiceExt2 so they round-trip through storage; wire external_prerequisite through Invoice split/assemble/ from_legacy; add EmergencyWithdraw to TimelockAction. - events.rs: remove eight duplicate function definitions that were added by a prior PR alongside the originals. - lib.rs: remove duplicate SHARD_COUNT constant, two spurious require_admin definitions, duplicate admin_frozen assignment in clone_invoice, and update all InvoiceExt2 default literals to include creation_timestamp/min_payment_increment. - test.rs: add missing external_prerequisite: None to invoice_options. Tests added: - test_replay_invoice_events_pending: verifies 2 replay events emitted for a pending invoice (created + 1 payment). - test_replay_invoice_events_released: verifies 4 replay events for a fully-funded and released invoice (created + 2 payments + released). - test_emergency_withdraw_blocked_when_unpaused: confirms request panics when contract is not paused. - test_emergency_withdraw_blocked_before_7_days: confirms execute panics when called before the 7-day delay elapses. - test_emergency_withdraw_succeeds_after_7_days: confirms full balance is transferred to destination after pause + 7-day delay. --- contracts/split/src/events.rs | 66 ++++++++---------- contracts/split/src/lib.rs | 118 +++++++++++++++++++++++-------- contracts/split/src/test.rs | 127 ++++++++++++++++++++++++++++++++++ contracts/split/src/types.rs | 21 ++++-- 4 files changed, 261 insertions(+), 71 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index a6b90e2..e6c2c2d 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -267,59 +267,53 @@ pub fn pending_payout_claimed(env: &Env, invoice_id: u64, recipient: &Address, a ); } -pub fn nft_gate_set(env: &Env, contract: &Option
, admin: &Address) { - env.events().publish( - (symbol_short!("split"), symbol_short!("nft_set")), - (contract.clone(), admin.clone()), - ); -} - -pub fn action_queued(env: &Env, action_id: u64, action: &TimelockAction, admin: &Address) { - env.events().publish( - (symbol_short!("split"), symbol_short!("tl_queue"), action_id), - (action.clone(), admin.clone()), - ); -} - -pub fn action_executed(env: &Env, action_id: u64, action: &TimelockAction) { - env.events().publish( - (symbol_short!("split"), symbol_short!("tl_exec"), action_id), - action.clone(), - ); -} - -pub fn action_cancelled(env: &Env, action_id: u64, action: &TimelockAction, admin: &Address) { +/// Emitted when an emergency withdrawal is executed. +/// Topics: (split, emrg_wd) +/// Data: (token, destination, amount) +pub fn emergency_withdrawal_executed(env: &Env, token: &Address, destination: &Address, amount: i128) { env.events().publish( - (symbol_short!("split"), symbol_short!("tl_cncl"), action_id), - (action.clone(), admin.clone()), + (symbol_short!("split"), symbol_short!("emrg_wd")), + (token.clone(), destination.clone(), amount), ); } -pub fn invoice_admin_frozen(env: &Env, invoice_id: u64, admin: &Address, reason: &String) { +/// Replayed invoice_created event tagged with "replay" so indexers can distinguish it. +/// Topics: (split, created, invoice_id, replay) +/// Data: (creator, total, cross_chain_ref) +pub fn replay_invoice_created(env: &Env, invoice_id: u64, creator: &Address, total: i128, cross_chain_ref: &Option) { env.events().publish( - (symbol_short!("split"), symbol_short!("adm_frz"), invoice_id), - (admin.clone(), reason.clone()), + (symbol_short!("split"), symbol_short!("created"), invoice_id, symbol_short!("replay")), + (creator.clone(), total, cross_chain_ref.clone()), ); } -pub fn invoice_admin_unfrozen(env: &Env, invoice_id: u64, admin: &Address) { +/// Replayed payment_received event tagged with "replay". +/// Topics: (split, paid, invoice_id, replay) +/// Data: (payer, amount) +pub fn replay_payment_received(env: &Env, invoice_id: u64, payer: &Address, amount: i128) { env.events().publish( - (symbol_short!("split"), symbol_short!("adm_unf"), invoice_id), - admin.clone(), + (symbol_short!("split"), symbol_short!("paid"), invoice_id, symbol_short!("replay")), + (payer.clone(), amount), ); } -pub fn batch_archived(env: &Env, count: u32, ids: &Vec) { +/// Replayed invoice_released event tagged with "replay". +/// Topics: (split, released, invoice_id, replay) +/// Data: recipients +pub fn replay_invoice_released(env: &Env, invoice_id: u64, recipients: &Vec
) { env.events().publish( - (symbol_short!("split"), symbol_short!("bat_arc")), - (count, ids.clone()), + (symbol_short!("split"), symbol_short!("released"), invoice_id, symbol_short!("replay")), + recipients.clone(), ); } -pub fn partial_refund_issued(env: &Env, invoice_id: u64, creator: &Address, bps: u32, amount: i128) { +/// Replayed invoice_refunded event tagged with "replay". +/// Topics: (split, refunded, invoice_id, replay) +/// Data: () +pub fn replay_invoice_refunded(env: &Env, invoice_id: u64) { env.events().publish( - (symbol_short!("split"), symbol_short!("prt_ref"), invoice_id), - (creator.clone(), bps, amount), + (symbol_short!("split"), symbol_short!("refunded"), invoice_id, symbol_short!("replay")), + (), ); } diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 2f11520..7a6f375 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -24,8 +24,6 @@ use types::{ TimelockAction, Tranche, TreasuryRecord, }; -const SHARD_COUNT: u64 = 8; - // --------------------------------------------------------------------------- // Storage key helpers // --------------------------------------------------------------------------- @@ -500,8 +498,10 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { min_payment: 0, min_funding_amount: 0, priorities: Vec::new(env), + creation_timestamp: 0, + min_payment_increment: 0, }); - + // Load compact representation if available if let Some(compact) = env.storage().persistent().get::<_, CompactInvoice>(&invoice_compact_key(id)) { Invoice::from_compact(&compact, core, ext, ext2) @@ -564,16 +564,6 @@ fn require_not_paused(env: &Env) { assert!(!is_paused(env), "contract is paused"); } -fn require_admin(env: &Env) -> Address { - let admin: Address = env - .storage() - .instance() - .get(&admin_key()) - .expect("admin not set"); - admin.require_auth(); - admin -} - fn require_role(env: &Env, admin: &Address, min_role: AdminRole) { admin.require_auth(); let admins: Map = env @@ -595,20 +585,6 @@ fn require_role(env: &Env, admin: &Address, min_role: AdminRole) { } } -fn require_admin(env: &Env) -> Address { - let caller = env.current_contract_address(); - let admins: Map = env - .storage() - .instance() - .get(&admins_key()) - .expect("admins not set"); - assert!( - admins.contains_key(caller.clone()), - "caller is not an admin" - ); - caller -} - fn require_fn_not_paused(env: &Env, name: &Symbol) { require_not_paused(env); let paused_fns: Vec = env @@ -1488,6 +1464,8 @@ impl SplitContract { min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + creation_timestamp: 0, + min_payment_increment: 0, }) }); let audit_log: Vec = get_audit_log(&env, invoice_id); @@ -1623,12 +1601,27 @@ impl SplitContract { env.storage() .persistent() .set(&creator_self_limit_key(creator), new_limit); - + // Reset daily usage when limit is raised env.storage() .persistent() .set(&creator_self_used_key(creator), &0i128); } + TimelockAction::EmergencyWithdraw(token, destination) => { + // Issue #233: Enforce a minimum 7-day delay independently of + // the contract's configured timelock_secs. + const EMERGENCY_MIN_DELAY: u64 = 7 * 24 * 60 * 60; + assert!( + now >= queued.queued_at.saturating_add(EMERGENCY_MIN_DELAY), + "emergency withdrawal requires a 7-day delay" + ); + assert!(is_paused(&env), "contract must still be paused for emergency withdrawal"); + let token_client = token::Client::new(&env, token); + let amount = token_client.balance(&env.current_contract_address()); + assert!(amount > 0, "no balance to withdraw"); + token_client.transfer(&env.current_contract_address(), destination, &amount); + events::emergency_withdrawal_executed(&env, token, destination, amount); + } } queued.executed = true; @@ -2197,6 +2190,8 @@ impl SplitContract { allowed_callers: None, admin_frozen: false, min_funding_amount: 0, + creation_timestamp: env.ledger().timestamp(), + min_payment_increment: 0, external_prerequisite, }; @@ -2577,8 +2572,8 @@ impl SplitContract { creation_timestamp: env.ledger().timestamp(), min_payment_increment: source.min_payment_increment, min_funding_amount: source.min_funding_amount, - admin_frozen: source.admin_frozen, require_kyc: source.require_kyc, + external_prerequisite: source.external_prerequisite.clone(), }; save_invoice(&env, id, &new_invoice); @@ -6448,6 +6443,7 @@ impl SplitContract { admin_frozen: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + creation_timestamp: 0, min_payment_increment: 0, }); env.storage().instance().set(&invoice_key(id), &core); @@ -6749,6 +6745,70 @@ impl SplitContract { /// /// # Returns /// The minimum TTL in ledgers across the invoice's entries, or 0 if archived. + /// Replay all historical events for an invoice so an indexer can resync + /// from a single call. Replayed events carry a `replay` topic marker to + /// distinguish them from live events and prevent double-counting. + /// + /// Emits: + /// - one `invoice_created` (replay) event + /// - one `payment_received` (replay) event per entry in `invoice.payments`, in order + /// - one terminal status event (`invoice_released` or `invoice_refunded`, replay) + /// if the invoice is no longer Pending + /// + /// Pure read + emit — no state mutation, callable by anyone. + pub fn replay_invoice_events(env: Env, invoice_id: u64) { + let invoice = load_invoice(&env, invoice_id); + + let total: i128 = invoice.amounts.iter().sum(); + events::replay_invoice_created(&env, invoice_id, &invoice.creator, total, &invoice.cross_chain_ref); + + for payment in invoice.payments.iter() { + events::replay_payment_received(&env, invoice_id, &payment.payer, payment.amount); + } + + match invoice.status { + InvoiceStatus::Released => { + events::replay_invoice_released(&env, invoice_id, &invoice.recipients); + } + InvoiceStatus::Refunded => { + events::replay_invoice_refunded(&env, invoice_id); + } + _ => {} + } + } + + /// Queue an emergency withdrawal of all custodied funds for `token` to + /// `destination`. Requires: + /// - the contract to already be paused + /// - SuperAdmin auth + /// + /// Execution via `execute_action` is gated by a mandatory 7-day minimum + /// delay regardless of the configured `timelock_secs`. + /// + /// Returns the `action_id` of the queued action. + pub fn request_emergency_withdraw(env: Env, admin: Address, token: Address, destination: Address) -> u64 { + require_role(&env, &admin, AdminRole::SuperAdmin); + assert!(is_paused(&env), "contract must be paused before requesting emergency withdrawal"); + + let action = TimelockAction::EmergencyWithdraw(token, destination); + let mut counter: u64 = env + .storage() + .persistent() + .get(&timelock_action_counter_key()) + .unwrap_or(0u64); + counter = counter.checked_add(1).expect("action counter overflow"); + let now = env.ledger().timestamp(); + let queued = QueuedAction { + action: action.clone(), + queued_at: now, + executed: false, + }; + env.storage().persistent().set(&timelock_action_key(counter), &queued); + env.storage().persistent().set(&timelock_action_counter_key(), &counter); + events::action_queued(&env, counter, &action, &admin); + counter + } + pub fn get_invoice_storage_footprint(env: Env, invoice_id: u64) -> u32 { let storage = env.storage(); diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 7d66374..a7a3ad0 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -126,6 +126,7 @@ fn invoice_options( priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + external_prerequisite: None, } } @@ -5512,3 +5513,129 @@ fn test_refund_with_insufficient_balance() { assert_eq!(tk.balance(&payer2), 1000); // Full refund: 800 + 200 assert_eq!(tk.balance(&payer3), 800); // Partial refund: 700 + 100 } + +// --------------------------------------------------------------------------- +// Issue #232 — Invoice payment event replay +// --------------------------------------------------------------------------- + +#[test] +fn test_replay_invoice_events_pending() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let payer = 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, 300, &token_id, 9_999); + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); + + // Invoice is still Pending (100/300 funded) + let before = env.events().all().len(); + c.replay_invoice_events(&id); + let replayed = env.events().all().len() - before; + + // invoice_created (replay) + 1× payment_received (replay) = 2; no terminal event + assert_eq!(replayed, 2); +} + +#[test] +fn test_replay_invoice_events_released() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let payer1 = Address::generate(&env); + let payer2 = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer1, &500); + StellarAssetClient::new(&env, &token_id).mint(&payer2, &500); + env.ledger().set_timestamp(1_000); + + let id = make_invoice(&env, &c, &creator, &recipient, 300, &token_id, 9_999); + c.pay(&payer1, &id, &100_i128, &0_u64, &false, &false); + c.pay(&payer2, &id, &200_i128, &0_u64, &false, &false); + // Invoice is Released (300/300) + + let before = env.events().all().len(); + c.replay_invoice_events(&id); + let replayed = env.events().all().len() - before; + + // invoice_created (replay) + 2× payment_received (replay) + invoice_released (replay) = 4 + assert_eq!(replayed, 4); +} + +// --------------------------------------------------------------------------- +// Issue #233 — Contract-wide emergency withdrawal +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "contract must be paused")] +fn test_emergency_withdraw_blocked_when_unpaused() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let destination = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + + // Contract is not paused — must panic + c.request_emergency_withdraw(&admin, &token_id, &destination); +} + +#[test] +#[should_panic(expected = "emergency withdrawal requires a 7-day delay")] +fn test_emergency_withdraw_blocked_before_7_days() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let destination = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.pause(&admin); + + let action_id = c.request_emergency_withdraw(&admin, &token_id, &destination); + + // Only a few seconds have passed — must panic + env.ledger().set_timestamp(5_000); + c.execute_action(&action_id); +} + +#[test] +fn test_emergency_withdraw_succeeds_after_7_days() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let destination = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + + // Seed the contract with custodied funds + StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); + assert_eq!(tk.balance(&contract_id), 500); + + c.pause(&admin); + let action_id = c.request_emergency_withdraw(&admin, &token_id, &destination); + + // Advance past the mandatory 7-day delay + const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60; + env.ledger().set_timestamp(1_000 + SEVEN_DAYS + 1); + c.execute_action(&action_id); + + assert_eq!(tk.balance(&destination), 500); + assert_eq!(tk.balance(&contract_id), 0); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 87d85f8..9f15145 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -366,6 +366,8 @@ pub struct InvoiceExt2 { pub min_payment: i128, pub min_funding_amount: i128, pub priorities: Vec, + pub creation_timestamp: u64, + pub min_payment_increment: i128, } /// Issue #211: A single escalating penalty tier (seconds_after_deadline, bps). @@ -384,6 +386,8 @@ pub enum TimelockAction { SetPlatformFee(u32), /// Issue #241: Creator self-imposed limit raise request. RaiseCreatorSelfLimit(Address, i128), + /// Issue #233: Emergency withdrawal of all custodied funds for a given token. + EmergencyWithdraw(Address, Address), } /// A queued timelock action with metadata. @@ -482,12 +486,8 @@ pub struct Invoice { pub creation_timestamp: u64, /// Issue #201: minimum payment increment - reject payments below this threshold. pub min_payment_increment: i128, - /// Minimum funding amount required before invoice can be released. - pub min_funding_amount: i128, - /// Issue: per-recipient release priorities (parallel to recipients); empty = no ordering. - pub priorities: Vec, - /// Issue #188: admin can freeze an invoice. - pub admin_frozen: bool, + /// Issue #242: External prerequisite - (contract_address, invoice_id) on different contract instance. + pub external_prerequisite: Option<(Address, u64)>, } impl Invoice { @@ -558,6 +558,7 @@ impl Invoice { penalty_tiers: self.penalty_tiers, allowed_callers: self.allowed_callers, refund_grace_secs: self.refund_grace_secs, + external_prerequisite: self.external_prerequisite, }, InvoiceExt2 { notification_contract: self.notification_contract, @@ -573,6 +574,8 @@ impl Invoice { min_payment: self.min_payment, min_funding_amount: self.min_funding_amount, priorities: self.priorities, + creation_timestamp: self.creation_timestamp, + min_payment_increment: self.min_payment_increment, }, ) } @@ -641,6 +644,7 @@ impl Invoice { penalty_tiers: ext.penalty_tiers, allowed_callers: ext.allowed_callers, refund_grace_secs: ext.refund_grace_secs, + external_prerequisite: ext.external_prerequisite, notification_contract: ext2.notification_contract, overflow_behavior: ext2.overflow_behavior, cross_chain_ref: ext2.cross_chain_ref, @@ -654,6 +658,8 @@ impl Invoice { min_payment: ext2.min_payment, min_funding_amount: ext2.min_funding_amount, priorities: ext2.priorities, + creation_timestamp: ext2.creation_timestamp, + min_payment_increment: ext2.min_payment_increment, } } } @@ -866,6 +872,9 @@ impl Invoice { parent_invoice_id: None, priorities: Vec::new(env), require_kyc: false, + creation_timestamp: 0, + min_payment_increment: 0, + external_prerequisite: None, } } }