From 454415c41df4702add6f747ceb721e924c7b9e42 Mon Sep 17 00:00:00 2001 From: kurehkim334-lang Date: Wed, 30 Sep 2026 11:22:24 +0000 Subject: [PATCH] feat: implement issues #367, #368, #369, #370 #367 - Exposure-weighted bonding - Add `accepted_notional` exposure tracking to SolverRecord via DataKey::SolverExposure(Address) - Add ExposureExceeded error (code 68) - accept_intent: compute notional at 1.0x weight (oracle-ready), check new_exposure <= bond * coverage_multiplier, persist to SolverExposure - fill_intent: release exposure on fill (full notional on complete fill, proportional on partial) - slash_solver: release unfilled notional on slash - Add get_solver_exposure(solver) -> (used, capacity) view - Add coverage_multiplier field to ProtocolConfig (default 10x) #368 - Oracle adapter - New intent_settlement/src/oracle.rs module with SEP-40-compatible OracleAdapterClient interface (lastprice -> Option) - Staleness guard (MAX_PRICE_AGE_SECS = 300s) and deviation guard (MAX_PRICE_DEVIATION_BPS = 200bps) with fail-closed OracleFault enum - Timelocked admin setter: propose_oracle / execute_oracle (48h delay) - get_oracle() view; DataKey::OracleAddr + PendingOracle - Error variants: OraclePriceStale, OraclePriceInvalid, OracleNotConfigured, OracleTimelockNotElapsed, NoPendingOracle #369 - Harden claim_backstop_compensation - Eligible states: Slashed OR (Open/PartiallyFilled with slash_cycles > 0) - Per-intent cap: MAX_BACKSTOP_INTENT_BPS (10%) of min_dst_amount - Per-user epoch cap: MAX_BACKSTOP_USER_EPOCH_CLAIM per 24h epoch via DataKey::UserEpochClaim(Address, u64) - Pro-rata when pool is short: payout = min(intent_cap, epoch_remaining, pool) - Idempotent double-claim guard via BackstopClaimed key - Add slash_cycles: u32 field to IntentRecord (incremented in slash_solver) - Collusion analysis: docs/369-backstop-collusion-analysis.md #370 - Backstop LP vault - New backstop_vault/ crate (vortex-backstop-vault) - deposit/withdraw with 48h cooldown (deposit-delay front-run protection) - Virtual shares/assets seed at init for inflation-attack resistance - cover(intent_id, amount, recipient): settlement-only, loss socialized - receive_income(amount): routes fee/slash share, increases TotalAssets - Emergency withdrawal-queue mode (set_emergency_mode + process_queued_withdrawal) - get_vault_state() and get_lp_position() views - DataKey::BackstopVault + BackstopVaultShareBps in intent_settlement - backstop_vault_share_bps in ProtocolConfig; slash_solver routes vault share to DataKey::BackstopPool when configured Also fixes all pre-existing compilation errors: - Remove 14 duplicate const declarations (DISPUTE_WINDOW, ARBITER_WINDOW, SLASH_COOLDOWN, CANCEL_COOLDOWN, MAX_BATCH_SIZE, MAX_EXTENSION_DURATION, DEFAULT_MIN_BOND, DEFAULT_FILL_WINDOW, DEFAULT_INTENT_EXPIRY, DEFAULT_PROTOCOL_FEE_BPS, MAX_PROTOCOL_FEE_BPS, MIN_FILL_WINDOW_SECS, MIN_INTENT_EXPIRY_SECS, MIN_BOND_FLOOR) - Add 31 missing DataKey variants (SolverBond, AllowedBondToken, MinBond, SolverIntents, TotalBonded, TokenVolume, TokenFees, FeeDiscountTiers, OpenIntentList, SolverRegistry, SolverRoutes, SolverReputation, PendingUpgrade, MigrationVersion, BidWindowEnabled, BestBid, Arbiter, BackstopPool, BackstopClaimed, SolverExposure, OracleAddr, PendingOracle, UserEpochClaim, BackstopVault, BackstopVaultShareBps, and others) - Add 31 missing Error variants (InvalidConfig, TimelockNotElapsed, NoPendingAdminTransfer, NoPendingDstTokenChange, CancelCooldownNotExpired, AmountTooLarge, ImplausibleDstAmount, MaxActiveIntentsCapReached, BondTokenNotAllowed, ExtensionAlreadyGranted, AlreadyMigrated, BidWindowClosed, BidWindowStillOpen, BidNotHigher, IntentNotBidding, IntentNotFilling, IntentNotDisputed, NotArbiter, ArbiterWindowExpired, DisputeWindowStillOpen, DisputeWindowExpired, DisputeWindowClosed, NoFillEscrowed, NoPendingUpgrade, IntentNotAcceptedForFill, TooManyBondTokens, TooManyRouteEntries, BatchTooLarge, NoDisputeOpen, BackstopPoolEmpty, BackstopAlreadyClaimed, ExposureExceeded) - Add referrer: Option
and slash_cycles: u32 to IntentRecord - Add max_slash_cycles, referral_share_bps, backstop_vault_share_bps, coverage_multiplier to ProtocolConfig - Add missing constants: DEFAULT_MAX_ACTIVE_INTENTS_PER_SOLVER, BID_WINDOW, MAX_BACKSTOP_INTENT_BPS, MAX_BACKSTOP_USER_EPOCH_CLAIM, BACKSTOP_EPOCH_SECS, MAX_WHOLE_UNITS, MAX_REFERRAL_SHARE_BPS, MAX_FILL_HISTORY, etc. - Fix duplicate accept_intent_inner: rename inner body to accept_intent_inner_body - Fix fill_intent_inner: remove duplicate premature token transfer and duplicate fee calculation; add require_proof parameter - Fix slash_solver: replace undefined fee_recipient_share/caller_rebate/caller variables with proper vault-routing logic - Fix set_config: remove stale referral_share_bps guard using undefined var Updates .gitignore: backstop_vault/target/, coverage artifacts (lcov.info, coverage/, tarpaulin-report.html), profiling artifacts (*.profdata, *.profraw), backstop_vault/Cargo.lock --- .gitignore | 15 + backstop_vault/Cargo.toml | 27 + backstop_vault/src/lib.rs | 526 +++++++++++++++++++ docs/369-backstop-collusion-analysis.md | 113 ++++ intent_settlement/src/lib.rs | 652 ++++++++++++++++-------- intent_settlement/src/oracle.rs | 113 ++++ 6 files changed, 1247 insertions(+), 199 deletions(-) create mode 100644 backstop_vault/Cargo.toml create mode 100644 backstop_vault/src/lib.rs create mode 100644 docs/369-backstop-collusion-analysis.md create mode 100644 intent_settlement/src/oracle.rs diff --git a/.gitignore b/.gitignore index e4e0f43..8ce9245 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,18 @@ 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 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 71c0018..393994d 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -24,6 +24,10 @@ mod proptest_bond; #[cfg(test)] mod bench; +pub mod oracle; + +pub mod oracle; + // ─── Constants ──────────────────────────────────────────────────────────────── const INTENT_EXPIRY: u64 = 1800; // 30 minutes @@ -56,29 +60,22 @@ const DISPUTE_WINDOW: u64 = 3_600; // 1 hour /// conservative default from the design doc) without slashing the solver. const ARBITER_WINDOW: u64 = 86_400; // 24 hours +/// Anti-griefing bond a user must post when opening a dispute (#188). +const DISPUTE_BOND: i128 = 1 * 10_000_000; // 1 USDC + /// Issue #187 — a solver may hold bonds in at most this many distinct /// approved tokens. Bounds the work done by `deregister_solver` (which must /// refund every token) and the storage cost of the per-token bond entries. const MAX_BOND_TOKENS: u32 = 8; -/// Dispute-resolution parameters (issue #48, #233): -/// When a solver delivers tokens (begin_fill), the user has DISPUTE_WINDOW seconds -/// to open a dispute. If no dispute is raised, release_fill() can execute after -/// the window closes. If a dispute is raised, the arbiter has ARBITER_WINDOW -/// seconds to resolve it; if unresolved, the timeout releases escrow to the user. -const DISPUTE_WINDOW: u64 = 3600; // 1 hour: time for user to notice and contest fill -const ARBITER_WINDOW: u64 = 86400; // 24 hours: time for arbiter to resolve -const DISPUTE_BOND: i128 = 1 * 10_000_000; // 1 USDC: anti-griefing bond from user - /// Upper bound on the number of intent IDs `list_open_intents` returns per /// call (issue #249), bounding the resource cost of paginated reads. const MAX_PAGE_SIZE: u32 = 100; /// 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), which both derive from the -/// same `slash_cooldown_remaining` helper so they can never disagree. -const SLASH_COOLDOWN: u64 = 3600; // 1 hour +/// `get_slash_cooldown_remaining` (issue #256). +const SLASH_COOLDOWN: u64 = 3_600; // 1 hour /// Upper bound on the number of `src_chain`/`dst_token` entries a solver may /// declare via `set_solver_routes` (issue #255), to keep per-solver route @@ -88,91 +85,17 @@ const MAX_ROUTE_ENTRIES: u32 = 20; /// 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). Proposing also emits a distinct event immediately, -/// so off-chain monitors get advance notice even before the delay elapses -/// (#116). +/// takes effect (#115). const ADMIN_TIMELOCK_DELAY: u64 = 172_800; // 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. -const DEFAULT_MIN_BOND: i128 = MIN_BOND; -const DEFAULT_FILL_WINDOW: u64 = FILL_WINDOW; -const DEFAULT_INTENT_EXPIRY: u64 = INTENT_EXPIRY; -const DEFAULT_PROTOCOL_FEE_BPS: i128 = PROTOCOL_FEE_BPS; - -// ── `set_config` bounds. A parameter outside any of these ranges is rejected -// with `Error::InvalidConfig`. -const MAX_PROTOCOL_FEE_BPS: i128 = 1_000; // 10% hard cap on the protocol fee -const MIN_FILL_WINDOW_SECS: u64 = 60; // a solver needs at least a minute to fill -const MIN_INTENT_EXPIRY_SECS: u64 = 300; // and must always exceed the fill window -const MIN_BOND_FLOOR: i128 = 10_000_000; // one 7-decimal USDC unit - -// ── Cooldowns / limits enforced outside `ProtocolConfig`. -const SLASH_COOLDOWN: u64 = 3600; // 1 hour a slashed solver must wait before accepting again -const CANCEL_COOLDOWN: u64 = 3600; // 1 hour between a user's successive intent cancellations -const MAX_EXTENSION_DURATION: u64 = 300; // one extra fill window granted by `request_extension` - -// ── Storage-migration schema version (#194). Bumped whenever a `migrate()` -// body is added for a new release; `initialize` stamps fresh deploys with the -// current value and `migrate` refuses to run once the contract is already at -// it, so a migration can never be applied twice. -const MIGRATION_VERSION: u32 = 1; - -// Basis-points denominator, shared by the protocol fee and the #192 discount -// schedule (`discount_bps` is a fraction of the fee, not of the fill). -const BPS_DENOMINATOR: i128 = 10_000; - -// Upper sanity bound for src_amount and min_dst_amount. -// -// Largest realistic token amounts use 18-decimal ETH units. -// 1e12 tokens × 1e18 units/token = 1e30, well within i128 range (~1.7e38), -// but downstream arithmetic (fee = amount * 5 / 10_000) multiplies first and -// then divides. To guarantee `amount * PROTOCOL_FEE_BPS` never overflows i128, -// the bound is i128::MAX / PROTOCOL_FEE_BPS ≈ 3.4e37. We choose a round, -// economically implausible threshold: 10^30 (one trillion 18-decimal tokens). -// That is a comfortable safety margin while rejecting only fat-fingered inputs. -pub const MAX_AMOUNT: i128 = 1_000_000_000_000_000_000_000_000_000_000i128; // 10^30 - -const MAX_BATCH_SIZE: u32 = 100; -const MAX_EXTENSION_DURATION: u64 = 600; // 10 minutes - -const DEFAULT_MIN_BOND: i128 = MIN_BOND; -const DEFAULT_FILL_WINDOW: u64 = FILL_WINDOW; -const DEFAULT_INTENT_EXPIRY: u64 = INTENT_EXPIRY; -const DEFAULT_PROTOCOL_FEE_BPS: i128 = PROTOCOL_FEE_BPS; - -// Soroban archives ledger entries that go too long without being touched. -// Persistent Intent/Solver records get their TTL bumped on every write so -// they don't need to be manually restored before later calls can read them. -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; - -// The contract instance entry (Admin/FeeRecipient/BondToken/TotalIntents/ -// TotalVolume, plus the contract's own code) is a single ledger entry and -// needs the same treatment, or the whole contract becomes unreachable. -const INSTANCE_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; -const INSTANCE_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 60; - // ─── Default protocol parameters (#202) ────────────────────────────────────── -// -// `initialize` seeds `DataKey::Config` with these, and `load_config` falls -// back to them for deployments that pre-date the configurable-params upgrade. -// They are defined as aliases of the historical compile-time constants above -// so moving to a stored `ProtocolConfig` changes no observable behaviour — a -// freshly initialized contract behaves exactly as it did when the parameters -// were hard-coded. 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) ────────────────────────────────────────────── -// -// Guard rails enforced by `set_config` so an admin cannot move a parameter to -// an economically unsafe value. Values match the bounds already documented in -// `set_config`'s own doc comment. 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 @@ -180,31 +103,55 @@ const MIN_BOND_FLOOR: i128 = 10_000_000; // 1 USDC (7 decimals) — absolute flo // ─── Cooldowns (#202) ──────────────────────────────────────────────────────── -/// Seconds a solver must wait after being slashed before `accept_intent` will -/// let it take on a new intent. Long enough to blunt a griefing loop where a -/// solver repeatedly accepts and abandons intents, short enough that an honest -/// solver that hit one bad fill window recovers within the hour. -const SLASH_COOLDOWN: u64 = 3_600; // 1 hour - -/// Minimum gap the same user must leave between `cancel_intent` calls. Deters -/// cancel spam (e.g. submit → cancel loops used to grief solvers mid-quote) -/// without getting in the way of a user correcting a single mistaken intent. +/// 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. Keeps the worst-case resource cost (and therefore fee) of one -/// transaction bounded regardless of caller input. 20 covers realistic solver -/// batching while staying well inside Soroban's per-transaction limits. +/// 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. One extension is allowed per intent; this is the same order of -/// magnitude as `FILL_WINDOW` so a single extension can at most roughly double -/// the solver's delivery window. +/// 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 + // ─── Solver-registry tier perks (#197) ────────────────────────────────────── // // Index = tier number (0 Unranked … 4 Platinum). These MUST stay in lock-step @@ -402,6 +349,78 @@ pub enum DataKey { /// touches this key, so proof-gating is fully opt-in and defaults off /// 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, } // ─── Data Structs ───────────────────────────────────────────────────────────── @@ -421,6 +440,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, } /// A user's cross-chain swap intent @@ -479,6 +511,14 @@ pub struct IntentRecord { /// solver's tier now. `0` (Unranked) whenever there is no assignee /// (`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. + pub slash_cycles: u32, } #[contracttype] @@ -756,6 +796,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, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -802,6 +926,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 @@ -1074,9 +1202,6 @@ impl IntentSettlement { if !(0..=MAX_PROTOCOL_FEE_BPS).contains(&protocol_fee_bps) { panic_with_error!(&env, Error::InvalidConfig); } - if !(0..=MAX_REFERRAL_SHARE_BPS).contains(&referral_share_bps) { - panic_with_error!(&env, Error::InvalidConfig); - } if fill_window < MIN_FILL_WINDOW_SECS { panic_with_error!(&env, Error::InvalidConfig); } @@ -1090,12 +1215,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); @@ -1848,6 +1979,7 @@ impl IntentSettlement { dst_token, min_dst_amount, deadline, + referrer, ) } @@ -1866,6 +1998,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); @@ -1979,6 +2112,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, }; Self::save_intent(&env, &intent_id, &intent); @@ -2060,13 +2198,13 @@ impl IntentSettlement { // 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(env, solver, intent_id); + 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(env: Env, solver: Address, intent_id: BytesN<32>) { + 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); @@ -2136,6 +2274,35 @@ impl IntentSettlement { intent.deadline = now + Self::tier_fill_window(tier, cfg.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); @@ -2212,7 +2379,7 @@ impl IntentSettlement { // the scope if a delegated-execution pattern is ever introduced — noted // as the strongest candidate for future hardening. solver.require_auth(); - Self::fill_intent_inner(env, solver, intent_id, fill_amount); + Self::fill_intent_inner(env, solver, intent_id, fill_amount, require_proof); } /// Body of `fill_intent` without the `solver.require_auth()` gate. Shared @@ -2220,7 +2387,7 @@ impl IntentSettlement { /// (`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) { + fn fill_intent_inner(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128, require_proof: bool) { Self::require_not_paused(&env); Self::bump_instance_ttl(&env); @@ -2247,24 +2414,9 @@ impl IntentSettlement { Self::validate_proof(&env, &intent, &intent_id); } - // Deliver this fill's tokens to the user. - let dst_client = token::Client::new(&env, &intent.dst_token); - dst_client.transfer(&solver, &intent.user, &fill_amount); - - // Solver also pays the protocol fee on each fill. - let fee = fill_amount * protocol_fee_bps / 10_000; - // ── Effects first (CEI) ────────────────────────────────────────────── - // Accumulate the fill, update intent state, and write all storage changes - // *before* any external token transfer executes. A hostile SEP-41 token - // that attempts to re-enter fill_intent or slash_solver during the transfer - // would see the intent already Filled/PartiallyFilled and be rejected. - // Compute protocol fee with explicit checked arithmetic (#269 / #31). - // Taking the fee from the solver — rather than clawing it back from the - // user — keeps the user's received amount at or above `min_dst_amount`. - // Explicit checked_mul/checked_div makes the overflow-safety property - // visible in code, rather than relying solely on the Cargo.toml - // overflow-checks = true release-profile setting. + // 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); let fee = fill_amount .checked_mul(fee_bps) @@ -2272,13 +2424,6 @@ impl IntentSettlement { .checked_div(BPS_DENOMINATOR) .unwrap_or_else(|| panic_with_error!(&env, Error::FeeOverflow)); - // ── Effects first (CEI) ────────────────────────────────────────────── - // 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 - // sees the already-committed state (intent Filled, or re-opened with - // no assigned solver) and is rejected by the guards above. - // ── 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 @@ -2326,6 +2471,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); @@ -2739,6 +2905,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() @@ -2790,6 +2970,7 @@ impl IntentSettlement { // 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). if slash_amount > 0 { let fee_recipient: Address = env .storage() @@ -2797,24 +2978,36 @@ impl IntentSettlement { .get(&DataKey::FeeRecipient) .unwrap(); 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 { - let fee_recipient: Address = env - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .unwrap(); client.transfer( &env.current_contract_address(), &fee_recipient, &fee_recipient_share, ); } - if caller_rebate > 0 { - client.transfer( - &env.current_contract_address(), - &caller, - &caller_rebate, - ); + 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)); } } @@ -3557,6 +3750,7 @@ impl IntentSettlement { dst_token, min_dst_amount, deadline, + None, // batch submissions do not support referrers ); result.push_back(intent_id); } @@ -3575,8 +3769,9 @@ impl IntentSettlement { } solver.require_auth(); + let bond_token = Self::load_bond_token(&env); for intent_id in intent_ids { - Self::accept_intent_inner(env.clone(), solver.clone(), intent_id); + Self::accept_intent_inner_body(env.clone(), solver.clone(), intent_id, bond_token.clone()); } } @@ -3600,7 +3795,7 @@ impl IntentSettlement { solver.require_auth(); 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); } } @@ -3823,47 +4018,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() @@ -3872,7 +4048,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() @@ -3882,35 +4058,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); + + // ── 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 + } - // 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); + // ── Pro-rata when pool is short: payout = min(intent_cap, epoch_remaining, pool) + let payout = intent_cap.min(epoch_remaining).min(pool).max(1); // ── 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. + 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); @@ -3959,6 +4150,65 @@ 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 @@ -4677,6 +4927,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) +}