From eb596d8bf0208f60615a9380071450b5d2852929 Mon Sep 17 00:00:00 2001 From: Umar faruk Date: Wed, 30 Sep 2026 07:10:55 +0000 Subject: [PATCH] feat: propose/execute rescue with surplus-only check and timelock - Add PendingRescue(Address) and TokenLiabilities(Address) DataKey variants - Add RescueTimelockNotElapsed=36, NoPendingRescue=37, RescueLiabilityConflict=38, RescueAmountExceedsSurplus=39 error variants - Add PendingRescueRecord contracttype struct (token, to, amount, eta, proposed_ledger) - Add RESCUE_TIMELOCK_DELAY constant (48 h, mirrors ADMIN_TIMELOCK_DELAY) - Instrument register_solver_inner to increment TokenLiabilities on bond deposit - Instrument deregister_solver refund loop to decrement TokenLiabilities - Partial: remaining call sites (withdraw_bond, slash_solver, begin_fill, release_fill, resolve_dispute) and propose/execute_rescue entrypoints still to be wired up Relates to: #265, #35 --- intent_settlement/src/lib.rs | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 71c0018..2908880 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -93,6 +93,13 @@ const MAX_ROUTE_ENTRIES: u32 = 20; /// (#116). const ADMIN_TIMELOCK_DELAY: u64 = 172_800; // 48 hours +/// Minimum delay between `propose_rescue` and `execute_rescue`. Matches +/// `ADMIN_TIMELOCK_DELAY` so the community has the same 48-hour window to +/// notice and react to a pending rescue as to any other admin-key action. +/// A rescue proposal also emits a `rescue_proposed` event immediately so +/// off-chain monitors have advance notice. +const RESCUE_TIMELOCK_DELAY: u64 = ADMIN_TIMELOCK_DELAY; // 48 hours + // ── Defaults seeded into `ProtocolConfig` by `initialize`, and the fallback // `load_config` returns for contracts deployed before the configurable-params // feature existed. They mirror the historical compile-time constants above. @@ -402,6 +409,24 @@ pub enum DataKey { /// touches this key, so proof-gating is fully opt-in and defaults off /// exactly like `DstAllowlistEnabled`. ProofRegistry, + + /// **Persistent storage.** Pending `propose_rescue` record for `token`: + /// stores the target address, the amount, the ledger timestamp at which + /// `execute_rescue` may apply it, and the ledger sequence at which the + /// proposal was created (for the same-ledger conflict guard). + /// Cleared by `execute_rescue` or a future `cancel_rescue` call. + PendingRescue(Address), + + /// **Persistent storage.** Per-`token` sum of all outstanding protocol + /// liabilities denominated in that token: solver bonds + escrowed fill + /// amounts. Maintained incrementally — incremented whenever a bond is + /// added (`register_solver`, `register_solver_with_token`) or escrow is + /// created (`begin_fill`), decremented when bonds are released + /// (`withdraw_bond`, `withdraw_bond_token`, `deregister_solver`, + /// `slash_solver`) or escrow is released (`release_fill`, + /// `resolve_dispute`). Absent until the first liability in that token + /// is created; `get(&key).unwrap_or(0)` handles that safely. + TokenLiabilities(Address), } // ─── Data Structs ───────────────────────────────────────────────────────────── @@ -585,6 +610,33 @@ pub struct ReputationSnapshot { pub total_volume: i128, } +/// A pending `propose_rescue` record. Stored under +/// `DataKey::PendingRescue(token)` in persistent storage. Consumed (and +/// removed) by `execute_rescue`. +/// +/// The `proposed_ledger` field carries the Soroban ledger sequence number at +/// the time the proposal was made, so `execute_rescue` can enforce the +/// same-ledger conflict guard — if the execution lands in the same ledger +/// (sequence) as the proposal there has been no intervening block and the +/// `TokenLiabilities` snapshot taken at proposal time may already be stale. +#[contracttype] +#[derive(Clone)] +pub struct PendingRescueRecord { + /// The token the rescue will transfer. + pub token: Address, + /// Recipient of the rescued funds. + pub to: Address, + /// Amount to transfer (must be ≤ `balance − liabilities` at both proposal + /// and execution time). + pub amount: i128, + /// Ledger timestamp at which `execute_rescue` becomes callable. + pub eta: u64, + /// Soroban ledger sequence at which the proposal was created, used by the + /// same-ledger conflict guard to prevent a proposal and its execution + /// from landing in the same block. + pub proposed_ledger: u32, +} + // ─── Errors ─────────────────────────────────────────────────────────────────── #[contracterror] @@ -756,6 +808,36 @@ pub enum Error { /// submitting `user`. Self-referral is rejected to prevent a user from /// gaming the referral programme by naming their own address. SelfReferral = 35, + + // ── Rescue timelock errors (issue #265 replacement) ────────────────────── + + /// `execute_rescue` was called before the `RESCUE_TIMELOCK_DELAY` since + /// the matching `propose_rescue` call has elapsed. Mirrors the pattern + /// used for `TimelockNotElapsed` on admin-transfer and dst-token changes. + RescueTimelockNotElapsed = 36, + + /// `execute_rescue` (or `cancel_rescue`) was called with no matching + /// `PendingRescue` record for the given token in persistent storage. + /// Either `propose_rescue` was never called for this token, or the + /// previous rescue was already executed or cancelled. + NoPendingRescue = 37, + + /// `execute_rescue` or `propose_rescue` was called in the same ledger + /// as a liability-changing operation on the same token. The per-token + /// `TokenLiabilities` counter cannot be atomically snapshotted across + /// ledger boundaries within a single transaction, so same-ledger + /// proposals/executions are rejected to prevent a race where a bond + /// deposit and a rescue land in the same ledger, giving the rescue a + /// stale surplus view. + RescueLiabilityConflict = 38, + + /// `execute_rescue` was called but the contract's current balance in + /// `token` minus `TokenLiabilities(token)` is less than the requested + /// `amount`. The surplus available for rescue has shrunk since + /// `propose_rescue` (e.g. a new bond was posted or an escrow was + /// created in the interim), so the rescue is rejected rather than + /// drawing on funds needed by the protocol. + RescueAmountExceedsSurplus = 39, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -1630,6 +1712,10 @@ impl IntentSettlement { .instance() .set(&DataKey::TotalBonded, &(total_bonded + bond_amount)); + // Increment per-token liabilities so `compute_token_liabilities` stays + // accurate for the rescue-surplus check. + Self::adjust_token_liabilities(&env, &bond_token, bond_amount); + if is_new_solver { let total: u32 = env .storage() @@ -1728,6 +1814,9 @@ impl IntentSettlement { let mut total_default_refund = 0i128; for i in 0..refunds.len() { let (t, amt) = refunds.get(i).unwrap(); + // Decrement per-token liabilities before the transfer so the + // surplus view is always conservative (never over-counts available). + Self::adjust_token_liabilities(&env, &t, -amt); token::Client::new(&env, &t).transfer( &env.current_contract_address(), &solver,