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
66 changes: 30 additions & 36 deletions contracts/split/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>, 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<soroban_sdk::String>) {
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<u64>) {
/// 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<Address>) {
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")),
(),
);
}

Expand Down
118 changes: 89 additions & 29 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ use types::{
TimelockAction, Tranche, TreasuryRecord,
};

const SHARD_COUNT: u64 = 8;

// ---------------------------------------------------------------------------
// Storage key helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Address, AdminRole> = env
Expand All @@ -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<Address, AdminRole> = 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<Symbol> = env
Expand Down Expand Up @@ -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),
})
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
});

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

Expand Down
Loading