Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/contracts/blend-adapter/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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<AdapterError> 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
}
}
19 changes: 12 additions & 7 deletions packages/contracts/blend-adapter/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -434,7 +439,7 @@ mod tests {
contract, contractimpl,
testutils::{Address as _, Events},
token::{StellarAssetClient, TokenClient},
Address, Env,
Address, Env, Map, Val,
};

// -----------------------------------------------------------------------
Expand Down
96 changes: 96 additions & 0 deletions packages/contracts/blend-adapter/src/types.rs
Original file line number Diff line number Diff line change
@@ -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<i128, ContractError> {
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<u32, i128>,
pub collateral: Map<u32, i128>,
pub supply: Map<u32, i128>,
}

#[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<Request>,
) -> Val;
fn get_reserve(env: Env, asset: Address) -> Reserve;
fn get_positions(env: Env, address: Address) -> Positions;
}
59 changes: 59 additions & 0 deletions packages/contracts/vault/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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,
}
35 changes: 10 additions & 25 deletions packages/contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 51 additions & 0 deletions packages/contracts/vault/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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()));
}