#![allow(clippy::needless_pass_by_value)] use crate::{ aum::AUM, governance::Abdicator, governance::Gate, governance::Timelocks, storage_management::{require_attached_at_least, require_attached_for_pending_withdrawal}, }; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, json_types::{U128, U64}, near, require, serde_json, store::IterableMap, AccountId, BorshStorageKey, Gas, IntoStorageKey, NearToken, PanicOnDefault, Promise, PromiseOrValue, }; use near_sdk_contract_tools::{ ft::{ nep141::GAS_FOR_FT_TRANSFER_CALL, nep145::Nep145ForceUnregister, ContractMetadata, FungibleToken, Nep141Controller, Nep141Mint, Nep141Transfer, Nep145 as _, Nep145Controller, Nep148Controller, StorageBalanceBounds, }, Owner, Rbac, }; use near_sdk_contract_tools::{owner::Owner, rbac}; use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, num::NonZeroU8, }; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, market::ext_market, panic_with_message, vault::{ require_at_least, AllocatingState, AllocationDelta, AllocationPlan, Error, Event, IdleBalanceDelta, Locker, MarketConfiguration, OpState, PayoutState, PendingWithdrawal, QueueAction, QueueStatus, Reason, TimestampNs, UnbrickPhase, VaultConfiguration, WithdrawProgressPhase, WithdrawingState, AFTER_SEND_TO_USER_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, FT_BALANCE_OF_GAS, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, SUPPLY_AFTER_TRANSFER_CHECK_GAS, WITHDRAW_CREATE_REQUEST_CALLBACK_GAS, }, }; pub use wad::*; pub mod aum; pub mod governance; pub mod impl_callbacks; pub mod impl_token_receiver; pub mod storage_management; pub mod wad; #[cfg(test)] mod test_utils; #[derive(Debug, Clone)] #[near(serializers = [borsh])] #[derive(BorshStorageKey)] /// Internal storage keys used by persistent collections. pub enum StorageKey { PendingWithdrawals, } #[derive(BorshStorageKey)] #[near] /// Role-based access control roles for privileged actions. pub enum Role { /// Primary operator for market configuration and policy. /// Can submit/accept cap changes and market removals, and is implicitly granted the Allocator role. Curator, /// Safety backstop that can revoke pending governance changes (e.g., timelock/guardian). /// Has no authority to change caps or the supply queue on its own. Guardian, /// Operational role for allocation and withdrawal execution. /// May set the supply_queue while the vault is Idle; cannot modify caps/timelocks/guardian. Allocator, } #[near(serializers = [borsh])] #[derive(Debug, Clone, Default)] pub struct MarketRecord { pub cfg: MarketConfiguration, pub principal: u128, } impl From for MarketRecord { fn from(cfg: MarketConfiguration) -> Self { Self { cfg, principal: 0 } } } #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] #[fungible_token(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] #[near(contract_state)] /// Vault contract that issues shares over an underlying fungible asset and allocates liquidity /// across configured markets. Implements 4626-like deposit/withdraw semantics. /// /// What this contract does /// - Issues a share token (NEP-141) that represents a vault over an underlying NEP-141 “BorrowAsset”. /// - Allocates deposits across “markets” via a supply queue. /// - User withdrawals are enqueued in a FIFO pending-withdrawals queue, while the /// actual market route for each withdrawal is keeper-routed and provided per /// execution (no persistent global withdraw route). /// - Governance uses Owner + RBAC (Curator/Guardian/Allocator) with a timelock for certain changes. /// - Withdraw flow escrows shares, builds market-side withdrawal requests, then pays out and burns proportional escrow. /// - Performance fees accrue by minting fee shares based on increases in total assets. /// /// Critical invariants /// - Assets accounting is correct: total_assets = idle_balance + sum(all principals in markets). /// - Only one op in flight (op_state); mutating ops require Idle. /// - Governance changes obey timelocks; Guardian may revoke pending changes. /// /// Note: RBAC storage is paid by the contract; callers are not charged deposits for RBAC changes. pub struct Contract { /// The underlying asset that the vault manages underlying_asset: FungibleAsset, /// The process in which the vault calculates its assets under management aum: AUM, /// Performance fee performance_fee: Wad, /// The recipient of performance fees fee_recipient: AccountId, /// The recipient of any skimmed tokens that are erroneously held by the vault skim_recipient: AccountId, /// Last recorded total assets (for fee accrual) last_total_assets: u128, /// Vaults liquidity buffer idle_balance: u128, /// The vault's operation state op_state: OpState, /// The next operation id next_op_id: u64, /// Virtual offsets used only in conversions/previews to harden edge cases virtual_shares: u128, virtual_assets: u128, /// Markets controlled by the vault markets: BTreeMap, /// Per‑action governance timelock configuration. governance_timelocks: Timelocks, /// Ordered list of market IDs for deposit allocation supply_queue: BTreeSet, /// Pending withdrawals queue pending_withdrawals: IterableMap, next_withdraw_to_execute: u64, // indices of markets with created requests (per withdrawing op) market_execution_lock: Locker, // Keeper-provided withdraw route for the current Withdrawing op withdraw_route: Vec, abdicator: Abdicator, gate: Gate, } #[near] impl Contract { #[allow(clippy::unwrap_used, reason = "Infallible")] #[init] #[must_use] pub fn new(configuration: VaultConfiguration) -> Self { let VaultConfiguration { owner, curator, guardian, underlying_token, initial_timelock_ns, fee_recipient, skim_recipient, name, symbol, decimals, restrictions, } = configuration; require!( (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&initial_timelock_ns.0), "timelock bounds" ); let mut contract = Self { underlying_asset: underlying_token, aum: AUM::BalanceSheet, performance_fee: Wad::default(), fee_recipient, skim_recipient, markets: BTreeMap::new(), governance_timelocks: governance::Timelocks::new( initial_timelock_ns.0, initial_timelock_ns.0, initial_timelock_ns.0, initial_timelock_ns.0, ), supply_queue: BTreeSet::default(), last_total_assets: 0, virtual_shares: 1, virtual_assets: 1, idle_balance: 0, op_state: OpState::Idle, next_op_id: 1, pending_withdrawals: IterableMap::new( [ b'v'.into_storage_key().as_slice(), StorageKey::PendingWithdrawals.into_storage_key().as_slice(), ] .concat(), ), next_withdraw_to_execute: 0, market_execution_lock: Locker::default(), withdraw_route: Vec::new(), abdicator: Abdicator::new(), gate: Gate::new(restrictions), }; contract.set_metadata(&ContractMetadata::new(name, symbol, decimals.into())); Owner::init(&mut contract, &owner); Rbac::add_role(&mut contract, &curator, &Role::Curator); Rbac::add_role(&mut contract, &curator, &Role::Allocator); Rbac::add_role(&mut contract, &guardian, &Role::Guardian); contract.set_storage_balance_bounds(&StorageBalanceBounds { min: NearToken::from_millinear(2), max: None, }); contract } /// Burns the necessary shares to withdraw `amount` of underlying to `receiver`. /// Internally calls `redeem` after computing the share amount. #[payable] pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { require_at_least(templar_common::vault::WITHDRAW_GAS); self.internal_accrue_fee(); let shares_needed = self.preview_withdraw(amount).0; Event::WithdrawPreview { shares: U128(shares_needed), receiver: receiver.clone(), } .emit(); self.redeem(U128(shares_needed), receiver) } /// Redeems `shares` for underlying assets sent to `receiver`. /// Shares are escrowed to the contract and only burned after successful payout. #[payable] pub fn redeem(&mut self, shares: U128, receiver: AccountId) -> PromiseOrValue<()> { let shares = shares.0; let assets = self.convert_to_assets(U128(shares)).0; let sender = env::predecessor_account_id(); // Gate withdraw entrypoint: who is sending and who will receive assets. self.gate.enforce_policy(&sender); self.gate.enforce_policy(&receiver); require!(shares > 0, "Invalid shares"); require!(assets > 0, "Dust redeem would yield 0 assets"); let _ = require_attached_for_pending_withdrawal(); Gate::bypass_transfer( self, &Nep141Transfer::new(shares, &sender, env::current_account_id()), ); self.internal_accrue_fee(); Event::RedeemRequested { shares: U128(shares), estimated_assets: U128(assets), } .emit(); self.enqueue_pending_withdrawal(&sender, &receiver, shares, assets); PromiseOrValue::Value(()) } /// Executes the withdraw route provided by the allocator /// If `route` is empty, try to settle with the idle balance pub fn execute_withdrawal(&mut self, route: Vec) -> PromiseOrValue<()> { require_at_least(EXECUTE_WITHDRAW_GAS); self.ensure_idle(); Self::assert_allocator(); self.internal_accrue_fee(); if let Some(id) = self.peek_next_pending_withdrawal_id() { let pending = self .pending_withdrawals .get(&id) .unwrap_or_else(|| env::panic_str("pending vanished unexpectedly")); Event::WithdrawProgress { phase: WithdrawProgressPhase::ExecutionStarted, op_id: None, id: Some(id.into()), market_index: None, owner: Some(pending.owner.clone()), receiver: Some(pending.receiver.clone()), escrow_shares: Some(U128(pending.escrow_shares)), expected_assets: Some(U128(pending.expected_assets)), requested_at: Some(pending.requested_at.into()), } .emit(); let owner = pending.owner.clone(); let receiver = pending.receiver.clone(); if pending.expected_assets == 0 { Event::WithdrawProgress { phase: WithdrawProgressPhase::SkippedDust, op_id: None, id: Some(id.into()), market_index: None, owner: None, receiver: None, escrow_shares: None, expected_assets: None, requested_at: None, } .emit(); // Skip dust request to avoid wedging the queue self.pop_head(); return self.execute_withdrawal(route); } return self.start_withdraw( pending.expected_assets, &receiver, &owner, pending.escrow_shares, route, ); } Event::WithdrawQueueStatus { status: QueueStatus::Empty, id: None, } .emit(); PromiseOrValue::Value(()) } /// Allocator-only. Progress the current Withdrawing op by executing the market at `market_index` /// from the `withdraw_route`. Use when offchain signals the vault is next in the market queue. pub fn execute_market_withdrawal( &mut self, op_id: U64, market_index: u32, batch_limit: Option, ) -> PromiseOrValue<()> { require_at_least(EXECUTE_WITHDRAW_GAS); Self::assert_allocator(); self.internal_accrue_fee(); let ctx = match self.ctx_withdrawing(op_id.0) { Ok(s) => s.clone(), Err(_) => panic_with_message("Not withdrawing"), }; if let Err(e) = self.resolve_withdraw_market(market_index) { return self.stop_and_exit(Some(&e)); } self.market_execution_lock.lock(market_index); if ctx.index != market_index { self.op_state = OpState::Withdrawing(WithdrawingState { index: market_index, ..ctx }); } PromiseOrValue::Promise( ext_ft_core::ext(self.underlying_asset.contract_id().into()) .with_static_gas(FT_BALANCE_OF_GAS) .with_unused_gas_weight(0) .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) .with_unused_gas_weight(100) .execute_withdraw_01_execute_withdraw_fetch_position( op_id.into(), market_index, batch_limit, ), ), ) } /// Allocator-only. Executes an existing market-side supply withdrawal request /// for `market` and credits any returned underlying to the vault's /// `idle_balance`, without touching the user withdrawal queue /// (`pending_withdrawals`) or the payout state machine. /// /// This is a pure rebalance operation: /// - `total_assets` and `total_supply` are preserved. /// - Only per-market principal and `idle_balance` are updated. /// - No pending user withdrawal is dequeued or paid out. /// /// Implementation details: /// - Uses `OpState::Allocating` as a generic in-flight guard for this /// rebalance op. /// - Locks the target market index in `market_execution_lock` to serialize /// the underlying market call. /// /// Expects that a supply withdrawal request for this vault already exists /// in the given `market` and is ready to be executed. pub fn execute_rebalance_withdrawal( &mut self, market: AccountId, batch_limit: Option, ) -> PromiseOrValue<()> { require_at_least(EXECUTE_WITHDRAW_GAS); Self::assert_allocator(); self.ensure_idle(); self.internal_accrue_fee(); let principal = self.principal_of(&market); require!(principal > 0, "No principal to withdraw"); let op_id = self.next_op_id; let Some(market_index) = self.rebalance_lock_index(&market) else { Event::RebalanceWithdrawStopped { op_id: op_id.into(), market, reason: Some(Reason::Other("Missing market record".to_string())), } .emit(); return PromiseOrValue::Value(()); }; self.market_execution_lock.lock(market_index); // Use Allocating as a generic in-flight guard for this rebalancing op. self.next_op_id = op_id.saturating_add(1); self.op_state = OpState::Allocating(AllocatingState { op_id, index: market_index, remaining: 0, plan: Vec::new(), }); PromiseOrValue::Promise( ext_ft_core::ext(self.underlying_asset.contract_id().into()) .with_static_gas(FT_BALANCE_OF_GAS) .with_unused_gas_weight(0) .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) .with_unused_gas_weight(100) .rebalance_withdraw_01_execute_withdraw_fetch_position( op_id, market, batch_limit, U128(principal), ), ), ) } /// Allocator/Curator/Owner only. Unbricks the current in-flight withdrawal or payout: /// - If Withdrawing: refunds escrowed shares to the owner and dequeues the pending request. /// - If in Payout: re-syncs idle_balance with the underlying FT balance, /// refunds escrowed shares to the owner, and dequeues the pending request. /// - Clears withdraw state and market execution locks and returns the vault to Idle. pub fn unbrick(&mut self) -> PromiseOrValue<()> { Self::assert_allocator(); match self.op_state.clone() { OpState::Withdrawing(s) => { let id = self.next_withdraw_to_execute; Event::UnbrickInvoked { phase: UnbrickPhase::Withdrawing, op_id: Some(s.op_id.into()), id: Some(id.into()), } .emit(); // stop_and_exit_withdrawing refunds escrow, unlocks current index, clears route, // dequeues the inflight request and sets the vault back to Idle. self.stop_and_exit_withdrawing::<&str>(None); PromiseOrValue::Value(()) } OpState::Payout(s) => { let id = self.next_withdraw_to_execute; Event::UnbrickInvoked { phase: UnbrickPhase::Payout, op_id: Some(s.op_id.into()), id: Some(id.into()), } .emit(); // Treat stuck payout as failure, but re-sync idle_balance using // the actual underlying FT balance held by the vault account. PromiseOrValue::Promise( ext_ft_core::ext(self.underlying_asset.contract_id().into()) .with_static_gas(FT_BALANCE_OF_GAS) .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) .stop_and_exit_payout_01_reconcile(Some(Reason::Other( "unbrick_payout".to_string(), ))), ), ) } _ => PromiseOrValue::Value(()), } } /// Sends the entire balance of `token` held by the vault to the `skim_recipient`. pub fn skim(&mut self, token: AccountId) -> Promise { Self::require_owner(); // Disallow skimming underlying or this own share token let share_token_id = env::current_account_id(); let underlying_token_id = self.underlying_asset.contract_id(); require!(token != share_token_id, "Refusing to skim the share token"); require!( token != underlying_token_id, "Refusing to skim the underlying token" ); self.ensure_idle(); ext_ft_core::ext(token.clone()) .with_static_gas(Gas::from_tgas(3)) .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) .with_static_gas(Gas::from_tgas(10)) .skim_01_read_balance(token, self.skim_recipient.clone()), ) } /// Allocates assets across markets according to the provided weights. /// If `amount` is provided, it is used as the target amount for each market. /// Otherwise, the vault will attempt to allocate as much as possible. /// /// NOTE: Each allocation takes roughly [`ALLOCATE_GAS`] gas. (~21 TGAS) /// So in one allocation cycle, we should do at most ~12 market allocations. /// This is a conservative estimate, and may need to be tweaked. /// /// /// NOTE: When we rewrite this we should use a delta based approach pub fn reallocate(&mut self, delta: AllocationDelta) -> PromiseOrValue<()> { Self::assert_allocator(); self.ensure_idle(); self.internal_accrue_fee(); delta.as_ref().validate(); match delta { AllocationDelta::Supply(delta) => { require_at_least(ALLOCATE_GAS); let total = self.clamp_allocation_total(Some(delta.amount.0)); let plan = vec![(delta.market, total)]; Event::AllocationPlanSet { op_id: self.next_op_id.into(), total: U128(total), plan: plan .iter() .cloned() .map(|(market, amount)| (market, amount.into())) .collect(), } .emit(); self.start_allocation(total, plan) } AllocationDelta::Withdraw(delta) => { require_at_least(WITHDRAW_CREATE_REQUEST_CALLBACK_GAS); let to_request = self.principal_of(&delta.market).min(delta.amount.0); require!(to_request > 0, "Insufficient principal"); Event::SupplyWithdrawRequestCreated { market: delta.market.clone(), amount: U128(to_request), } .emit(); let market = delta.market.clone(); let amount = U128(to_request); PromiseOrValue::Promise( ext_market::ext(market.clone()) .with_static_gas(CREATE_WITHDRAW_REQ_GAS) .create_supply_withdrawal_request(BorrowAssetAmount::from(amount)) .then( Self::ext(env::current_account_id()) .with_static_gas(WITHDRAW_CREATE_REQUEST_CALLBACK_GAS) .rebalance_withdraw_01_after_create_request(market, amount), ), ) } } } fn queue_tail(&self) -> u64 { self.next_withdraw_to_execute + u64::from(self.pending_withdrawals.len()) } fn peek_next_pending_withdrawal_id(&self) -> Option { let tail = self.queue_tail(); if self.next_withdraw_to_execute < tail { Event::WithdrawQueueStatus { status: QueueStatus::NextFound, id: Some(self.next_withdraw_to_execute.into()), } .emit(); Some(self.next_withdraw_to_execute) } else { Event::WithdrawQueueStatus { status: QueueStatus::Empty, id: None, } .emit(); None } } fn pop_head(&mut self) { let id = self.next_withdraw_to_execute; let removed = self.pending_withdrawals.remove(&id); require!(removed.is_some(), "queue corrupt: head missing"); self.next_withdraw_to_execute = id.saturating_add(1); Event::WithdrawQueueUpdate { action: QueueAction::Dequeued, id: id.into(), } .emit(); } fn park_head_for_retry(&mut self) { Event::WithdrawQueueUpdate { action: QueueAction::Parked, id: self.next_withdraw_to_execute.into(), } .emit(); } } /* ----- Views ----- */ #[near] impl Contract { /// # Panics /// - If the owner is not set /// - If the curator is not set /// - If the guardian is not set #[allow(clippy::expect_used, reason = "No side effects")] pub fn get_configuration(&self) -> VaultConfiguration { let meta = self.get_metadata(); VaultConfiguration { owner: self.own_get_owner().unwrap_or_else(|| { templar_common::panic_with_message("Owner not set in get_configuration") }), curator: Self::with_members_of(&Role::Curator, |members| { require!( members.len() == 1, "Invariant violation: Cannot have more than one Curator" ); members .iter() .next() .expect("Curator not set in get_configuration") .clone() }), guardian: Self::with_members_of(&Role::Guardian, |members| { require!( members.len() == 1, "Invariant violation: Cannot have more than one Guardian" ); members .iter() .next() .expect("Guardian not set in get_configuration") .clone() }), underlying_token: self.underlying_asset.clone(), initial_timelock_ns: self.governance_timelocks.timelock_config_ns.into(), fee_recipient: self.fee_recipient.clone(), skim_recipient: self.skim_recipient.clone(), name: meta.name, symbol: meta.symbol, decimals: NonZeroU8::new(meta.decimals).expect("Decimals must be non-zero"), restrictions: self.gate.restrictions.clone(), } } /// Returns total assets under management = idle balance + sum of market principals. pub fn get_total_assets(&self) -> U128 { self.aum.get_total_assets(self) } pub fn get_idle_balance(&self) -> U128 { self.idle_balance.into() } pub fn get_total_supply(&self) -> U128 { U128(self.total_supply()) } /// Returns a best-effort estimate of the maximum additional amount that can be deposited /// across all markets given current caps and the current `supply_queue`. /// /// This does not reserve capacity and may become stale immediately after it is read. pub fn get_max_deposit(&self) -> U128 { let total = self .supply_queue .iter() .fold(0u128, |acc, m| match self.markets.get(m) { Some(rec) if rec.cfg.cap.0 > 0 => acc + rec.cfg.cap.0.saturating_sub(rec.principal), _ => acc, }); U128(total) } /// Returns a best-effort estimate of the maximum additional amount that can be deposited /// into any single market in the current `supply_queue`, given current caps. /// /// This is intended for UIs that want to route deposits in a way that is consistent with /// the vault's `supply_queue`. It does not reserve capacity and may become stale /// immediately after it is read. pub fn get_max_single_market_deposit(&self) -> U128 { let max_room = self .supply_queue .iter() .fold(0u128, |acc, m| match self.markets.get(m) { Some(rec) if rec.cfg.cap.0 > 0 => { acc.max(rec.cfg.cap.0.saturating_sub(rec.principal)) } _ => acc, }); U128(max_room) } /// Converts an amount of underlying assets to shares, flooring the result. /// Uses virtual offsets and fee-aware totals (pre-accrual simulation). pub fn convert_to_shares(&self, assets: U128) -> U128 { let a: u128 = assets.0; if a == 0 { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); U128(mul_div_floor(a.into(), new_total_supply.into(), new_total_assets.into()).into()) } /// Converts an amount of shares to underlying assets, flooring the result. /// Uses virtual offsets and fee-aware totals (pre-accrual simulation). pub fn convert_to_assets(&self, shares: U128) -> U128 { let s: u128 = shares.0; if s == 0 { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); U128(mul_div_floor(s.into(), new_total_assets.into(), new_total_supply.into()).into()) } /// Preview the number of shares minted for a deposit of `assets` (floored). /// Simulates fee accrual first (minting fee shares), then applies virtual offsets for conversion. pub fn preview_deposit(&self, assets: U128) -> U128 { self.convert_to_shares(assets) } /// Preview the amount of assets required to mint `shares` (ceiled). /// Simulates fee accrual first (minting fee shares), then applies virtual offsets for conversion. pub fn preview_mint(&self, shares: U128) -> U128 { let s = shares.0; if s == 0 { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); U128(mul_div_ceil(s.into(), new_total_assets.into(), new_total_supply.into()).into()) } /// Preview the number of shares required to withdraw `assets` (ceiled). /// Applies virtual offsets and fee-aware totals (pre-accrual simulation). pub fn preview_withdraw(&self, assets: U128) -> U128 { let a = assets.0; if a == 0 { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); U128(mul_div_ceil(a.into(), new_total_supply.into(), new_total_assets.into()).into()) } /// Preview the amount of assets received by redeeming `shares` (floored). /// Returns 0 if total supply is zero. pub fn preview_redeem(&self, shares: U128) -> U128 { self.convert_to_assets(shares) } pub fn get_withdrawing_op_id(&self) -> Option { match &self.op_state { OpState::Withdrawing(WithdrawingState { op_id, .. }) => Some((*op_id).into()), _ => None, } } /// Returns `true` if any market execution lock is currently held. /// /// This is a coarse signal that a withdrawal or allocator-only /// rebalance is in-flight against at least one market. pub fn has_pending_market_withdrawal(&self) -> bool { self.market_execution_lock.is_locked_all() } pub fn get_current_withdraw_request_id(&self) -> Option { match &self.op_state { OpState::Withdrawing(_) | OpState::Payout(_) => { Some(self.next_withdraw_to_execute.into()) } _ => None, } } } /* ----- Private Helpers ----- */ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct IdleCoverage { pub remaining_unmet: u128, pub collected_from_idle: u128, } impl Contract { fn principal_of(&self, market: &AccountId) -> u128 { self.markets.get(market).map_or(0, |r| r.principal) } fn cap_of(&self, market: &AccountId) -> u128 { self.markets.get(market).map_or(0, |r| r.cfg.cap.0) } fn room_of(&self, market: &AccountId) -> u128 { self.cap_of(market) .saturating_sub(self.principal_of(market)) } fn enqueue_pending_withdrawal( &mut self, owner: &AccountId, receiver: &AccountId, escrow_shares: u128, expected_assets: u128, ) { let id = self.queue_tail(); let requested_at = env::block_timestamp(); self.pending_withdrawals.insert( id, PendingWithdrawal { owner: owner.clone(), receiver: receiver.clone(), escrow_shares, expected_assets, requested_at, }, ); Event::WithdrawalQueued { id: id.into(), owner: owner.clone(), receiver: receiver.clone(), escrow_shares: U128(escrow_shares), expected_assets: U128(expected_assets), requested_at: requested_at.into(), } .emit(); } /// Computes fee-aware effective totals for conversions, mimicking `MetaMorpho`: /// - Include fee shares that would be minted if fees accrued now. /// - Apply virtual offsets: +`virtual_shares` to supply and +`virtual_assets` to assets. fn effective_totals_fee_aware(&self) -> (u128, u128) { let cur = self.get_total_assets().0; let ts = self.total_supply(); let (new_total_supply, new_total_assets) = Self::compute_effective_totals( cur.into(), self.last_total_assets.into(), self.performance_fee, ts.into(), self.virtual_shares.into(), self.virtual_assets.into(), ); (new_total_supply.into(), new_total_assets.into()) } // Pure helper to compute how many escrowed shares to burn on partial payout fn compute_burn_shares(escrow_shares: u128, collected: u128, requested_total: u128) -> u128 { mul_div_floor( escrow_shares.into(), collected.into(), requested_total.max(1).into(), ) .into() } pub fn compute_effective_totals( cur_assets: Number, last_total_assets: Number, performance_fee: Wad, total_supply: Number, virtual_shares: Number, virtual_assets: Number, ) -> (Number, Number) { let fee_shares = compute_fee_shares(cur_assets, last_total_assets, performance_fee, total_supply); // Bump by fake virtual assets to bypass inflation attacks let new_total_supply = total_supply .saturating_add(fee_shares) .saturating_add(virtual_shares); let new_total_assets = cur_assets.saturating_add(virtual_assets); (new_total_supply, new_total_assets) } pub fn clamp_allocation_total(&self, requested: Option) -> u128 { let requested = requested.unwrap_or(self.idle_balance); let max_room = self.get_max_deposit().0; requested.min(self.idle_balance).min(max_room) } pub fn internal_accrue_fee(&mut self) { // Invariant: Fees are minted only when total_assets() > last_total_assets (no fees on losses/flat). let cur = self.get_total_assets().0; let fee_shares = compute_fee_shares( cur.into(), self.last_total_assets.into(), self.performance_fee, self.total_supply().into(), ); if fee_shares > Number::zero() { let minted: u128 = fee_shares.into(); let recipient = self.fee_recipient.clone(); let _ = self .mint(&Nep141Mint::new(minted, &recipient)) .inspect_err(|e| { Event::PerformanceFeeMintFailed { error: e.to_string(), } .emit(); }); Event::PerformanceFeeAccrued { recipient, shares: U128(minted), } .emit(); } self.last_total_assets = cur; } /* ----- Auth ----- */ fn assert_guardian_or_owner() { let p = env::predecessor_account_id(); if !Self::has_role(&p, &Role::Guardian) { Self::require_owner(); } } fn assert_curator_or_owner() { let p = env::predecessor_account_id(); if !Self::has_role(&p, &Role::Curator) { Self::require_owner(); } } fn assert_allocator() { let p = env::predecessor_account_id(); if !Self::has_role(&p, &Role::Allocator) && !Self::has_role(&p, &Role::Curator) { Self::require_owner(); } } /* ----- Internal: op orchestration ----- */ fn ensure_idle(&self) { // Invariant: Only one op in flight; ensure_idle() guards all mutating ops. if !matches!(self.op_state, OpState::Idle) { templar_common::panic_with_message(&format!( "Invariant: Only one op in flight; current op_state = {:?}", self.op_state )); } } fn start_allocation(&mut self, amount: u128, plan: AllocationPlan) -> PromiseOrValue<()> { if amount == 0 { return PromiseOrValue::Value(()); } self.ensure_idle(); require!( amount <= self.idle_balance, "Policy violation: reserve amount must be <= idle_balance" ); self.update_idle_balance(IdleBalanceDelta::Decrease(amount.into())); let op_id = self.next_op_id; self.next_op_id += 1; self.op_state = OpState::Allocating(AllocatingState { op_id, index: 0, remaining: amount, plan, }); Event::AllocationStarted { op_id: op_id.into(), remaining: U128(amount), } .emit(); self.step_allocation() } /// build a supply `transfer_call` and chain `after_supply_1_check` fn supply_and_then( &self, market: &AccountId, amount: u128, op_id: u64, index: u32, remaining_before: u128, ) -> Promise { self::require_at_least( SUPPLY_AFTER_TRANSFER_CHECK_GAS.saturating_add(GAS_FOR_FT_TRANSFER_CALL), ); self.underlying_asset .transfer_call( market, U128(amount).into(), Some( #[allow(clippy::expect_used, reason = "Infallible")] serde_json::to_string(&templar_common::market::DepositMsg::Supply) .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())) .as_str(), ), ) .then( Self::ext(env::current_account_id()) .with_static_gas(SUPPLY_AFTER_TRANSFER_CHECK_GAS) .supply_01_handle_transfer( market.clone(), op_id, index, U128(amount), U128(remaining_before), ), ) } fn step_allocation(&mut self) -> PromiseOrValue<()> { let (op_id, index, remaining, plan) = match &self.op_state { OpState::Allocating(AllocatingState { op_id, index, remaining, plan, }) => (*op_id, *index, *remaining, plan.clone()), _ => return self.stop_and_exit(Some(&Error::NotAllocating)), }; if remaining == 0 { return self.stop_and_exit::(None); } let idx = index as usize; if let Some((market, amount)) = plan.get(idx) { let market_id = market.clone(); let room = self.room_of(&market_id); let to_supply = room.min(*amount); Event::AllocationStepPlan { op_id: op_id.into(), index, market: market_id.clone(), target: U128(*amount), room: U128(room), to_supply: U128(to_supply), remaining_before: U128(remaining), planned: true, reason: None, } .emit(); if to_supply == 0 { Event::AllocationStepPlan { op_id: op_id.into(), index, market: market_id.clone(), target: U128(*amount), room: U128(room), to_supply: U128(0), remaining_before: U128(remaining), planned: false, reason: Some(if room == 0 { Reason::NoRoom } else { Reason::ZeroTarget }), } .emit(); self.op_state = OpState::Allocating(AllocatingState { op_id, index: index + 1, remaining, plan: plan.into_iter().filter(|m| m.0 != market_id).collect(), }); return self.step_allocation(); } PromiseOrValue::Promise( self.supply_and_then(&market_id, to_supply, op_id, index, remaining), ) } else { // Plan exhausted; stop and reconcile remaining in stop_and_exit self.stop_and_exit::(None) } } fn start_withdraw( &mut self, amount: u128, receiver: &AccountId, owner: &AccountId, escrow_shares: u128, route: Vec, ) -> PromiseOrValue<()> { if amount == 0 { return self.stop_and_exit(Some(&Error::ZeroAmount)); } self.ensure_idle(); let op_id = self.next_op_id; self.next_op_id += 1; // Policy: Idle-first reservation does not mutate idle_balance until payout succeeds. let cov = self.compute_idle_coverage(amount); self.withdraw_route = route; self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: Default::default(), remaining: cov.remaining_unmet, receiver: receiver.clone(), collected: cov.collected_from_idle, owner: owner.clone(), escrow_shares, }); self.pay_or_signal_next_withdraw() } fn pay_or_signal_next_withdraw(&mut self) -> PromiseOrValue<()> { let OpState::Withdrawing(WithdrawingState { op_id, index, remaining, receiver, collected, owner, escrow_shares, }) = self.op_state.clone() else { return self.stop_and_exit(Some(&Error::NotWithdrawing)); }; if remaining == 0 { Event::WithdrawProgress { phase: WithdrawProgressPhase::CoveredByIdle, op_id: Some(op_id.into()), id: Some(self.next_withdraw_to_execute.into()), market_index: None, owner: None, receiver: None, escrow_shares: None, expected_assets: None, requested_at: None, } .emit(); return self.pay( op_id, &receiver, collected, &owner, escrow_shares, escrow_shares, ); } if self.withdraw_route.get(index as usize).is_some() { Event::WithdrawProgress { phase: WithdrawProgressPhase::ExecutionRequired, op_id: Some(op_id.into()), id: Some(self.next_withdraw_to_execute.into()), market_index: Some(index), owner: None, receiver: None, escrow_shares: None, expected_assets: None, requested_at: None, } .emit(); PromiseOrValue::Value(()) } else { let requested = collected.saturating_add(remaining); let burn_shares = Self::compute_burn_shares(escrow_shares, collected, requested); self.pay_or_else( op_id, &receiver, collected, &owner, escrow_shares, burn_shares, |self_| { self_.withdraw_route.clear(); self_.op_state = OpState::Idle; self_.park_head_for_retry(); PromiseOrValue::Value(()) }, ) } } #[allow(clippy::too_many_arguments)] /// If we collected something, pay it out now and burn proportional shares or do something else fn pay_or_else( &mut self, op_id: u64, receiver: &AccountId, amount: u128, owner: &AccountId, escrow_shares: u128, burn_shares: u128, or_else: impl FnOnce(&mut Self) -> PromiseOrValue<()>, ) -> PromiseOrValue<()> { if amount > 0 { self.pay(op_id, receiver, amount, owner, escrow_shares, burn_shares) } else { or_else(self) } } fn pay( &mut self, op_id: u64, receiver: &AccountId, amount: u128, owner: &AccountId, escrow_shares: u128, burn_shares: u128, ) -> PromiseOrValue<()> { self.op_state = OpState::Payout(PayoutState { op_id, receiver: receiver.clone(), amount, owner: owner.clone(), escrow_shares, burn_shares, }); require!(self.idle_balance >= amount, "idle underflow in payout"); self.update_idle_balance(IdleBalanceDelta::Decrease(amount.into())); PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(amount).into()) .then( Self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) .payment_01_reconcile_idle_or_refund(op_id, receiver.clone(), U128(amount)), ), ) } fn rebalance_lock_index(&self, market: &AccountId) -> Option { self.markets .keys() .position(|m| m == market) .and_then(|idx| u32::try_from(idx).ok()) } /// Computes how much of `amount` can be covered by idle balance without mutating state. /// Returns `IdleCoverage`. fn compute_idle_coverage(&self, amount: u128) -> IdleCoverage { let used_idle = self.idle_balance.min(amount); IdleCoverage { remaining_unmet: amount.saturating_sub(used_idle), collected_from_idle: used_idle, } } } impl near_sdk_contract_tools::hook::Hook> for Contract { fn hook(_: &mut Self, _: &Nep145ForceUnregister, _: impl FnOnce(&mut Self) -> R) -> R { // Invariant: Force unregister must fail to preserve FT ledger integrity. templar_common::panic_with_message("force unregistration is not supported") } } #[cfg(test)] mod tests;