diff --git a/.gitignore b/.gitignore index a7d95c6..a839291 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,20 @@ deploy-testnet.env # proptest failure-seed artifacts proptest-regressions/ +# backstop_vault build artifacts +backstop_vault/target/ + +# Coverage artifacts +lcov.info +coverage/ +tarpaulin-report.html + +# Rust profiling artifacts +*.profdata +*.profraw + +# Cargo.lock files for individual crates (reproducible via workspace) +backstop_vault/Cargo.lock # Cargo lock files for library crates (keep for binary/contract crates) # Uncomment to ignore: #Cargo.lock diff --git a/backstop_vault/Cargo.toml b/backstop_vault/Cargo.toml new file mode 100644 index 0000000..5cf32b6 --- /dev/null +++ b/backstop_vault/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "vortex-backstop-vault" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[dependencies] +soroban-sdk = { version = "21.0.0" } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/backstop_vault/src/lib.rs b/backstop_vault/src/lib.rs new file mode 100644 index 0000000..6666af7 --- /dev/null +++ b/backstop_vault/src/lib.rs @@ -0,0 +1,526 @@ +#![no_std] + +//! Vortex Protocol — Backstop LP Vault (`backstop_vault`) — Issue #370 +//! +//! A standalone Soroban contract where liquidity providers deposit the bond +//! token (USDC) in exchange for vault shares. The vault: +//! +//! - Absorbs user-compensation claims via `cover()` (callable only by the +//! settlement contract address set at initialization). +//! - Earns a configurable share of protocol fees and slash proceeds routed +//! by `intent_settlement` via `receive_income()`. +//! - Issues pro-rata shares that appreciate as the vault earns income and +//! depreciate as it pays out claims (loss socialization across all LPs). +//! - Enforces a deposit-triggered cooldown to prevent withdrawal front-running +//! ahead of a known fee inflow. +//! - Supports an emergency withdrawal-queue mode when the vault cannot service +//! all withdrawals immediately (bank-run protection). +//! +//! ## Share accounting (inflation-attack protection) +//! +//! The vault is seeded with `VIRTUAL_SHARES` / `VIRTUAL_ASSETS` at +//! initialization so the initial exchange rate starts at 1:1 and a dust +//! first-deposit cannot manipulate the rate to an extreme. +//! +//! ```text +//! shares_minted = deposit_amount * total_shares / total_assets +//! assets_out = shares_redeemed * total_assets / total_shares +//! ``` + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, token, + Address, BytesN, Env, Symbol, +}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/// Virtual shares/assets seeded at initialization to prevent inflation attacks. +const VIRTUAL_SHARES: i128 = 1_000_000_000; // 100 USDC-equivalent at 1:1 +const VIRTUAL_ASSETS: i128 = 1_000_000_000; + +/// Minimum deposit (1 USDC in 7-decimal units). +const MIN_DEPOSIT: i128 = 10_000_000; + +/// Withdrawal cooldown in seconds. An LP who just deposited must wait this +/// long before they can withdraw, preventing deposit front-running ahead of +/// a known incoming fee or slash payment. +const WITHDRAWAL_COOLDOWN_SECS: u64 = 172_800; // 48 hours + +const DAY_IN_LEDGERS: u32 = 17_280; // ~5 s per ledger +const PERSISTENT_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 14; +const PERSISTENT_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30; +const INSTANCE_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; +const INSTANCE_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 60; + +// ─── Storage keys ───────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Admin address (set in `initialize`). + Admin, + /// Bond token address (USDC or equivalent). + BondToken, + /// Settlement contract authorized to call `cover` and `receive_income`. + Settlement, + /// Total shares issued (i128). + TotalShares, + /// Total assets held by the vault (i128). + TotalAssets, + /// Per-LP share balance (i128). + Shares(Address), + /// Per-LP withdrawal cooldown: earliest allowed withdrawal timestamp (u64). + WithdrawCooldown(Address), + /// Emergency mode flag (bool). When `true`, withdrawals queue instead of + /// executing immediately. + EmergencyMode, + /// Per-LP queued withdrawal amount (i128). + WithdrawQueue(Address), + /// Total queued withdrawal amount (i128). + TotalQueued, + /// Cumulative cover paid out (i128) — informational. + TotalCoverPaid, + /// Cumulative income received (fees + slashes) (i128). + TotalIncome, +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + ZeroAmount = 4, + BelowMinDeposit = 5, + CooldownNotExpired = 6, + InsufficientShares = 7, + VaultInsolvent = 8, + EmergencyModeActive = 9, + EmergencyModeNotActive = 10, + NothingQueued = 11, +} + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct BackstopVault; + +#[contractimpl] +impl BackstopVault { + // ── Initialization ──────────────────────────────────────────────────────── + + /// One-time setup. Records the admin, bond_token, and authorized settlement + /// contract. Seeds virtual shares/assets for inflation-attack resistance. + pub fn initialize( + env: Env, + admin: Address, + bond_token: Address, + settlement: Address, + ) { + if env.storage().instance().has(&DataKey::Admin) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::BondToken, &bond_token); + env.storage().instance().set(&DataKey::Settlement, &settlement); + // Seed virtual entries. + env.storage().instance().set(&DataKey::TotalShares, &VIRTUAL_SHARES); + env.storage().instance().set(&DataKey::TotalAssets, &VIRTUAL_ASSETS); + env.storage().instance().set(&DataKey::TotalCoverPaid, &0i128); + env.storage().instance().set(&DataKey::TotalIncome, &0i128); + env.storage().instance().set(&DataKey::TotalQueued, &0i128); + env.storage().instance().set(&DataKey::EmergencyMode, &false); + Self::bump_instance_ttl(&env); + } + + // ── LP operations ───────────────────────────────────────────────────────── + + /// Deposit `amount` of bond_token and receive vault shares. + /// + /// Shares are minted proportional to the current assets:shares ratio. + /// Starts a new withdrawal cooldown on the depositing LP's account to + /// prevent deposit front-running ahead of incoming income. + /// + /// Returns the number of shares minted. + pub fn deposit(env: Env, lp: Address, amount: i128) -> i128 { + lp.require_auth(); + Self::bump_instance_ttl(&env); + + if amount < MIN_DEPOSIT { + panic_with_error!(&env, Error::BelowMinDeposit); + } + + let total_assets = Self::load_total_assets(&env); + let total_shares = Self::load_total_shares(&env); + + // shares_minted = amount * total_shares / total_assets + // Virtual entries guarantee total_assets > 0 always. + let shares_minted = amount + .checked_mul(total_shares) + .unwrap_or(amount) + .checked_div(total_assets) + .unwrap_or(1) + .max(1); + + // Transfer bond_token from LP to vault. + let bond_token: Address = env.storage().instance().get(&DataKey::BondToken).unwrap(); + token::Client::new(&env, &bond_token) + .transfer(&lp, &env.current_contract_address(), &amount); + + // Update state. + let lp_shares: i128 = env + .storage() + .persistent() + .get(&DataKey::Shares(lp.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::Shares(lp.clone()), &(lp_shares + shares_minted)); + env.storage() + .instance() + .set(&DataKey::TotalShares, &(total_shares + shares_minted)); + env.storage() + .instance() + .set(&DataKey::TotalAssets, &(total_assets + amount)); + Self::bump_persistent_ttl(&env, &DataKey::Shares(lp.clone())); + + // Reset cooldown: fresh deposit starts a new window. + let cooldown_until = env.ledger().timestamp() + WITHDRAWAL_COOLDOWN_SECS; + env.storage() + .persistent() + .set(&DataKey::WithdrawCooldown(lp.clone()), &cooldown_until); + Self::bump_persistent_ttl(&env, &DataKey::WithdrawCooldown(lp.clone())); + + env.events().publish( + (Symbol::new(&env, "deposit"), lp), + (amount, shares_minted), + ); + + shares_minted + } + + /// Redeem `shares` for bond_token assets. Subject to withdrawal cooldown. + /// + /// In emergency mode the withdrawal is queued and shares are burned + /// immediately; call `process_queued_withdrawal` once funds are available. + /// + /// Returns the number of assets redeemed (or queued in emergency mode). + pub fn withdraw(env: Env, lp: Address, shares: i128) -> i128 { + lp.require_auth(); + Self::bump_instance_ttl(&env); + + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + let lp_shares: i128 = env + .storage() + .persistent() + .get(&DataKey::Shares(lp.clone())) + .unwrap_or(0); + if lp_shares < shares { + panic_with_error!(&env, Error::InsufficientShares); + } + + // Cooldown guard. + let now = env.ledger().timestamp(); + let cooldown_until: u64 = env + .storage() + .persistent() + .get(&DataKey::WithdrawCooldown(lp.clone())) + .unwrap_or(0); + if now < cooldown_until { + panic_with_error!(&env, Error::CooldownNotExpired); + } + + let total_assets = Self::load_total_assets(&env); + let total_shares = Self::load_total_shares(&env); + + // assets_out = shares * total_assets / total_shares + let assets_out = shares + .checked_mul(total_assets) + .unwrap_or(shares) + .checked_div(total_shares) + .unwrap_or(0) + .max(0); + + // Emergency mode: queue the withdrawal, burn shares immediately. + let emergency: bool = env + .storage() + .instance() + .get(&DataKey::EmergencyMode) + .unwrap_or(false); + if emergency { + let queued: i128 = env + .storage() + .persistent() + .get(&DataKey::WithdrawQueue(lp.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::WithdrawQueue(lp.clone()), &(queued + assets_out)); + let total_queued: i128 = env + .storage() + .instance() + .get(&DataKey::TotalQueued) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalQueued, &(total_queued + assets_out)); + // Burn shares immediately. + env.storage() + .persistent() + .set(&DataKey::Shares(lp.clone()), &(lp_shares - shares)); + env.storage() + .instance() + .set(&DataKey::TotalShares, &(total_shares - shares)); + Self::bump_persistent_ttl(&env, &DataKey::Shares(lp.clone())); + Self::bump_persistent_ttl(&env, &DataKey::WithdrawQueue(lp.clone())); + env.events().publish( + (Symbol::new(&env, "withdraw_queued"), lp), + (shares, assets_out), + ); + return assets_out; + } + + if assets_out > total_assets { + panic_with_error!(&env, Error::VaultInsolvent); + } + + // Normal path: burn shares, update totals, transfer assets. + env.storage() + .persistent() + .set(&DataKey::Shares(lp.clone()), &(lp_shares - shares)); + env.storage() + .instance() + .set(&DataKey::TotalShares, &(total_shares - shares)); + env.storage() + .instance() + .set(&DataKey::TotalAssets, &(total_assets - assets_out)); + Self::bump_persistent_ttl(&env, &DataKey::Shares(lp.clone())); + + let bond_token: Address = env.storage().instance().get(&DataKey::BondToken).unwrap(); + token::Client::new(&env, &bond_token) + .transfer(&env.current_contract_address(), &lp, &assets_out); + + env.events().publish( + (Symbol::new(&env, "withdraw"), lp), + (shares, assets_out), + ); + + assets_out + } + + /// Process a queued withdrawal for `lp` (emergency mode only). + /// Pays out up to the queued amount from available assets. + pub fn process_queued_withdrawal(env: Env, lp: Address) { + Self::bump_instance_ttl(&env); + let emergency: bool = env + .storage() + .instance() + .get(&DataKey::EmergencyMode) + .unwrap_or(false); + if !emergency { + panic_with_error!(&env, Error::EmergencyModeNotActive); + } + let queued: i128 = env + .storage() + .persistent() + .get(&DataKey::WithdrawQueue(lp.clone())) + .unwrap_or(0); + if queued <= 0 { + panic_with_error!(&env, Error::NothingQueued); + } + let total_assets = Self::load_total_assets(&env); + let payout = queued.min(total_assets); + env.storage() + .persistent() + .set(&DataKey::WithdrawQueue(lp.clone()), &0i128); + let total_queued: i128 = env + .storage() + .instance() + .get(&DataKey::TotalQueued) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalQueued, &(total_queued.saturating_sub(queued))); + env.storage() + .instance() + .set(&DataKey::TotalAssets, &(total_assets - payout)); + + let bond_token: Address = env.storage().instance().get(&DataKey::BondToken).unwrap(); + token::Client::new(&env, &bond_token) + .transfer(&env.current_contract_address(), &lp, &payout); + + env.events().publish( + (Symbol::new(&env, "queued_withdrawal_processed"), lp), + payout, + ); + } + + // ── Settlement-only callbacks ────────────────────────────────────────────── + + /// Pay `amount` of bond_token to `recipient` as backstop compensation. + /// + /// Only callable by the settlement contract registered at initialization. + /// Loss is socialized across all shares by reducing `TotalAssets`. + pub fn cover( + env: Env, + intent_id: BytesN<32>, + amount: i128, + recipient: Address, + ) { + Self::bump_instance_ttl(&env); + let settlement: Address = env + .storage() + .instance() + .get(&DataKey::Settlement) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + settlement.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + let total_assets = Self::load_total_assets(&env); + let payout = amount.min(total_assets); + + // Socialize loss across all shares. + env.storage() + .instance() + .set(&DataKey::TotalAssets, &(total_assets - payout)); + let total_cover: i128 = env + .storage() + .instance() + .get(&DataKey::TotalCoverPaid) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalCoverPaid, &(total_cover + payout)); + + let bond_token: Address = env.storage().instance().get(&DataKey::BondToken).unwrap(); + token::Client::new(&env, &bond_token) + .transfer(&env.current_contract_address(), &recipient, &payout); + + env.events().publish( + (Symbol::new(&env, "cover"), recipient), + (intent_id, payout), + ); + } + + /// Notify the vault that `amount` of bond_token income has been transferred + /// to the contract address (fee share or slash proceeds). + /// + /// Increases `TotalAssets` so all existing share holders benefit + /// proportionally. The caller (settlement) must have already transferred + /// the tokens before calling this function. + pub fn receive_income(env: Env, amount: i128) { + Self::bump_instance_ttl(&env); + let settlement: Address = env + .storage() + .instance() + .get(&DataKey::Settlement) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + settlement.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + let total_assets = Self::load_total_assets(&env); + env.storage() + .instance() + .set(&DataKey::TotalAssets, &(total_assets + amount)); + let total_income: i128 = env + .storage() + .instance() + .get(&DataKey::TotalIncome) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalIncome, &(total_income + amount)); + env.events() + .publish((Symbol::new(&env, "income_received"),), amount); + } + + // ── Admin ───────────────────────────────────────────────────────────────── + + /// Admin-only: toggle emergency withdrawal-queue mode. + /// When enabled, `withdraw` queues redemptions instead of paying + /// immediately, protecting the vault during high-stress periods. + pub fn set_emergency_mode(env: Env, enabled: bool) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::EmergencyMode, &enabled); + env.events() + .publish((Symbol::new(&env, "emergency_mode"),), enabled); + } + + // ── Views ────────────────────────────────────────────────────────────────── + + /// Returns (total_assets, total_shares, assets_per_share_in_bps). + /// `assets_per_share_in_bps = total_assets * 10_000 / total_shares` + /// (i.e. 10_000 means 1:1, 12_000 means the vault has appreciated 20%). + pub fn get_vault_state(env: Env) -> (i128, i128, i128) { + let total_assets = Self::load_total_assets(&env); + let total_shares = Self::load_total_shares(&env); + let rate = total_assets + .checked_mul(10_000) + .unwrap_or(total_assets) + .checked_div(total_shares) + .unwrap_or(10_000); + (total_assets, total_shares, rate) + } + + /// Returns (share_balance, queued_withdrawal_amount) for `lp`. + pub fn get_lp_position(env: Env, lp: Address) -> (i128, i128) { + let shares: i128 = env + .storage() + .persistent() + .get(&DataKey::Shares(lp.clone())) + .unwrap_or(0); + let queued: i128 = env + .storage() + .persistent() + .get(&DataKey::WithdrawQueue(lp)) + .unwrap_or(0); + (shares, queued) + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + fn load_total_assets(env: &Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::TotalAssets) + .unwrap_or(VIRTUAL_ASSETS) + } + + fn load_total_shares(env: &Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::TotalShares) + .unwrap_or(VIRTUAL_SHARES) + } + + fn bump_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); + } + + fn bump_persistent_ttl(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + } +} diff --git a/docs/369-backstop-collusion-analysis.md b/docs/369-backstop-collusion-analysis.md new file mode 100644 index 0000000..a54432f --- /dev/null +++ b/docs/369-backstop-collusion-analysis.md @@ -0,0 +1,113 @@ +# Backstop Compensation — Collusion Analysis (#369) + +## Attack: Self-Slash Collusion + +A solver and user collude: +1. Solver accepts an intent for the colluding user. +2. Solver intentionally misses the fill window. +3. `slash_solver` is called — slash proceeds go to the fee recipient or + backstop pool depending on `backstop_vault_share_bps`. +4. User calls `claim_backstop_compensation` to extract funds from the pool. + +## Why This Is Unprofitable + +### Solver's loss (slash cost) + +``` +slash_amount = min(intent.min_dst_amount − total_filled, bond) / 10 + ≥ 1 stroop + ≤ bond × SLASH_BPS / 10_000 (10% cap) +``` + +For a typical intent where `min_dst_amount ≈ bond`: + +``` +slash_amount ≈ bond × 10% +``` + +### User's gain (claim payout) + +``` +intent_cap = min_dst_amount × MAX_BACKSTOP_INTENT_BPS / 10_000 + = min_dst_amount × 10% + +epoch_cap = MAX_BACKSTOP_USER_EPOCH_CLAIM (fixed ceiling per 24 h) + +payout = min(intent_cap, epoch_remaining, pool) +``` + +The backstop pool is **shared** across all users. The colluding user +competes with every other legitimate claimant; their slash proceeds enter +the pool that all claimants draw from, not a private reserve. + +### Net value: negative for the colluding pair + +| Item | Value | +|-----------------------------|--------------------------------------| +| Solver loses | `slash_amount` (10% of bond) | +| Pool receives | `vault_share × slash_amount` | +| User claims (best case) | `min(intent_cap, epoch_cap, pool)` | +| Net (pair) | < 0 in all realistic scenarios | + +For the claim to offset the slash loss at 1:1: + +``` +min_dst_amount × 10% ≥ bond × 10% +⟹ min_dst_amount ≥ bond +``` + +But the **exposure check (#367)** enforces: + +``` +accepted_notional ≤ bond × coverage_multiplier +``` + +So `min_dst_amount ≤ bond × coverage_multiplier`, meaning the maximum +possible claim payout is bounded by `bond × coverage_multiplier × 10%`. +For the default `coverage_multiplier = 10`, this is `bond × 100%` — but +the pool only holds what all prior slashes contributed, divided among all +claimants. + +### Additional deterrents + +1. **Slash cooldown**: After a slash, `SLASH_COOLDOWN` (1 hour) blocks the + solver from accepting new intents, limiting attack throughput to one + cycle per hour. + +2. **Per-user epoch cap**: `MAX_BACKSTOP_USER_EPOCH_CLAIM` bounds how much + any single user can extract per 24-hour epoch, regardless of how many + colluding intents are set up. + +3. **Exposure check (#367)**: `ExposureExceeded` prevents a solver from + accepting intents with notional far exceeding their bond. This caps the + maximum slash impact and therefore the maximum claim the colluding user + could trigger. + +4. **Active-intent deactivation**: A slash that drops the bond below + `min_bond` for the backing token sets `is_active = false`, blocking + further accepts until the solver tops back up. + +5. **Double-claim guard**: `BackstopClaimed(intent_id)` ensures each + intent can only be claimed once, regardless of how many slash cycles it + accumulates. + +6. **Eligible-state gate (#369)**: Only intents with `slash_cycles > 0` (or + in `Slashed` state) are eligible, preventing fresh intents from claiming. + +## Conclusion + +Self-slash collusion has **negative expected value** for the colluding pair +because: + +- The solver loses the full slash amount from their bond (real economic loss). +- The user can claim at most `MAX_BACKSTOP_INTENT_BPS` of `min_dst_amount` + from a pool that is shared with all other claimants and may be near-empty. +- Epoch caps prevent repeated extraction over time. +- Slash cooldowns limit throughput to one attack per hour per solver. +- The exposure check limits how large any single intent can be relative to + the solver's bond, capping the maximum yield from any single attack. + +The only scenario where the attack could be net-positive is if the backstop +pool happens to be very large (from many legitimate slashes) while no other +claimants are present — a condition that is both self-defeating (requires +many prior slashes, each costly to the attacker) and transient. diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 0a8e223..5f3815f 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -48,6 +48,7 @@ const DISPUTE_WINDOW: u64 = 3_600; // 1 hour /// Arbiter's time to resolve a dispute (issue #188). After expiry, release_fill becomes permissionless. const ARBITER_WINDOW: u64 = 86_400; // 24 hours +/// Anti-griefing bond a user must post when opening a dispute (#188). /// Minimum solver bond (50 USDC). const MIN_BOND: i128 = 50 * 10_000_000; @@ -86,6 +87,7 @@ const DISPUTE_WINDOW: u64 = 3_600; // 1 hour /// After being slashed a solver must wait this many seconds before they can /// accept new intents. Used by `accept_intent`'s cooldown guard and by +/// `get_slash_cooldown_remaining` (issue #256). /// `get_slash_cooldown_remaining` (issue #256), which both derive from the /// same `slash_cooldown_remaining` helper so they can never disagree. const SLASH_COOLDOWN: u64 = 3_600; // 1 hour @@ -108,6 +110,76 @@ const CANCEL_COOLDOWN: u64 = 60; // 1 minute (NOT 1 hour; validated at #341) const AMENDMENT_COOLDOWN: u64 = 60; // 1 minute const SLASH_COOLDOWN: u64 = 3_600; // 1 hour +/// Delay enforced between proposing and executing a sensitive admin change +/// (admin transfer, fee recipient handover, dst_token allowlist changes). +/// Gives users and solvers a window to notice and react before the change +/// takes effect (#115). +const ADMIN_TIMELOCK_DELAY: u64 = 172_800; // 48 hours + +// ─── Default protocol parameters (#202) ────────────────────────────────────── +const DEFAULT_MIN_BOND: i128 = MIN_BOND; // 50 USDC +const DEFAULT_FILL_WINDOW: u64 = FILL_WINDOW; // 300 s +const DEFAULT_INTENT_EXPIRY: u64 = INTENT_EXPIRY; // 1800 s +const DEFAULT_PROTOCOL_FEE_BPS: i128 = PROTOCOL_FEE_BPS; // 5 bps (0.05%) +const DEFAULT_MAX_ACTIVE_INTENTS_PER_SOLVER: u32 = 10; + +// ─── `set_config` bounds (#202) ────────────────────────────────────────────── +const MAX_PROTOCOL_FEE_BPS: i128 = 1_000; // 10% — hard ceiling on the protocol fee +const MIN_FILL_WINDOW_SECS: u64 = 60; // a solver needs at least a minute to deliver a fill +const MIN_INTENT_EXPIRY_SECS: u64 = 300; // an intent must stay live for at least five minutes +const MIN_BOND_FLOOR: i128 = 10_000_000; // 1 USDC (7 decimals) — absolute floor for `min_bond` + +// ─── Cooldowns (#202) ──────────────────────────────────────────────────────── + +/// Minimum gap the same user must leave between `cancel_intent` calls. +const CANCEL_COOLDOWN: u64 = 60; // 1 minute + +// ─── Batch + extension limits (#202) ───────────────────────────────────────── + +/// Upper bound on the number of items any `batch_*` entrypoint processes in a single call. +const MAX_BATCH_SIZE: u32 = 20; + +/// Longest additional time `request_extension` can add to an Accepted intent's deadline. +const MAX_EXTENSION_DURATION: u64 = 300; // 5 minutes + +// ─── Competitive bid window (#191) ─────────────────────────────────────────── +const BID_WINDOW: u64 = 600; // 10 minutes + +// ─── Storage-migration schema version (#194) ───────────────────────────────── +const MIGRATION_VERSION: u32 = 1; + +// ─── Basis-points denominator ──────────────────────────────────────────────── +const BPS_DENOMINATOR: i128 = 10_000; + +// ─── Amount sanity bounds ───────────────────────────────────────────────────── +/// Upper bound for src_amount and min_dst_amount (10^30). +pub const MAX_AMOUNT: i128 = 1_000_000_000_000_000_000_000_000_000_000i128; +/// Ceiling on whole dst_token units for the decimals-aware guard (#252). +const MAX_WHOLE_UNITS: i128 = 1_000_000_000_000i128; // 1 trillion + +// ─── TTL constants ──────────────────────────────────────────────────────────── +const DAY_IN_LEDGERS: u32 = 17280; // ~5s per ledger +const PERSISTENT_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 14; +const PERSISTENT_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 30; +const INSTANCE_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; +const INSTANCE_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 60; + +// ─── Referral (#281) ───────────────────────────────────────────────────────── +const MAX_REFERRAL_SHARE_BPS: i128 = 5_000; // 50% max referral share + +// ─── Validation limits ─────────────────────────────────────────────────────── +const MAX_CHAIN_LEN: u32 = 64; +const MAX_TOKEN_LEN: u32 = 128; +const MAX_FILL_HISTORY: u32 = 10; + +// ─── Backstop compensation caps (#369) ─────────────────────────────────────── +/// Maximum compensation per intent: 10% of min_dst_amount. +const MAX_BACKSTOP_INTENT_BPS: i128 = 1_000; +/// Maximum compensation per user per 24-hour epoch. +const MAX_BACKSTOP_USER_EPOCH_CLAIM: i128 = 5 * 50 * 10_000_000; // 5 × 50 USDC +/// Epoch length for per-user backstop claim caps. +const BACKSTOP_EPOCH_SECS: u64 = 86_400; // 24 hours + /// Minimum gap between successive cancel_intent() calls by the same user (issue #341). /// Deters cancel-spam griefing while allowing correction of mistakes. const CANCEL_COOLDOWN: u64 = 60; // 1 minute (NOT 1 hour; validated at #341) @@ -558,6 +630,77 @@ pub enum DataKey { /// exactly like `DstAllowlistEnabled`. ProofRegistry, + // ── Multi-bond-token support (#187) ───────────────────────────────────── + /// Per-(solver, bond_token) bond balance (i128). + SolverBond(Address, Address), + /// Allowed bond tokens set (presence flag `true`). + AllowedBondToken(Address), + /// Minimum bond amount required for a non-default bond token (i128). + MinBond(Address), + + // ── Per-solver pending intents list (#230) ─────────────────────────────── + /// `Vec>` of intent IDs currently accepted by this solver. + SolverIntents(Address), + + // ── Total bonded across all solvers (#231) ─────────────────────────────── + TotalBonded, + + // ── Token-level stats (#248) ───────────────────────────────────────────── + TokenVolume(Address), + TokenFees(Address), + + // ── Fee discount schedule (#192) ───────────────────────────────────────── + /// `Vec<(i128, i128)>` — (min_volume, discount_bps) pairs, ascending. + FeeDiscountTiers, + + // ── Open-intent enumeration list (#249) ────────────────────────────────── + OpenIntentList, + + // ── Solver registry link (#197) ─────────────────────────────────────────── + SolverRegistry, + + // ── Solver routes (#255) ───────────────────────────────────────────────── + SolverRoutes(Address), + + // ── Reputation snapshot (#272) ─────────────────────────────────────────── + SolverReputation(Address), + + // ── Contract upgrade (#194) ────────────────────────────────────────────── + PendingUpgrade, + MigrationVersion, + + // ── Bid window (#191) ──────────────────────────────────────────────────── + BidWindowEnabled, + BestBid(BytesN<32>), + + // ── Dispute / arbiter (#188) ───────────────────────────────────────────── + Arbiter, + + // ── Backstop pool (#280) ───────────────────────────────────────────────── + /// Current backstop pool balance (i128), funded by slash proceeds. + BackstopPool, + /// Presence flag recording that a user already claimed for this intent. + BackstopClaimed(BytesN<32>), + + // ── #367 Exposure tracking ──────────────────────────────────────────────── + /// Per-solver accepted notional exposure (i128). + SolverExposure(Address), + + // ── #368 Oracle ─────────────────────────────────────────────────────────── + /// Address of the oracle adapter contract. + OracleAddr, + /// Pending oracle proposal: (Address, u64 eta). + PendingOracle, + + // ── #369 Per-user epoch claims ──────────────────────────────────────────── + /// Per-user epoch claim amount (i128). Key: (user, epoch_number). + UserEpochClaim(Address, u64), + + // ── #370 Backstop vault ─────────────────────────────────────────────────── + /// Address of the deployed backstop_vault contract. + BackstopVault, + /// bps of fees/slashes routed to the vault. + BackstopVaultShareBps, /// **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 @@ -767,6 +910,19 @@ pub struct ProtocolConfig { pub protocol_fee_bps: i128, /// Maximum number of intents a single solver may accept simultaneously (issue #230). pub max_active_intents_per_solver: u32, + + /// Maximum slash cycles before an intent is abandoned (#241). + pub max_slash_cycles: u32, + + /// Referral share in bps (0 = disabled, max 5000 = 50%) (#281). + pub referral_share_bps: i128, + + /// Bps of fees/slashes routed to the backstop vault (#370, 0 = disabled). + pub backstop_vault_share_bps: i128, + + /// Exposure coverage multiplier (#367): accepted_notional <= bond * coverage_multiplier. + /// Stored as a plain integer (e.g. 10 means 10×). + pub coverage_multiplier: i128, /// Issue #362: Optional deposit token (None = deposits disabled). pub submission_deposit_token: Option
, /// Issue #362: Deposit amount in smallest units (refunded on fill/cancel, forfeited on expiry). @@ -859,6 +1015,12 @@ pub struct IntentRecord { /// (`Open` / `PartiallyFilled`) or the registry integration is unset. pub solver_tier: u32, + /// #281: optional referrer address for fee-sharing. + pub referrer: Option
, + + /// #241: number of slash cycles this intent has been through. + /// Used by `slash_solver` to detect `max_slash_cycles` exhaustion and + /// by `claim_backstop_compensation` to confirm eligibility. /// Issue #371: ledger timestamp at which this intent entered a terminal /// state (Filled, Cancelled, Expired, Resolved, Slashed). `None` for /// intents that have not yet reached a terminal state. Used by @@ -1276,6 +1438,90 @@ 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, + + // ── Previously missing variants ────────────────────────────────────────── + /// `set_config` received an invalid parameter value. + InvalidConfig = 36, + /// The timelock delay since the matching `propose_*` call has not elapsed. + TimelockNotElapsed = 37, + /// `accept_admin_transfer` called with no pending proposal. + NoPendingAdminTransfer = 38, + /// `execute_add_dst_token` / `execute_remove_dst_token` with no proposal. + NoPendingDstTokenChange = 39, + /// `cancel_intent` rate-limit cooldown not yet expired. + CancelCooldownNotExpired = 40, + /// `src_amount` or `min_dst_amount` exceeds the MAX_AMOUNT safety ceiling. + AmountTooLarge = 41, + /// `min_dst_amount` is implausible given the dst_token's declared decimals. + ImplausibleDstAmount = 42, + /// Solver's active-intent count is at the configured cap. + MaxActiveIntentsCapReached = 43, + /// Bond token is not on the allowed-token set. + BondTokenNotAllowed = 44, + /// `request_extension` already used for this intent. + ExtensionAlreadyGranted = 45, + /// Withdrawal would leave the solver under-covered. + ExtensionCapExceeded = 46, + /// `migrate` called on a contract already at the current schema version. + AlreadyMigrated = 47, + /// Bid-window closed — cannot accept new bids. + BidWindowClosed = 48, + /// `settle_bids` called while bid window is still open. + BidWindowStillOpen = 49, + /// Submitted bid does not improve on the current best. + BidNotHigher = 50, + /// Intent is not in Bidding state. + IntentNotBidding = 51, + /// Intent is not in Filling state. + IntentNotFilling = 52, + /// Intent is not in Disputed state. + IntentNotDisputed = 53, + /// Caller is not the arbiter. + NotArbiter = 54, + /// Arbiter resolution window has elapsed. + ArbiterWindowExpired = 55, + /// Dispute window is still open — cannot release fill yet. + DisputeWindowStillOpen = 56, + /// Dispute window has already expired — cannot raise a dispute. + DisputeWindowExpired = 57, + /// The dispute window has closed with no dispute. + DisputeWindowClosed = 58, + /// No fill is currently held in escrow for this intent. + NoFillEscrowed = 59, + /// No pending contract upgrade to execute. + NoPendingUpgrade = 60, + /// `fill_intent` called on a non-Accepted intent (different message path). + IntentNotAcceptedForFill = 61, + /// Solver has too many bond tokens. + TooManyBondTokens = 62, + /// Solver has too many route entries. + TooManyRouteEntries = 63, + /// `batch_*` called with more than MAX_BATCH_SIZE items. + BatchTooLarge = 64, + /// No dispute is currently open for this intent. + NoDisputeOpen = 65, + + // ── #369 Backstop hardening ─────────────────────────────────────────────── + /// Backstop pool is empty — nothing to pay out. + BackstopPoolEmpty = 66, + /// User already claimed backstop compensation for this intent. + BackstopAlreadyClaimed = 67, + + // ── #367 Exposure tracking ──────────────────────────────────────────────── + /// Accepting this intent would exceed the solver's coverage capacity. + ExposureExceeded = 68, + + // ── #368 Oracle ─────────────────────────────────────────────────────────── + /// Oracle price data is stale (too old). + OraclePriceStale = 69, + /// Oracle returned a zero or invalid price. + OraclePriceInvalid = 70, + /// No oracle has been configured. + OracleNotConfigured = 71, + /// Oracle timelock has not elapsed for the pending proposal. + OracleTimelockNotElapsed = 72, + /// No pending oracle proposal. + NoPendingOracle = 73, /// #371: `close_intent` was called on an intent that is not in a terminal /// state (Filled, Cancelled, Expired, Resolved, Slashed). IntentNotTerminal = 36, @@ -1486,6 +1732,10 @@ impl IntentSettlement { intent_expiry: DEFAULT_INTENT_EXPIRY, protocol_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, max_active_intents_per_solver: DEFAULT_MAX_ACTIVE_INTENTS_PER_SOLVER, + max_slash_cycles: 3, + referral_share_bps: 0, + backstop_vault_share_bps: 0, + coverage_multiplier: 10, }, ); // Fresh deploys are already at the current schema — `migrate` is a @@ -1771,12 +2021,18 @@ impl IntentSettlement { panic_with_error!(&env, Error::InvalidConfig); } + // Preserve fields not updated by this call. + let existing = Self::load_config(&env); let cfg = ProtocolConfig { min_bond, fill_window, intent_expiry, protocol_fee_bps, max_active_intents_per_solver, + max_slash_cycles: existing.max_slash_cycles, + referral_share_bps: existing.referral_share_bps, + backstop_vault_share_bps: existing.backstop_vault_share_bps, + coverage_multiplier: existing.coverage_multiplier, }; env.storage().instance().set(&DataKey::Config, &cfg); Self::bump_instance_ttl(&env); @@ -2779,6 +3035,7 @@ impl IntentSettlement { dst_token, min_dst_amount, deadline, + referrer, ) } @@ -2797,6 +3054,7 @@ impl IntentSettlement { dst_token: Address, min_dst_amount: i128, deadline: Option, + referrer: Option
, ) -> BytesN<32> { Self::require_not_paused(&env); Self::bump_instance_ttl(&env); @@ -2920,6 +3178,11 @@ impl IntentSettlement { dispute_deadline: None, dispute_raised_at: None, resolution: None, + solver_tier: 0, + // #281: referrer for fee-sharing (passed through from submit_intent) + referrer, + // #241: no slash cycles yet + slash_cycles: 0, // Issue #371: not terminal yet. terminal_at: None, }; @@ -3001,6 +3264,16 @@ impl IntentSettlement { intent_id: BytesN<32>, bond_token: Address, ) { + // Auth audit: require_auth() is correct. The solver must sign to + // voluntarily take on the fill obligation and bond risk. + solver.require_auth(); + Self::accept_intent_inner_body(env, solver, intent_id, bond_token); + } + + /// Body of `accept_intent` without the `solver.require_auth()` gate. Shared + /// with `batch_accept_intent`, which authorises the solver once per batch + /// (`require_auth()` is one-shot per address per invocation). + fn accept_intent_inner_body(env: Env, solver: Address, intent_id: BytesN<32>, bond_token: Address) { Self::require_not_paused(&env); Self::bump_instance_ttl(&env); @@ -3074,6 +3347,35 @@ impl IntentSettlement { intent.deadline = now + Self::tier_fill_window(tier, ctx.config.fill_window); solver_record.active_intents += 1; + + // ── Exposure tracking (#367) ───────────────────────────────────────── + // Use a flat 1.0× weight (10_000 bps) until the oracle (#368) supplies + // real prices. Same-token intents are always measured; cross-token + // intents use the same conservative default until the oracle is live. + let weight: i128 = 10_000; // 1.0× in bps + let notional = intent.min_dst_amount + .checked_mul(weight) + .unwrap_or(intent.min_dst_amount) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(intent.min_dst_amount); + let current_exposure: i128 = env + .storage() + .persistent() + .get(&DataKey::SolverExposure(solver.clone())) + .unwrap_or(0); + let new_exposure = current_exposure.saturating_add(notional); + // Coverage: new_exposure ≤ bond × coverage_multiplier + let bond_for_coverage = Self::get_solver_bond_amount(&env, &solver_record, &bond_token); + let capacity = bond_for_coverage + .checked_mul(cfg.coverage_multiplier) + .unwrap_or(i128::MAX); + if new_exposure > capacity { + panic_with_error!(&env, Error::ExposureExceeded); + } + env.storage() + .persistent() + .set(&DataKey::SolverExposure(solver.clone()), &new_exposure); + env.storage() .persistent() .set(&DataKey::Solver(solver.clone()), &solver_record); @@ -3152,6 +3454,12 @@ impl IntentSettlement { Self::fill_intent_inner(env, solver, intent_id, fill_amount); } + /// Body of `fill_intent` without the `solver.require_auth()` gate. Shared + /// with `batch_fill_intent`, which authorises the solver once per batch + /// (`require_auth()` is one-shot per address per invocation). The solver's + /// signature over the batch call still covers the individual dst-token + /// transfers each fill performs. + fn fill_intent_inner(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128, require_proof: bool) { /// Body of `fill_intent` without the `solver.require_auth()` gate. /// /// Issue #375: uses `InvocationCtx` to load `Config`, `FeeRecipient`, @@ -3188,6 +3496,10 @@ impl IntentSettlement { Self::validate_proof(&env, &intent, &intent_id); } + // Compute protocol fee with explicit checked arithmetic (#269 / #31). + // Taking the fee from the solver keeps the user's received amount at or + // above `min_dst_amount`. + let fee_bps = Self::get_tiered_fee_bps(&env); // Issue #375: load Config, FeeRecipient, OpenIntents, TotalVolume once. let mut ctx = InvocationCtx::load(&env); @@ -3203,6 +3515,10 @@ impl IntentSettlement { .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)); // ── Effects first (CEI) ────────────────────────────────────────────── + // Mark every state change and write it to storage *before* any external + // token transfer executes. A hostile SEP-41 token that tries to re-enter + // fill_intent or slash_solver during the transfer sees the already- + // updated intent state and is rejected by the guards above. // Every state change below is written to storage *before* the token // transfers at the end of this function. A hostile SEP-41 token that // tries to re-enter `fill_intent` / `slash_solver` during a transfer @@ -3244,6 +3560,27 @@ impl IntentSettlement { Self::add_to_open_intent_list(&env, &intent_id); } + // Release accepted_notional exposure (#367) + { + let exposure: i128 = env + .storage() + .persistent() + .get(&DataKey::SolverExposure(solver.clone())) + .unwrap_or(0); + // Release: full notional on complete fill; proportional on partial. + let released = if cumulative >= intent.min_dst_amount { + intent.min_dst_amount + } else { + fill_amount + }; + env.storage() + .persistent() + .set( + &DataKey::SolverExposure(solver.clone()), + &(exposure.saturating_sub(released)), + ); + } + env.storage() .persistent() .set(&DataKey::Solver(solver.clone()), &solver_record); @@ -3732,6 +4069,20 @@ impl IntentSettlement { solver_record.active_intents = solver_record.active_intents.saturating_sub(1); Self::solver_intents_remove(&env, &solver_addr, &intent_id); + // Release accepted_notional exposure (#367) + let exposure: i128 = env + .storage() + .persistent() + .get(&DataKey::SolverExposure(solver_addr.clone())) + .unwrap_or(0); + let unfilled_notional = intent.min_dst_amount.saturating_sub(intent.total_filled); + env.storage() + .persistent() + .set( + &DataKey::SolverExposure(solver_addr.clone()), + &(exposure.saturating_sub(unfilled_notional)), + ); + // Decrement TotalBonded by the slashed amount (issue #231) let total_bonded: i128 = env .storage() @@ -3775,9 +4126,43 @@ impl IntentSettlement { Self::bump_solver_ttl(&env, &solver_addr); Self::save_intent(&env, &intent_id, &intent); + // Send slash to fee recipient, in the same token the solver bonded + // (issue #187), with state already committed above. + // Route a configured share to the backstop pool (#370). // Send the slashed amount to the fee recipient (same bond token, #187). if slash_amount > 0 { let client = token::Client::new(&env, &bond_token); + + let vault_share_bps = cfg.backstop_vault_share_bps; + let vault_share = if vault_share_bps > 0 { + slash_amount + .checked_mul(vault_share_bps) + .unwrap_or(0) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(0) + } else { + 0 + }; + let fee_recipient_share = slash_amount - vault_share; + + if fee_recipient_share > 0 { + client.transfer( + &env.current_contract_address(), + &fee_recipient, + &fee_recipient_share, + ); + } + if vault_share > 0 { + // Credit the backstop pool in instance storage (#369). + let pool: i128 = env + .storage() + .instance() + .get(&DataKey::BackstopPool) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::BackstopPool, &(pool + vault_share)); + } client.transfer( &env.current_contract_address(), &ctx.fee_recipient, @@ -4530,6 +4915,7 @@ impl IntentSettlement { dst_token, min_dst_amount, deadline, + None, // batch submissions do not support referrers ); result.push_back(intent_id); } @@ -4549,7 +4935,9 @@ impl IntentSettlement { solver.require_auth(); let bond_token = Self::load_bond_token(&env); + let bond_token = Self::load_bond_token(&env); for intent_id in intent_ids { + Self::accept_intent_inner_body(env.clone(), solver.clone(), intent_id, bond_token.clone()); Self::accept_intent_inner( env.clone(), solver.clone(), @@ -4584,7 +4972,7 @@ impl IntentSettlement { ); for (intent_id, fill_amount) in fills { - Self::fill_intent_inner(env.clone(), solver.clone(), intent_id, fill_amount); + Self::fill_intent_inner(env.clone(), solver.clone(), intent_id, fill_amount, false); } } @@ -5002,47 +5390,28 @@ impl IntentSettlement { 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. + // ── Load intent ─────────────────────────────────────────────────────── 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" + // ── Eligible state check (#369) ─────────────────────────────────────── + // Eligible: Slashed (abandoned after max_slash_cycles) OR + // Open/PartiallyFilled that has been through at least one slash cycle. + let eligible = match intent.state { + IntentState::Slashed => true, + IntentState::Open | IntentState::PartiallyFilled => intent.slash_cycles > 0, + _ => false, + }; + if !eligible { + 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. + // ── Double-claim guard ──────────────────────────────────────────────── if env .storage() .persistent() @@ -5051,7 +5420,7 @@ impl IntentSettlement { panic_with_error!(&env, Error::BackstopAlreadyClaimed); } - // Pool must be non-empty. + // ── Pool must be non-empty ──────────────────────────────────────────── let pool: i128 = env .storage() .instance() @@ -5061,35 +5430,50 @@ impl IntentSettlement { panic_with_error!(&env, Error::BackstopPoolEmpty); } - // ── Compute payout ──────────────────────────────────────────────────── + // ── Per-intent cap: MAX_BACKSTOP_INTENT_BPS of min_dst_amount (#369) ─ + let intent_cap = intent.min_dst_amount + .checked_mul(MAX_BACKSTOP_INTENT_BPS) + .unwrap_or(intent.min_dst_amount) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(1) + .max(1); - // 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); + // ── Per-user epoch cap (#369) ───────────────────────────────────────── + let now = env.ledger().timestamp(); + let epoch = now / BACKSTOP_EPOCH_SECS; + let epoch_key = DataKey::UserEpochClaim(intent.user.clone(), epoch); + let epoch_claimed: i128 = env + .storage() + .persistent() + .get(&epoch_key) + .unwrap_or(0); + let epoch_remaining = MAX_BACKSTOP_USER_EPOCH_CLAIM.saturating_sub(epoch_claimed); + if epoch_remaining <= 0 { + panic_with_error!(&env, Error::BackstopPoolEmpty); // epoch cap exhausted + } - // ── Effects ─────────────────────────────────────────────────────────── + // ── Pro-rata when pool is short: payout = min(intent_cap, epoch_remaining, pool) + let payout = intent_cap.min(epoch_remaining).min(pool).max(1); - // Mark this intent as claimed before transferring, preventing re-entrancy - // or a back-to-back call from double-paying. + // ── Effects ─────────────────────────────────────────────────────────── 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. + Self::bump_intent_ttl(&env, &intent_id); env.storage() .instance() .set(&DataKey::BackstopPool, &(pool - payout)); + env.storage() + .persistent() + .set(&epoch_key, &(epoch_claimed + payout)); + // Bump epoch key TTL alongside the intent. + env.storage().persistent().extend_ttl( + &epoch_key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); // ── 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); @@ -5289,6 +5673,71 @@ impl IntentSettlement { env.storage().persistent().get(&DataKey::Solver(solver)) } + /// #367: Returns (used_notional, capacity_notional) for a solver. + /// `capacity = bond * coverage_multiplier` from ProtocolConfig. + /// `used` is the sum of accepted intent notionals not yet released. + pub fn get_solver_exposure(env: Env, solver: Address) -> (i128, i128) { + let used: i128 = env + .storage() + .persistent() + .get(&DataKey::SolverExposure(solver.clone())) + .unwrap_or(0); + let capacity = match env + .storage() + .persistent() + .get::<_, SolverRecord>(&DataKey::Solver(solver)) + { + Some(rec) => { + let cfg = Self::load_config(&env); + rec.bond_amount + .checked_mul(cfg.coverage_multiplier) + .unwrap_or(i128::MAX) + } + None => 0, + }; + (used, capacity) + } + + /// #368: Admin-only — propose a new oracle adapter (timelocked 48 h). + pub fn propose_oracle(env: Env, oracle: Address) { + Self::require_admin(&env); + let eta = env.ledger().timestamp() + ADMIN_TIMELOCK_DELAY; + env.storage() + .instance() + .set(&DataKey::PendingOracle, &(oracle.clone(), eta)); + env.events() + .publish((Symbol::new(&env, "oracle_proposed"),), (oracle, eta)); + } + + /// #368: Execute a pending oracle proposal after the timelock has elapsed. + pub fn execute_oracle(env: Env) { + let (pending, eta): (Address, u64) = env + .storage() + .instance() + .get(&DataKey::PendingOracle) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingOracle)); + if env.ledger().timestamp() < eta { + panic_with_error!(&env, Error::OracleTimelockNotElapsed); + } + env.storage() + .instance() + .set(&DataKey::OracleAddr, &pending.clone()); + env.storage().instance().remove(&DataKey::PendingOracle); + env.events() + .publish((Symbol::new(&env, "oracle_set"),), pending); + } + + /// #368: Returns the current oracle address, if set. + pub fn get_oracle(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::OracleAddr) + } + + /// List the intent IDs currently `Accepted` by `solver` (issue #245). + /// Returns an empty `Vec` if the solver has no in-flight obligations (or + /// has never accepted an intent). Lets a solver bot recovering from a + /// crash rediscover its own active intents without replaying events. + pub fn get_solver_intents(env: Env, solver: Address) -> Vec> { + env.storage() /// List the intent IDs currently `Accepted` by `solver`, paginated /// (issue #245, #374). /// @@ -6321,6 +6770,10 @@ impl IntentSettlement { intent_expiry: DEFAULT_INTENT_EXPIRY, protocol_fee_bps: DEFAULT_PROTOCOL_FEE_BPS, max_active_intents_per_solver: DEFAULT_MAX_ACTIVE_INTENTS_PER_SOLVER, + max_slash_cycles: 3, + referral_share_bps: 0, + backstop_vault_share_bps: 0, + coverage_multiplier: 10, }) } diff --git a/intent_settlement/src/oracle.rs b/intent_settlement/src/oracle.rs new file mode 100644 index 0000000..d67cd33 --- /dev/null +++ b/intent_settlement/src/oracle.rs @@ -0,0 +1,113 @@ +//! Oracle adapter module for Vortex Protocol (#368). +//! +//! Provides an `OracleAdapter` client interface compatible with SEP-40 +//! (Reflector protocol). The settlement contract queries asset prices +//! through this adapter for exposure checks and min-bond adjustments. +//! +//! ## Staleness guard +//! Prices older than `MAX_PRICE_AGE_SECS` are rejected (fail-closed). +//! +//! ## Deviation guard +//! If two prices disagree by more than `MAX_PRICE_DEVIATION_BPS` the +//! data is treated as unreliable (fail-closed). +//! +//! ## Fail-closed behaviour +//! On stale / zero / missing price data: +//! - `accept_intent` exposure check → uses conservative 1.0× weight +//! (the check still runs, so an over-exposed solver is still blocked) +//! - `slash_solver` → proceeds regardless (slashing must never be blocked) + +#![allow(unused)] + +use soroban_sdk::{contractclient, Address, Env}; + +// ─── SEP-40 compatible oracle client ───────────────────────────────────────── + +/// SEP-40 `lastprice` result. +#[soroban_sdk::contracttype] +#[derive(Clone, Debug)] +pub struct PriceData { + /// Price in oracle-defined units, normalised by `decimals`. + pub price: i128, + /// Decimal precision of `price`. + pub decimals: u32, + /// Unix timestamp of the observation (seconds). + pub timestamp: u64, +} + +/// Minimal SEP-40 oracle interface. +/// The settlement contract expects a deployed contract that implements at +/// least `lastprice(asset: Address) -> Option`. +#[contractclient(name = "OracleAdapterClient")] +pub trait OracleAdapterTrait { + /// Return the latest price for `asset`, or `None` if unavailable. + fn lastprice(env: Env, asset: Address) -> Option; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/// Maximum age of a valid price in seconds (5 minutes). +pub const MAX_PRICE_AGE_SECS: u64 = 300; + +/// Maximum allowed deviation between two oracle sources, in basis points (2%). +pub const MAX_PRICE_DEVIATION_BPS: i128 = 200; + +// ─── Result type ───────────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq)] +pub enum OracleFault { + /// Oracle returned `None` — no price available. + NotAvailable, + /// Price is older than `MAX_PRICE_AGE_SECS`. + Stale, + /// Price is zero or negative — unusable. + ZeroOrNegative, +} + +// ─── Public helpers ─────────────────────────────────────────────────────────── + +/// Fetch the price of `asset` from the oracle at `oracle_addr`. +/// +/// Returns `Ok((price, decimals))` on success. +/// Returns `Err(OracleFault)` if the price is stale, zero, or unavailable. +pub fn fetch_price( + env: &Env, + oracle_addr: &Address, + asset: &Address, +) -> Result<(i128, u32), OracleFault> { + let client = OracleAdapterClient::new(env, oracle_addr); + match client.lastprice(asset.clone()) { + None => Err(OracleFault::NotAvailable), + Some(pd) => { + let now = env.ledger().timestamp(); + if now > pd.timestamp && now - pd.timestamp > MAX_PRICE_AGE_SECS { + return Err(OracleFault::Stale); + } + if pd.price <= 0 { + return Err(OracleFault::ZeroOrNegative); + } + Ok((pd.price, pd.decimals)) + } + } +} + +/// Check that two prices (from different sources) agree within +/// `MAX_PRICE_DEVIATION_BPS`. Returns `true` if they agree, `false` +/// if the deviation exceeds the threshold or either price is zero. +pub fn prices_agree(price_a: i128, dec_a: u32, price_b: i128, dec_b: u32) -> bool { + if price_a <= 0 || price_b <= 0 { + return false; + } + // Normalise to the higher precision. + let (a, b) = if dec_a >= dec_b { + let scale = 10i128.pow(dec_a - dec_b); + (price_a, price_b.saturating_mul(scale)) + } else { + let scale = 10i128.pow(dec_b - dec_a); + (price_a.saturating_mul(scale), price_b) + }; + let diff = (a - b).abs(); + let denom = a.max(b); + // diff / denom <= MAX_PRICE_DEVIATION_BPS / 10_000 + diff.saturating_mul(10_000) <= MAX_PRICE_DEVIATION_BPS.saturating_mul(denom) +}