use std::{collections::BTreeSet, num::NonZeroU8}; use near_sdk::{ env, json_types::{U128, U64}, near, require, AccountId, AccountIdRef, Gas, Promise, PromiseOrValue, }; use crate::{ asset::{BorrowAsset, FungibleAsset}, supply::SupplyPosition, }; pub type TimestampNs = u64; pub const MIN_TIMELOCK_NS: u64 = 0; pub const MAX_TIMELOCK_NS: u64 = 30 * 86_400_000_000_000; // 30 days pub const MAX_QUEUE_LEN: usize = 64; pub type ExpectedIdx = u32; pub type ActualIdx = u32; pub type AllocationWeights = Vec<(AccountId, U128)>; pub type AllocationPlan = Vec<(AccountId, u128)>; /// Parsed from the string parameter `msg` passed by `*_transfer_call` to /// `*_on_transfer` calls. #[near(serializers = [json])] pub enum DepositMsg { /// Add the attached tokens to the sender's vault position. Supply, } /// Concrete configuration for a market. #[derive(Clone, Default, Debug)] #[near] pub struct MarketConfiguration { /// Supply cap for this market (in underlying asset units) pub cap: U128, /// Whether market is enabled for deposits/withdrawals pub enabled: bool, /// Timestamp (ns) after which market can be removed (if pending removal) pub removable_at: TimestampNs, } /// Configuration for the setup of a metavault. #[derive(Clone)] #[near(serializers = [json, borsh])] pub struct VaultConfiguration { /// The account that owns this vault. pub owner: AccountId, /// The account that can submit allocation plans. See [AllocationMode]. pub curator: AccountId, /// The account that can set guardianship. See [AllocationMode]. pub guardian: AccountId, /// The underlying asset for this vault. pub underlying_token: FungibleAsset, /// The initial timelock for this vault used for modifying the configuration. pub initial_timelock_ns: U64, /// The account that receives fees for this vault. pub fee_recipient: AccountId, /// The skim account that can unorphan any assets erroneously sent to this vault. pub skim_recipient: AccountId, /// The name of the share token. pub name: String, /// The symbol of the share token. pub symbol: String, /// The number of decimals for the share token, usually would be the same as the underlying asset. pub decimals: NonZeroU8, /// Restrictions for this market. pub restrictions: Option, } /// Restrictions that can be applied to the vault. /// /// It should cover both Whitelist style functionality and Blacklist style functionality. /// It should also enable Pausing #[near(serializers = [borsh, json])] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Restrictions { Paused, BlackList(BTreeSet), WhiteList(BTreeSet), } impl Restrictions { /// Check if the account is restricted, and if so, what is the reason pub fn is_restricted(&self, account_id: &AccountIdRef) -> Option { match self { Restrictions::Paused => Some(Restrictions::Paused), Restrictions::BlackList(blacklist) => { if blacklist.contains(account_id) { Some(Restrictions::BlackList(blacklist.clone())) } else { None } } Restrictions::WhiteList(whitelist) => { if whitelist.contains(account_id) || account_id == env::current_account_id() { None } else { Some(Restrictions::WhiteList(whitelist.clone())) } } } } } #[near_sdk::ext_contract(ext_vault)] pub trait VaultExt { // Role and admin fn set_curator(account: AccountId); fn set_is_allocator(account: AccountId, allowed: bool); fn submit_guardian(new_g: AccountId); fn accept_guardian(); fn revoke_pending_guardian(); fn set_skim_recipient(account: AccountId); fn set_fee_recipient(account: AccountId); fn set_performance_fee(fee: U128); fn submit_timelock(new_timelock_ns: U64); fn accept_timelock(); fn revoke_pending_timelock(); // Market config and queues fn submit_cap(market: AccountId, new_cap: U128); fn accept_cap(market: AccountId); fn revoke_pending_cap(market: AccountId); fn submit_market_removal(market: AccountId); fn revoke_pending_market_removal(market: AccountId); fn set_supply_queue(markets: Vec); fn set_withdraw_queue(queue: Vec); // User flows fn withdraw(amount: U128, receiver: AccountId) -> PromiseOrValue<()>; fn redeem(shares: U128, receiver: AccountId) -> PromiseOrValue<()>; fn execute_next_withdrawal_request() -> PromiseOrValue<()>; fn skim(token: AccountId) -> Promise; fn allocate(weights: AllocationWeights, amount: Option) -> PromiseOrValue<()>; // Views fn get_configuration() -> VaultConfiguration; fn get_total_assets() -> U128; fn get_total_supply() -> U128; fn get_max_deposit() -> U128; fn convert_to_shares(assets: U128) -> U128; fn convert_to_assets(shares: U128) -> U128; fn preview_deposit(assets: U128) -> U128; fn preview_mint(shares: U128) -> U128; fn preview_withdraw(assets: U128) -> U128; fn preview_redeem(shares: U128) -> U128; } // Add a 20% buffer to a gas estimate #[must_use] pub const fn buffer(size: u64) -> Gas { Gas::from_tgas((size * 6).div_ceil(5)) } // Fetching a position const GET_SUPPLY_POSITION: u64 = 4; pub const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(GET_SUPPLY_POSITION); // Create a withdrawal request pub const CREATE_WITHDRAW_REQ_GAS: Gas = buffer(5); // Balance reads against the underlying NEP-141 pub const FT_BALANCE_OF_GAS: Gas = Gas::from_tgas(5); // Execute the next withdrawal request on a market const EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ: u64 = 20; pub const EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); // Extra gas reserved for post-supply verification callbacks, used in // paths where we want a conservative safety margin beyond the base // estimate. pub const SUPPLY_POST_VERIFY_GAS: Gas = Gas::from_tgas(30); // Callback gas roots for withdraw/supply orchestration. // Root budget for callbacks after creating a market-side // supply-withdrawal request. Encodes: create request, read supply // position and settle withdraw accounting. pub const WITHDRAW_CREATE_REQUEST_CALLBACK_GAS: Gas = buffer(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ + AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); // Budget for the final "settle" phase of a withdraw execution: // reconcile principal and idle_balance, and potentially transition to // payout or the next market. const AFTER_EXECUTE_NEXT_WITHDRAW: u64 = 5 + 5 + AFTER_SEND_TO_USER; pub const WITHDRAW_SETTLE_CALLBACK_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_WITHDRAW); // Budget for executing the next supply-withdrawal request on a market // and fetching the updated supply position before the settle step. const AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ: u64 = GET_SUPPLY_POSITION + AFTER_EXECUTE_NEXT_WITHDRAW; pub const WITHDRAW_EXECUTE_FETCH_POSITION_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); const AFTER_SUPPLY_2_READ: u64 = 5; pub const SUPPLY_POSITION_READ_CALLBACK_GAS: Gas = buffer(AFTER_SUPPLY_2_READ); pub const SUPPLY_AFTER_TRANSFER_CHECK_GAS: Gas = buffer(GET_SUPPLY_POSITION + AFTER_SUPPLY_2_READ); // NOTE: these are taken after running the contract with the gas report and cieled to next whole TGAS. pub const SUPPLY_GAS: Gas = buffer(8); pub const ALLOCATE_GAS: Gas = buffer(20); pub const WITHDRAW_GAS: Gas = buffer(4); pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); pub const SUBMIT_CAP_GAS: Gas = buffer(3); const AFTER_SEND_TO_USER: u64 = 5; pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(AFTER_SEND_TO_USER); pub fn require_at_least(needed: Gas) { let gas = env::prepaid_gas(); require!( gas >= needed, format!("Insufficient gas: {}, needed: {needed}", gas) ); } #[derive(Clone, Debug)] #[near] pub struct PendingValue { pub value: T, // Timestamp when this pending value can be finalized pub valid_at_ns: TimestampNs, } impl PendingValue { pub fn verify(&self) { require!( near_sdk::env::block_timestamp() >= self.valid_at_ns, "Timelock not elapsed yet" ); } } #[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [borsh])] /// No operation in-flight. The vault is ready to start a new allocation or withdrawal. pub struct IdleState; #[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [borsh])] /// Supplying idle underlying to markets according to a plan or queue. /// /// Transitions: /// - On completion of allocation: Withdrawing (to satisfy pending user requests) or Idle (if stopped). /// - On stop/failure: Idle. pub struct AllocatingState { /// Unique operation id used to correlate async callbacks and detect drift. pub op_id: u64, /// Zero-based position within the allocation plan/queue currently being processed. pub index: u32, /// Amount of underlying (in asset units) still to allocate during this operation. pub remaining: u128, /// Plan for allocation. pub plan: Vec<(AccountId, u128)>, } #[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [borsh])] /// Collecting liquidity from markets to satisfy a user withdrawal/redeem request. /// /// Transitions: /// - Advance within queue: Withdrawing (index increments) while collecting funds. /// - When enough is collected to satisfy the request: Payout. /// - If the op is stopped or cannot proceed and needs to refund: Idle (escrow_shares refunded). pub struct WithdrawingState { /// Unique operation id used to correlate async callbacks and detect drift. pub op_id: u64, /// Zero-based position within the withdraw queue currently being processed. pub index: u32, /// Remaining assets that must still be collected to satisfy the request. pub remaining: u128, /// Assets already collected and held as idle_balance pending payout. pub collected: u128, /// Account that should receive the assets during payout. pub receiver: AccountId, /// The owner whose shares are being redeemed. pub owner: AccountId, /// Shares locked in escrow for this request. /// - Refunded on stop/failure. /// - On payout success, a portion is burned (see burn_shares) and any remainder is refunded. pub escrow_shares: u128, } #[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [borsh])] /// Final step that transfers assets to the receiver and settles the share escrow. /// /// Transitions: /// - On success or failure: Idle. /// /// Invariant hooks: /// - idle_balance decreases only on payout success by `amount`. /// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded. /// - On failure, all `escrow_shares` are refunded. pub struct PayoutState { /// Unique operation id used to correlate async callbacks and detect drift. pub op_id: u64, /// Receiver of the asset payout. pub receiver: AccountId, /// Amount of assets to transfer out from idle_balance. pub amount: u128, /// The owner whose shares were escrowed for this payout. pub owner: AccountId, /// Total shares currently held in escrow for this operation. pub escrow_shares: u128, /// Portion of `escrow_shares` that will be burned on successful payout. pub burn_shares: u128, } #[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [borsh])] /// Operation state machine for asynchronous allocation, withdrawal, and payout flows. /// /// State machine: /// - Allocating -> Withdrawing (or Idle via stop) /// - Withdrawing -> Withdrawing (advance) | Payout | Idle (refund) /// - Payout -> Idle (success or failure) /// /// Invariants: /// - idle_balance increases only when funds are received and decreases only on payout success. /// - escrow_shares are refunded on stop/failure or partially burned/refunded on payout success. pub enum OpState { /// No operation in-flight. The vault is ready to start a new allocation or withdrawal. Idle, /// Supplying idle underlying to markets according to a plan or queue. /// /// Transitions: /// - On completion of allocation: Withdrawing (to satisfy pending user requests) or Idle (if stopped). /// - On stop/failure: Idle. Allocating(AllocatingState), /// Collecting liquidity from markets to satisfy a user withdrawal/redeem request. /// /// Transitions: /// - Advance within queue: Withdrawing (index increments) while collecting funds. /// - When enough is collected to satisfy the request: Payout. /// - If the op is stopped or cannot proceed and needs to refund: Idle (escrow_shares refunded). Withdrawing(WithdrawingState), /// Final step that transfers assets to the receiver and settles the share escrow. /// /// Transitions: /// - On success or failure: Idle. /// /// Invariant hooks: /// - idle_balance decreases only on payout success by `amount`. /// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded. /// - On failure, all `escrow_shares` are refunded. Payout(PayoutState), } impl From for OpState { fn from(_: IdleState) -> Self { OpState::Idle } } impl From for OpState { fn from(s: AllocatingState) -> Self { OpState::Allocating(s) } } impl From for OpState { fn from(s: WithdrawingState) -> Self { OpState::Withdrawing(s) } } impl From for OpState { fn from(s: PayoutState) -> Self { OpState::Payout(s) } } impl AsRef for OpState { fn as_ref(&self) -> &IdleState { match self { OpState::Idle => &IdleState, _ => panic!("OpState::Idle expected"), } } } impl AsRef for OpState { fn as_ref(&self) -> &AllocatingState { match self { OpState::Allocating(s) => s, _ => panic!("OpState::Allocating expected"), } } } impl AsRef for OpState { fn as_ref(&self) -> &WithdrawingState { match self { OpState::Withdrawing(s) => s, _ => panic!("OpState::Withdrawing expected"), } } } impl AsRef for OpState { fn as_ref(&self) -> &PayoutState { match self { OpState::Payout(s) => s, _ => panic!("OpState::Payout expected"), } } } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub struct Delta { pub market: AccountId, pub amount: U128, } impl Delta { pub fn new>(market: AccountId, amount: T) -> Self { Delta { market, amount: amount.into(), } } pub fn validate(&self) { require!(self.amount.0 > 0, "Delta amount must be greater than zero"); } } // + Supply: forward-supply idle assets to a market // - Withdraw: ONLY creates a supply-withdrawal request in the market; does not execute it. #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum AllocationDelta { Supply(Delta), Withdraw(Delta), } impl AsRef for AllocationDelta { fn as_ref(&self) -> &Delta { match self { AllocationDelta::Supply(d) | AllocationDelta::Withdraw(d) => d, } } } #[derive(Debug, Clone, Copy)] pub struct EscrowSettlement { pub to_burn: u128, pub refund: u128, } impl EscrowSettlement { pub fn new(escrow_shares: u128, burn_shares: u128) -> Self { let to_burn = burn_shares.min(escrow_shares); let refund = escrow_shares.saturating_sub(to_burn); Self { to_burn, refund } } } impl From for (u128, u128) { fn from(tuple: EscrowSettlement) -> Self { (tuple.to_burn, tuple.refund) } } #[derive(Debug)] #[near(serializers = [json])] pub enum Error { // Invariant: Index drift or stale op_id results in a graceful stop IndexDrifted(ExpectedIdx, ActualIdx), // Invariant: Attempting to work on a market that is missing from the withdraw queue MissingMarket(u32), NotWithdrawing, NotAllocating, MarketTransferFailed, MissingSupplyPosition, PositionReadFailed, BalanceReadFailed, // Insufficient liquidity across all markets to satisfy withdrawal InsufficientLiquidity, ZeroAmount, } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{self:?}") } } #[derive(Clone, Debug)] #[near(serializers = [borsh])] pub struct PendingWithdrawal { pub owner: AccountId, pub receiver: AccountId, pub escrow_shares: u128, pub expected_assets: u128, pub requested_at: u64, } impl PendingWithdrawal { #[must_use] pub fn encoded_size() -> u64 { storage_bytes_for_account_id() + storage_bytes_for_account_id() + 16 // escrow_shares: u128 + 16 // expected_assets: u128 + 8 // requested_at: u64 } } // Worst case size encoded for AccountId #[must_use] pub const fn storage_bytes_for_account_id() -> u64 { // 4 bytes for length prefix + worst case size encoded for AccountId 4 + AccountId::MAX_LEN as u64 } #[derive(Clone, Debug)] #[near(serializers = [borsh, json])] pub enum IdleBalanceDelta { Increase(U128), Decrease(U128), } impl IdleBalanceDelta { pub fn apply(&self, balance: u128) -> u128 { let new = match self { IdleBalanceDelta::Increase(amount) => balance.saturating_add(amount.0), IdleBalanceDelta::Decrease(amount) => balance.saturating_sub(amount.0), }; Event::IdleBalanceUpdated { prev: U128::from(balance), delta: self.clone(), } .emit(); new } } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum Reason { NoRoom, ZeroTarget, Other(String), } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum QueueAction { Dequeued, Parked, } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum QueueStatus { NextFound, Empty, } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum WithdrawProgressPhase { ExecutionStarted, SkippedDust, CoveredByIdle, ExecutionRequired, } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum AllocationPositionIssueKind { Missing, ReadFailed, } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum WithdrawalAccountingKind { InflowMismatch, OverpayCredited, } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum PositionReportOutcome { Ok, Missing, ReadFailed, } #[derive(Debug, Clone)] #[near(serializers = [borsh, json])] pub enum UnbrickPhase { Withdrawing, Payout, } #[near(event_json(standard = "templar-vault"))] pub enum Event { #[event_version("1.0.0")] IdleBalanceUpdated { prev: U128, delta: IdleBalanceDelta }, #[event_version("1.0.0")] PerformanceFeeAccrued { recipient: AccountId, shares: U128 }, #[event_version("1.0.0")] PerformanceFeeMintFailed { error: String }, #[event_version("1.0.0")] LockChange { is_locked: bool, market_index: u32 }, // Allocation #[event_version("1.0.0")] AllocationPlanSet { op_id: U64, total: U128, plan: Vec<(AccountId, U128)>, }, #[event_version("1.0.0")] AllocationStarted { op_id: U64, remaining: U128 }, #[event_version("1.0.0")] AllocationStepPlan { op_id: U64, index: u32, market: AccountId, target: U128, room: U128, to_supply: U128, remaining_before: U128, planned: bool, reason: Option, }, #[event_version("1.0.0")] AllocationTransferFailed { op_id: U64, index: u32, market: AccountId, attempted: U128, }, #[event_version("1.0.0")] AllocationStepSettled { op_id: U64, index: u32, market: AccountId, before: U128, new_principal: U128, accepted: U128, attempted: U128, refunded: U128, remaining_after: U128, }, #[event_version("1.0.0")] AllocationCompleted { op_id: u64 }, #[event_version("1.0.0")] AllocationStopped { op_id: U64, index: u32, remaining: U128, reason: Option, }, // Admin and configuration events #[event_version("1.0.0")] CuratorSet { account: AccountId }, #[event_version("1.0.0")] GuardianSet { account: AccountId }, #[event_version("1.0.0")] AllocatorRoleSet { account: AccountId, allowed: bool }, #[event_version("1.0.0")] SkimRecipientSet { account: AccountId }, #[event_version("1.0.0")] FeeRecipientSet { account: AccountId }, #[event_version("1.0.0")] PerformanceFeeSet { fee: U128 }, #[event_version("1.0.0")] TimelockSet { seconds: U64 }, #[event_version("1.0.0")] TimelockChangeSubmitted { valid_at_ns: U64 }, #[event_version("1.0.0")] PendingTimelockRevoked, #[event_version("1.0.0")] Abdicated { method_name: String }, // Market and queue management #[event_version("1.0.0")] MarketCreated { market: AccountId }, #[event_version("1.0.0")] MarketEnabled { market: AccountId }, #[event_version("1.0.0")] MarketRemovalSubmitted { market: AccountId, removable_at: U64, }, #[event_version("1.0.0")] MarketRemovalRevoked { market: AccountId }, #[event_version("1.0.0")] SupplyCapRaiseSubmitted { market: AccountId, new_cap: U128, valid_at_ns: u64, }, #[event_version("1.0.0")] SupplyCapRaiseRevoked { market: AccountId }, #[event_version("1.0.0")] SupplyCapSet { market: AccountId, new_cap: U128 }, #[event_version("1.0.0")] WithdrawQueueUpdate { action: QueueAction, id: U64 }, #[event_version("1.0.0")] WithdrawQueueStatus { status: QueueStatus, id: Option, }, // Rebalance-only withdraw flows #[event_version("1.0.0")] RebalanceWithdrawCompleted { op_id: U64, market: AccountId }, #[event_version("1.0.0")] RebalanceWithdrawStopped { op_id: U64, market: AccountId, reason: Option, }, // User flows #[event_version("1.0.0")] RedeemRequested { shares: U128, estimated_assets: U128, }, #[event_version("1.0.0")] WithdrawalQueued { id: U64, owner: AccountId, receiver: AccountId, escrow_shares: U128, expected_assets: U128, requested_at: U64, }, #[event_version("1.0.0")] WithdrawPreview { shares: U128, receiver: AccountId }, #[event_version("1.0.0")] WithdrawProgress { phase: WithdrawProgressPhase, op_id: Option, id: Option, market_index: Option, owner: Option, receiver: Option, escrow_shares: Option, expected_assets: Option, requested_at: Option, }, #[event_version("1.0.0")] SupplyWithdrawRequestCreated { market: AccountId, amount: U128 }, #[event_version("1.0.0")] WithdrawRequestCreated { market: AccountId, amount: U128 }, #[event_version("1.0.0")] // Allocation read/settlement diagnostics #[event_version("1.0.0")] AllocationPositionIssue { op_id: U64, index: u32, market: AccountId, attempted: U128, accepted: U128, kind: AllocationPositionIssueKind, }, // Withdrawal read diagnostics #[event_version("1.0.0")] CreateWithdrawalFailed { op_id: U64, market: AccountId, index: u32, need: U128, }, #[event_version("1.0.0")] WithdrawalAccounting { kind: WithdrawalAccountingKind, op_id: U64, market: AccountId, index: u32, delta: Option, inflow: Option, extra: Option, }, // Payout and stop diagnostics #[event_version("1.0.0")] PayoutUnexpectedState { op_id: U64, receiver: AccountId, amount: U128, }, #[event_version("1.0.0")] WithdrawalStopped { op_id: U64, index: u32, remaining: U128, collected: U128, reason: Option, }, #[event_version("1.0.0")] PayoutStopped { op_id: U64, receiver: AccountId, amount: U128, reason: Option, }, #[event_version("1.0.0")] OperationStoppedWhileIdle { reason: Option }, #[event_version("1.0.0")] UnbrickInvoked { phase: UnbrickPhase, op_id: Option, id: Option, }, #[event_version("1.0.0")] WithdrawPositionReport { outcome: PositionReportOutcome, op_id: U64, market: AccountId, index: u32, position: Option, before: Option, }, #[event_version("1.0.0")] VaultBalance { amount: U128 }, } #[derive(Default)] #[near(serializers = [borsh, serde])] pub struct Locker { to_lock: Vec, } impl Locker { pub fn lock(&mut self, i: u32) { if self.is_locked(i) { env::panic_str("Market is locked for index"); } Event::LockChange { is_locked: true, market_index: i, } .emit(); self.to_lock.push(i); } pub fn unlock(&mut self, i: u32) { Event::LockChange { is_locked: false, market_index: i, } .emit(); self.to_lock.retain(|&x| x != i); } /// Clears the lock status for all markets. /// This method should be used with caution as it will unlock all markets pub fn clear(&mut self) { self.to_lock.clear(); } pub fn is_locked(&self, i: u32) -> bool { self.to_lock.contains(&i) } pub fn is_locked_all(&self) -> bool { !self.to_lock.is_empty() } }