diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs
index 3c1f205..ea5a5ed 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 350df86..4322fbc 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
// ---------------------------------------------------------------------------
@@ -501,9 +499,11 @@ 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,
substitute_recipient_approvals: Vec::new(env),
});
-
+
// 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)
@@ -566,16 +566,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
@@ -597,20 +587,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
@@ -1490,6 +1466,8 @@ impl SplitContract {
min_payment: 0,
min_funding_amount: 0,
priorities: Vec::new(&env),
+ creation_timestamp: 0,
+ min_payment_increment: 0,
substitute_recipient_approvals: Vec::new(&env),
})
});
@@ -1626,12 +1604,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;
@@ -2202,6 +2195,8 @@ impl SplitContract {
allowed_callers: None,
admin_frozen: false,
min_funding_amount: 0,
+ creation_timestamp: env.ledger().timestamp(),
+ min_payment_increment: 0,
fallback_action,
external_prerequisite,
};
@@ -2583,8 +2578,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);
@@ -6648,6 +6643,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,
substitute_recipient_approvals: Vec::new(&env),
});
@@ -6950,6 +6946,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 e7530c8..595dea2 100644
--- a/contracts/split/src/test.rs
+++ b/contracts/split/src/test.rs
@@ -127,6 +127,7 @@ fn invoice_options(
priorities: Vec::new(env),
require_kyc: false,
scheduled_release_at: None,
+ external_prerequisite: None,
fallback_action: None,
}
}
@@ -6117,6 +6118,130 @@ fn test_refund_with_insufficient_balance() {
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);
#[test]
#[should_panic(expected = "invoice under dispute")]
fn test_refund_blocked_when_disputed() {
diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs
index 552a876..9366951 100644
--- a/contracts/split/src/types.rs
+++ b/contracts/split/src/types.rs
@@ -369,6 +369,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 #230: co-signers who have approved the pending recipient substitution.
pub substitute_recipient_approvals: Vec,
}
@@ -389,6 +391,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.
@@ -490,12 +494,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 {
@@ -566,6 +566,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,
fallback_action: self.fallback_action,
},
InvoiceExt2 {
@@ -582,6 +583,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,
substitute_recipient_approvals: Vec::new(self.notification_contract.env()),
},
)
@@ -651,6 +654,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,
fallback_action: ext.fallback_action,
notification_contract: ext2.notification_contract,
overflow_behavior: ext2.overflow_behavior,
@@ -665,6 +669,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,
}
}
}
@@ -894,6 +900,9 @@ impl Invoice {
clone_depth: 0,
fallback_action: None,
require_kyc: false,
+ creation_timestamp: 0,
+ min_payment_increment: 0,
+ external_prerequisite: None,
}
}
}