From c99989bf2ac61fecc9e37535c75a74c34786e1ee Mon Sep 17 00:00:00 2001 From: emtee emmanuel Date: Sun, 30 Aug 2026 23:55:18 +0000 Subject: [PATCH] refactor(contracts): split vault and blend adapter modules --- .../contracts/blend-adapter/src/errors.rs | 28 ++++ packages/contracts/blend-adapter/src/lib.rs | 152 ++---------------- packages/contracts/blend-adapter/src/types.rs | 96 +++++++++++ packages/contracts/vault/src/errors.rs | 59 +++++++ packages/contracts/vault/src/lib.rs | 119 ++------------ packages/contracts/vault/src/storage.rs | 51 ++++++ 6 files changed, 256 insertions(+), 249 deletions(-) create mode 100644 packages/contracts/blend-adapter/src/errors.rs create mode 100644 packages/contracts/blend-adapter/src/types.rs create mode 100644 packages/contracts/vault/src/errors.rs create mode 100644 packages/contracts/vault/src/storage.rs diff --git a/packages/contracts/blend-adapter/src/errors.rs b/packages/contracts/blend-adapter/src/errors.rs new file mode 100644 index 00000000..5fe64452 --- /dev/null +++ b/packages/contracts/blend-adapter/src/errors.rs @@ -0,0 +1,28 @@ +use adapter_common::AdapterError; +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + /// `initialize` was called on an adapter that already has a vault set. + AlreadyInitialized = 1, + /// An intermediate arithmetic operation would overflow `i128`. + Overflow = 2, + /// A state-mutating call was made before `initialize`. + NotInitialized = 3, +} + +impl From for ContractError { + fn from(err: AdapterError) -> Self { + match err { + AdapterError::AlreadyInitialized => ContractError::AlreadyInitialized, + } + } +} + +impl adapter_common::NotInitializedError for ContractError { + fn not_initialized() -> Self { + ContractError::NotInitialized + } +} diff --git a/packages/contracts/blend-adapter/src/lib.rs b/packages/contracts/blend-adapter/src/lib.rs index 0702b4f9..3cf8b676 100644 --- a/packages/contracts/blend-adapter/src/lib.rs +++ b/packages/contracts/blend-adapter/src/lib.rs @@ -1,14 +1,19 @@ #![no_std] -use adapter_common::{ - get_usdc, require_not_initialized, require_vault_auth, store_vault_and_usdc, AdapterError, +mod errors; +mod types; + +pub use errors::ContractError; +pub use types::{ + b_tokens_to_usdc, BlendPoolClient, BlendPoolInterface, Positions, RATE_SCALAR, Request, + Reserve, ReserveConfig, ReserveData, REQUEST_SUPPLY, REQUEST_WITHDRAW, }; + +use adapter_common::{get_usdc, require_not_initialized, require_vault_auth, store_vault_and_usdc}; use soroban_sdk::{ auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}, - contract, contractclient, contracterror, contractimpl, contracttype, panic_with_error, - symbol_short, - token::TokenClient, - vec, Address, Env, IntoVal, Map, Symbol, Val, Vec, + contract, contractimpl, panic_with_error, symbol_short, token::TokenClient, vec, Address, + Env, IntoVal, Symbol, Vec, }; // --------------------------------------------------------------------------- @@ -18,139 +23,6 @@ use soroban_sdk::{ const POOL_KEY: Symbol = symbol_short!("POOL"); const TOTAL_KEY: Symbol = symbol_short!("TOTAL"); -// Blend RequestType constants -const REQUEST_SUPPLY: u32 = 2; -const REQUEST_WITHDRAW: u32 = 3; - -// Fixed-point base Blend's own contracts use for `Reserve.data.b_rate` (the -// bToken-to-underlying-asset exchange rate). This is a protocol-wide constant -// independent of any particular asset's decimals, and must NOT be confused -// with `Reserve.scalar` (which is `10^decimals` for the underlying asset, -// e.g. 1e7 for USDC) — the two are unrelated despite superficially similar -// magnitudes for some assets, and dividing by the wrong one silently -// corrupts `total_assets()` by orders of magnitude. Verified empirically -// against real testnet reserve data: `b_tokens * b_rate / RATE_SCALAR` -// reproduced the deposited amount plus a plausible small yield delta, while -// dividing by `reserve.scalar` produced a ~100,000x inflated value. -const RATE_SCALAR: i128 = 1_000_000_000_000; - -// Converts a bToken amount to its underlying USDC value at the given -// `b_rate`, guarding the intermediate multiply against i128 overflow. -// Shared by accrue() and withdraw() so the two never drift apart. -fn b_tokens_to_usdc(b_tokens: i128, b_rate: i128) -> Result { - b_tokens - .checked_mul(b_rate) - .ok_or(ContractError::Overflow)? - .checked_div(RATE_SCALAR) - .ok_or(ContractError::Overflow) -} - -// --------------------------------------------------------------------------- -// Blend pool interface types -// --------------------------------------------------------------------------- - -#[contracttype] -#[derive(Clone)] -pub struct Request { - pub request_type: u32, - pub address: Address, - pub amount: i128, -} - -// Blend pool returns a Positions struct; we define it to satisfy the return -// type but do not use the value. The XDR layout must match Blend's definition. -#[contracttype] -pub struct Positions { - pub liabilities: Map, - pub collateral: Map, - pub supply: Map, -} - -// Mirrors Blend's ReserveConfig (blend-contracts-v2/pool/src/storage.rs). Field -// order does not need to match Blend's declaration since #[contracttype] -// structs encode as a name-keyed map, but names and types must match exactly. -#[contracttype] -pub struct ReserveConfig { - pub index: u32, - pub decimals: u32, - pub c_factor: u32, - pub l_factor: u32, - pub util: u32, - pub max_util: u32, - pub r_base: u32, - pub r_one: u32, - pub r_two: u32, - pub r_three: u32, - pub reactivity: u32, - pub supply_cap: i128, - pub enabled: bool, -} - -// Mirrors Blend's ReserveData. `b_rate` is the bToken-to-underlying-asset -// exchange rate, scaled by the reserve's `scalar` (see `Reserve` below). -#[contracttype] -pub struct ReserveData { - pub d_rate: i128, - pub b_rate: i128, - pub ir_mod: i128, - pub b_supply: i128, - pub d_supply: i128, - pub backstop_credit: i128, - pub last_time: u64, -} - -// Mirrors Blend's Reserve (the return type of `get_reserve`). -#[contracttype] -pub struct Reserve { - pub asset: Address, - pub config: ReserveConfig, - pub data: ReserveData, - pub scalar: i128, -} - -#[contractclient(name = "BlendPoolClient")] -pub trait BlendPoolInterface { - fn submit( - env: Env, - from: Address, - spender: Address, - to: Address, - requests: Vec, - ) -> Val; - fn get_reserve(env: Env, asset: Address) -> Reserve; - fn get_positions(env: Env, address: Address) -> Positions; -} - -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum ContractError { - /// `initialize` was called on an adapter that already has a vault set. - AlreadyInitialized = 1, - /// An intermediate arithmetic operation would overflow `i128`. - Overflow = 2, - /// A state-mutating call was made before `initialize`. - NotInitialized = 3, -} - -impl From for ContractError { - fn from(err: AdapterError) -> Self { - match err { - AdapterError::AlreadyInitialized => ContractError::AlreadyInitialized, - } - } -} - -impl adapter_common::NotInitializedError for ContractError { - fn not_initialized() -> Self { - ContractError::NotInitialized - } -} - // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -422,7 +294,7 @@ mod tests { contract, contractimpl, testutils::Address as _, token::{StellarAssetClient, TokenClient}, - Address, Env, + Address, Env, Map, Val, }; // ----------------------------------------------------------------------- diff --git a/packages/contracts/blend-adapter/src/types.rs b/packages/contracts/blend-adapter/src/types.rs new file mode 100644 index 00000000..13da5ffc --- /dev/null +++ b/packages/contracts/blend-adapter/src/types.rs @@ -0,0 +1,96 @@ +use soroban_sdk::{ + contractclient, contracttype, Address, Env, Map, Val, Vec, +}; + +use crate::errors::ContractError; + +// Blend RequestType constants +pub const REQUEST_SUPPLY: u32 = 2; +pub const REQUEST_WITHDRAW: u32 = 3; + +// Fixed-point base Blend's own contracts use for `Reserve.data.b_rate` (the +// bToken-to-underlying-asset exchange rate). This is a protocol-wide constant +// independent of any particular asset's decimals, and must NOT be confused +// with `Reserve.scalar` (which is `10^decimals` for the underlying asset, +// e.g. 1e7 for USDC) — the two are unrelated despite superficially similar +// magnitudes for some assets, and dividing by the wrong one silently +// corrupts `total_assets()` by orders of magnitude. Verified empirically +// against real testnet reserve data: `b_tokens * b_rate / RATE_SCALAR` +// reproduced the deposited amount plus a plausible small yield delta, while +// dividing by `reserve.scalar` produced a ~100,000x inflated value. +pub const RATE_SCALAR: i128 = 1_000_000_000_000; + +/// Converts a bToken amount to its underlying USDC value at the given +/// `b_rate`, guarding the intermediate multiply against i128 overflow. +/// Shared by accrue() and withdraw() so the two never drift apart. +pub fn b_tokens_to_usdc(b_tokens: i128, b_rate: i128) -> Result { + b_tokens + .checked_mul(b_rate) + .ok_or(ContractError::Overflow)? + .checked_div(RATE_SCALAR) + .ok_or(ContractError::Overflow) +} + +#[contracttype] +#[derive(Clone)] +pub struct Request { + pub request_type: u32, + pub address: Address, + pub amount: i128, +} + +#[contracttype] +pub struct Positions { + pub liabilities: Map, + pub collateral: Map, + pub supply: Map, +} + +#[contracttype] +pub struct ReserveConfig { + pub index: u32, + pub decimals: u32, + pub c_factor: u32, + pub l_factor: u32, + pub util: u32, + pub max_util: u32, + pub r_base: u32, + pub r_one: u32, + pub r_two: u32, + pub r_three: u32, + pub reactivity: u32, + pub supply_cap: i128, + pub enabled: bool, +} + +#[contracttype] +pub struct ReserveData { + pub d_rate: i128, + pub b_rate: i128, + pub ir_mod: i128, + pub b_supply: i128, + pub d_supply: i128, + pub backstop_credit: i128, + pub last_time: u64, +} + +#[contracttype] +pub struct Reserve { + pub asset: Address, + pub config: ReserveConfig, + pub data: ReserveData, + pub scalar: i128, +} + +#[contractclient(name = "BlendPoolClient")] +pub trait BlendPoolInterface { + fn submit( + env: Env, + from: Address, + spender: Address, + to: Address, + requests: Vec, + ) -> Val; + fn get_reserve(env: Env, asset: Address) -> Reserve; + fn get_positions(env: Env, address: Address) -> Positions; +} diff --git a/packages/contracts/vault/src/errors.rs b/packages/contracts/vault/src/errors.rs new file mode 100644 index 00000000..97bb9436 --- /dev/null +++ b/packages/contracts/vault/src/errors.rs @@ -0,0 +1,59 @@ +use soroban_sdk::contracterror; + +/// Typed error codes returned by fallible contract entry points. Callers and +/// off-chain indexers can match on the variant instead of parsing panic +/// strings, and the numeric discriminant is stable across ABI changes. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + /// `initialize` was called on a contract that already has an admin set. + AlreadyInitialized = 1, + /// A state-mutating call was made before `initialize`. + NotInitialized = 2, + /// `deposit` was called while `set_paused(true)` is in effect. + DepositsPaused = 3, + /// `deposit` or `withdraw` was called with a non-positive amount/share count. + ZeroAmount = 4, + /// The deposited amount rounds down to zero shares at the current price. + DepositTooSmall = 5, + /// `withdraw` was called while the vault has no shares outstanding. + NoSharesOutstanding = 6, + /// The caller does not hold enough mUSDC shares to burn. + InsufficientShares = 7, + /// The shares burned round down to zero USDC at the current price. + WithdrawalTooSmall = 8, + /// An intermediate arithmetic operation would overflow `i128`. + Overflow = 9, + /// `set_adapter` was called while the vault still has shares outstanding. + AdapterSwapUnsafe = 10, + /// `migrate_adapter` was called with the vault's current adapter as the + /// target. + SameAdapter = 11, + /// `migrate_adapter`'s post-migration value fell outside the caller's + /// `max_slippage_bps` tolerance of the pre-migration value. + MigrationValueDrift = 12, + /// `migrate_adapter` was called while the vault's current adapter has no + /// position to migrate. Distinct from `NoSharesOutstanding`: this checks + /// `ADPT_SH` (adapter-side shares), not `TOTAL_SH` (vault mUSDC shares), + /// and the two can desync. + NoAdapterPosition = 13, + /// `migrate_adapter` was called with `max_slippage_bps > 10_000`. + InvalidSlippageBps = 14, + /// `withdraw` was called with a `min_usdc_out` floor and the actual + /// amount out fell below it. Distinct from `WithdrawalTooSmall` (which + /// fires when `usdc_out` rounds to zero): this fires when `usdc_out > 0` + /// but the caller's slippage tolerance was not met — i.e. the + /// ADPT_SH/TOTAL_SH ratio shifted between when the caller estimated + /// their proceeds and when their transaction landed. + MinAmountOutNotMet = 15, + /// `accept_admin` was called with no pending nominee recorded (no + /// `transfer_admin` call has happened, or a previous nomination was + /// already accepted). + NoPendingAdmin = 16, + /// The adapter reported zero or negative total assets while the vault + /// still has shares outstanding, indicating a broken adapter or + /// malformed protocol response. Depositing would dilute all existing + /// holders. + AdapterReportedNoAssets = 17, +} diff --git a/packages/contracts/vault/src/lib.rs b/packages/contracts/vault/src/lib.rs index 2da40543..c7b0a118 100644 --- a/packages/contracts/vault/src/lib.rs +++ b/packages/contracts/vault/src/lib.rs @@ -1,32 +1,17 @@ #![no_std] -use soroban_sdk::{ - contract, contractclient, contracterror, contractimpl, contracttype, symbol_short, - token::{self, TokenClient}, - Address, Env, Symbol, -}; +mod errors; +mod storage; -// --------------------------------------------------------------------------- -// Storage keys -// --------------------------------------------------------------------------- +pub use errors::ContractError; +pub use storage::{ + clear_position_records, DataKey, ADAPTER, ADPT_SH, ADMIN, MUSDC, OFFSET, PAUSED, PEND_ADM, + TOTAL_SH, USDC, +}; -const ADMIN: Symbol = symbol_short!("ADMIN"); -const PEND_ADM: Symbol = symbol_short!("PEND_ADM"); -const USDC: Symbol = symbol_short!("USDC"); -const MUSDC: Symbol = symbol_short!("MUSDC"); -const ADAPTER: Symbol = symbol_short!("ADAPTER"); -const TOTAL_SH: Symbol = symbol_short!("TOTAL_SH"); -const ADPT_SH: Symbol = symbol_short!("ADPT_SH"); -const PAUSED: Symbol = symbol_short!("PAUSED"); - -// Virtual shares/assets offset (OpenZeppelin ERC-4626 mitigation against the -// first-depositor inflation attack). Share price is computed against -// `total_assets + OFFSET` over `total_shares + OFFSET` instead of the raw -// values. The virtual liquidity belongs to no one, so an attacker who donates -// assets directly to the adapter recovers only ~1/OFFSET of the donation, -// making the skim strictly unprofitable. For honest depositors the offset is -// negligible (1_000 stroops = 0.0001 USDC). -const OFFSET: i128 = 1_000; +use soroban_sdk::{ + contract, contractclient, contractimpl, token::{self, TokenClient}, Address, Env, Symbol, +}; // --------------------------------------------------------------------------- // Adapter interface @@ -59,90 +44,6 @@ pub trait YieldAdapterInterface { fn get_protocol(env: Env) -> Symbol; } -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - // Deliberately no per-address share balance. mUSDC is a normal - // transferable token, so an internal balance map is a second source of - // truth that a plain `transfer()` silently invalidates: the recipient - // could not withdraw (the map still said zero) and the sender could not - // either (the map let the check pass, then `burn` failed on tokens they - // no longer held), permanently stranding the position. Share ownership - // is read from the mUSDC token itself, which is the only balance the - // burn actually operates on. - Entry(Address), - // Cost basis: net USDC an address has deposited. Used to derive yield earned - // (current share value - principal). Reduced proportionally on withdrawal - // and cleared on a full exit. - // - // Unlike the share balance above, this is not derivable from any token: - // it is history (what was paid, and when), not a current holding. It - // therefore does not follow a transfer, see `get_principal`. - Principal(Address), -} - -/// Typed error codes returned by fallible contract entry points. Callers and -/// off-chain indexers can match on the variant instead of parsing panic -/// strings, and the numeric discriminant is stable across ABI changes. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum ContractError { - /// `initialize` was called on a contract that already has an admin set. - AlreadyInitialized = 1, - /// A state-mutating call was made before `initialize`. - NotInitialized = 2, - /// `deposit` was called while `set_paused(true)` is in effect. - DepositsPaused = 3, - /// `deposit` or `withdraw` was called with a non-positive amount/share count. - ZeroAmount = 4, - /// The deposited amount rounds down to zero shares at the current price. - DepositTooSmall = 5, - /// `withdraw` was called while the vault has no shares outstanding. - NoSharesOutstanding = 6, - /// The caller does not hold enough mUSDC shares to burn. - InsufficientShares = 7, - /// The shares burned round down to zero USDC at the current price. - WithdrawalTooSmall = 8, - /// An intermediate arithmetic operation would overflow `i128`. - Overflow = 9, - /// `set_adapter` was called while the vault still has shares outstanding. - AdapterSwapUnsafe = 10, - /// `migrate_adapter` was called with the vault's current adapter as the - /// target. - SameAdapter = 11, - /// `migrate_adapter`'s post-migration value fell outside the caller's - /// `max_slippage_bps` tolerance of the pre-migration value. - MigrationValueDrift = 12, - /// `migrate_adapter` was called while the vault's current adapter has no - /// position to migrate. Distinct from `NoSharesOutstanding`: this checks - /// `ADPT_SH` (adapter-side shares), not `TOTAL_SH` (vault mUSDC shares), - /// and the two can desync. - NoAdapterPosition = 13, - /// `migrate_adapter` was called with `max_slippage_bps > 10_000`. - InvalidSlippageBps = 14, - /// `withdraw` was called with a `min_usdc_out` floor and the actual - /// amount out fell below it. Distinct from `WithdrawalTooSmall` (which - /// fires when `usdc_out` rounds to zero): this fires when `usdc_out > 0` - /// but the caller's slippage tolerance was not met — i.e. the - /// ADPT_SH/TOTAL_SH ratio shifted between when the caller estimated - /// their proceeds and when their transaction landed. - MinAmountOutNotMet = 15, - /// `accept_admin` was called with no pending nominee recorded (no - /// `transfer_admin` call has happened, or a previous nomination was - /// already accepted). - NoPendingAdmin = 16, - /// The adapter reported zero or negative total assets while the vault - /// still has shares outstanding, indicating a broken adapter or - /// malformed protocol response. Depositing would dilute all existing - /// holders. - AdapterReportedNoAssets = 17, -} - // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- diff --git a/packages/contracts/vault/src/storage.rs b/packages/contracts/vault/src/storage.rs new file mode 100644 index 00000000..e70770bb --- /dev/null +++ b/packages/contracts/vault/src/storage.rs @@ -0,0 +1,51 @@ +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol}; + +// Storage keys +pub const ADMIN: Symbol = symbol_short!("ADMIN"); +pub const PEND_ADM: Symbol = symbol_short!("PEND_ADM"); +pub const USDC: Symbol = symbol_short!("USDC"); +pub const MUSDC: Symbol = symbol_short!("MUSDC"); +pub const ADAPTER: Symbol = symbol_short!("ADAPTER"); +pub const TOTAL_SH: Symbol = symbol_short!("TOTAL_SH"); +pub const ADPT_SH: Symbol = symbol_short!("ADPT_SH"); +pub const PAUSED: Symbol = symbol_short!("PAUSED"); + +// Virtual shares/assets offset (OpenZeppelin ERC-4626 mitigation against the +// first-depositor inflation attack). Share price is computed against +// `total_assets + OFFSET` over `total_shares + OFFSET` instead of the raw +// values. The virtual liquidity belongs to no one, so an attacker who donates +// assets directly to the adapter recovers only ~1/OFFSET of the donation, +// making the skim strictly unprofitable. For honest depositors the offset is +// negligible (1_000 stroops = 0.0001 USDC). +pub const OFFSET: i128 = 1_000; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + // Deliberately no per-address share balance. mUSDC is a normal + // transferable token, so an internal balance map is a second source of + // truth that a plain `transfer()` silently invalidates: the recipient + // could not withdraw (the map still said zero) and the sender could not + // either (the map let the check pass, then `burn` failed on tokens they + // no longer held), permanently stranding the position. Share ownership + // is read from the mUSDC token itself, which is the only balance the + // burn actually operates on. + Entry(Address), + // Cost basis: net USDC an address has deposited. Used to derive yield earned + // (current share value - principal). Reduced proportionally on withdrawal + // and cleared on a full exit. + // + // Unlike the share balance above, this is not derivable from any token: + // it is history (what was paid, and when), not a current holding. It + // therefore does not follow a transfer, see `get_principal`. + Principal(Address), +} + +pub fn clear_position_records(env: &Env, address: &Address) { + env.storage() + .persistent() + .remove(&DataKey::Entry(address.clone())); + env.storage() + .persistent() + .remove(&DataKey::Principal(address.clone())); +}