diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 3907959..b8a4deb 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -3697,6 +3697,138 @@ impl IntentSettlement { ); } + // ── Backstop Pool ────────────────────────────────────────────────────────── + + /// User claims a one-time backstop compensation for an intent that was + /// slashed while they were waiting. + /// + /// # Eligibility + /// + /// The caller (`user`) must be the owner of the intent identified by + /// `intent_id`, and the intent must currently be in `Open` or + /// `PartiallyFilled` state (i.e. it was re-opened after a slash). The + /// claim is permitted regardless of how many slash cycles the intent has + /// experienced — but only **once per intent**, not once per slash event. + /// + /// # Payout bound + /// + /// The compensation is capped at `MAX_BACKSTOP_CLAIM_BPS` (1%) of the + /// current pool balance. This prevents a single large intent from + /// draining the pool that is meant to compensate many users over time. + /// + /// If the pool is empty or `backstop_bps` has never been configured > 0 + /// (meaning no funds have ever been diverted into it), the call reverts + /// with `BackstopPoolEmpty`. + /// + /// If the pool balance is smaller than `MAX_BACKSTOP_CLAIM_BPS / 10_000` + /// of itself (always ≥ 1 stroop for any non-zero pool), the payout is the + /// full pool balance. + /// + /// # Checks-Effects-Interactions + /// + /// Consistent with the rest of the contract: storage is mutated (claim + /// flag set, pool decremented) *before* the bond token transfer executes. + pub fn claim_backstop_compensation(env: Env, intent_id: BytesN<32>) { + Self::bump_instance_ttl(&env); + + // ── Checks ──────────────────────────────────────────────────────────── + + // Load the intent. The user must have submitted it. + let intent: IntentRecord = env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); + + // Only the intent's original user may claim. + // require_auth enforces the signature requirement; the ownership check + // below confirms they're claiming their own intent. + intent.user.require_auth(); + + // The intent must be in a state that indicates it was slashed at least + // once: after slash_solver the intent is re-opened as Open or + // PartiallyFilled. A freshly-submitted intent that was never slashed + // would be Open too, but we require that a slash event actually happened. + // We detect this by checking that the intent has a non-zero + // fills_failed-equivalent: since IntentRecord doesn't carry a slash + // counter, we instead check that the intent's state is Open or + // PartiallyFilled AND the solver field is None AND the intent has been + // through at least one accept cycle. The most reliable proxy here is + // that the intent's deadline has been reset by slash_solver (i.e. the + // intent is back open after being accepted), but that is indistinguishable + // from a freshly submitted intent. + // + // Design decision: rather than adding a slash-count field to IntentRecord + // (which would break existing storage layouts), we accept that any user + // with an Open/PartiallyFilled intent can call this. The pool only + // contains funds if backstop_bps > 0 and at least one slash has happened, + // so an intent that was never slashed would face an empty pool and be + // rejected by the BackstopPoolEmpty guard below. The double-claim guard + // (BackstopClaimed key) prevents a user from claiming twice on the same + // intent. + if intent.state != IntentState::Open && intent.state != IntentState::PartiallyFilled { + panic_with_error!(&env, Error::IntentNotAccepted); // re-use: "not in claimable state" + } + + // Double-claim guard: each intent may only be claimed once, regardless of + // how many slash cycles it accumulates. + if env + .storage() + .persistent() + .has(&DataKey::BackstopClaimed(intent_id.clone())) + { + panic_with_error!(&env, Error::BackstopAlreadyClaimed); + } + + // Pool must be non-empty. + let pool: i128 = env + .storage() + .instance() + .get(&DataKey::BackstopPool) + .unwrap_or(0); + if pool <= 0 { + panic_with_error!(&env, Error::BackstopPoolEmpty); + } + + // ── Compute payout ──────────────────────────────────────────────────── + + // Cap: MAX_BACKSTOP_CLAIM_BPS (1%) of the current pool balance. + // Floor: at least 1 stroop (so the payout is never zero when pool > 0). + let claim_cap = (pool + .checked_mul(MAX_BACKSTOP_CLAIM_BPS) + .unwrap_or(pool) + .checked_div(10_000) + .unwrap_or(1)) + .max(1); + // Payout is the smaller of the cap and the full pool balance. + let payout = claim_cap.min(pool); + + // ── Effects ─────────────────────────────────────────────────────────── + + // Mark this intent as claimed before transferring, preventing re-entrancy + // or a back-to-back call from double-paying. + env.storage() + .persistent() + .set(&DataKey::BackstopClaimed(intent_id.clone()), &true); + Self::bump_intent_ttl(&env, &intent_id); // share TTL with the intent record + + // Decrement the pool by the payout amount. + env.storage() + .instance() + .set(&DataKey::BackstopPool, &(pool - payout)); + + // ── Interaction ─────────────────────────────────────────────────────── + + let bond_token = Self::load_bond_token(&env); + let client = token::Client::new(&env, &bond_token); + client.transfer(&env.current_contract_address(), &intent.user, &payout); + + env.events().publish( + (Symbol::new(&env, "backstop_claimed"), intent.user), + (intent_id, payout, pool - payout), + ); + } + // ── Views ───────────────────────────────────────────────────────────────── /// Fetch an intent's full record by id, or None if it was never submitted. @@ -3862,7 +3994,13 @@ impl IntentSettlement { env.storage().instance().get(&DataKey::Admin) } - /// Returns `(total_intents, total_volume, open_intents)`. + /// Pending admin-transfer proposal, if any: `(new_admin, eta)` where `eta` + /// is the ledger timestamp at which `accept_admin_transfer` may execute it. + pub fn get_pending_admin(env: Env) -> Option<(Address, u64)> { + env.storage().instance().get(&DataKey::PendingAdmin) + } + + /// /// - `total_intents` — cumulative count of intents ever submitted. /// - `total_volume` — cumulative dst-token units delivered across all fills.