Skip to content
Merged
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
140 changes: 139 additions & 1 deletion intent_settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down