From 3b8a11700593ef17e34fb0f042a8008d8091daa9 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 10 Nov 2025 10:00:06 +0000 Subject: [PATCH 01/36] refactor: make some utils for withdraws --- contract/vault/src/lib.rs | 167 +++++++++++++++++++++----------------- 1 file changed, 94 insertions(+), 73 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 22285ce5..5002aa7d 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -663,7 +663,8 @@ impl Contract { .saturating_sub(self.principal_of(market)) } - /// Enqueue a vault-level pending withdrawal request (escrow already taken). + /// Enqueue a vault-level pending withdrawal request. + /// At this point the escrow shares are already taken. fn enqueue_pending_withdrawal( &mut self, owner: &AccountId, @@ -697,6 +698,46 @@ impl Contract { .emit(); } + fn create_withdraw_request_for_market( + &mut self, + op_id: u64, + index: u32, + remaining: u128, + receiver: &AccountId, + collected: u128, + owner: &AccountId, + escrow_shares: u128, + market: AccountId, + ) -> PromiseOrValue<()> { + let have = self.principal_of(&market); + let to_request = have.min(remaining); + if to_request == 0 { + self.op_state = OpState::Withdrawing(WithdrawingState { + op_id, + index: index + 1, + remaining, + receiver: receiver.clone(), + collected, + owner: owner.clone(), + escrow_shares, + }); + env::log_str(&format!( + "Skipping withdrawal for market {market} (have {have}, remaining {remaining})" + )); + return self.step_withdraw(); + } + PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(CREATE_WITHDRAW_REQ_GAS) + .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(to_request))) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) + .withdraw_01_handle_create_request(op_id, index, U128(to_request)), + ), + ) + } + /// 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. @@ -1037,7 +1078,8 @@ impl Contract { let op_id = self.next_op_id; self.next_op_id += 1; - // Invariant: Idle-first reservation does not mutate idle_balance until payout succeeds. + // Policy: Idle-first reservation does not mutate idle_balance until payout succeeds. + // TODO: think we have a func for this let used_idle = self.idle_balance.min(amount); let remaining = amount.saturating_sub(used_idle); let collected = used_idle; @@ -1072,60 +1114,31 @@ impl Contract { }; if remaining == 0 { - self.op_state = OpState::Payout(PayoutState { + self.pay( op_id, - receiver: receiver.clone(), - amount: collected, - owner: owner.clone(), + &receiver, + collected, + &owner, + escrow_shares, escrow_shares, - burn_shares: escrow_shares, - }); - require!(self.idle_balance >= collected, "idle underflow in payout"); - self.update_idle_balance(IdleBalanceDelta::Decrease(collected.into())); - - return PromiseOrValue::Promise( - self.underlying_asset - .transfer(receiver.clone(), U128(collected).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, U128(collected)), - ), ); } if let Some(market) = self.withdraw_route.get(index as usize) { - let have = self.principal_of(market); - let to_request = have.min(remaining); - if to_request == 0 { - self.op_state = OpState::Withdrawing(WithdrawingState { - op_id, - index: index + 1, - remaining, - receiver, - collected, - owner, - escrow_shares, - }); - env::log_str(&format!( - "Skipping withdrawal for market {market} (have {have}, remaining {remaining})" - )); - return self.step_withdraw(); - } - PromiseOrValue::Promise( - templar_common::market::ext_market::ext(market.clone()) - .with_static_gas(CREATE_WITHDRAW_REQ_GAS) - .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(to_request))) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) - .withdraw_01_handle_create_request(op_id, index, U128(to_request)), - ), + self.create_withdraw_request_for_market( + op_id, + index, + remaining, + &receiver, + collected, + &owner, + escrow_shares, + market.clone(), ) } else { let requested = collected.saturating_add(remaining); let burn_shares = Self::compute_burn_shares(escrow_shares, collected, requested); - self.pay_collected( + self.pay_or_else( op_id, &receiver, collected, @@ -1144,45 +1157,53 @@ impl Contract { #[allow(clippy::too_many_arguments)] /// If we collected something, pay it out now and burn proportional shares or do something else - fn pay_collected( + fn pay_or_else( &mut self, op_id: u64, receiver: &AccountId, - collected: u128, + amount: u128, owner: &AccountId, escrow_shares: u128, burn_shares: u128, or_else: impl FnOnce(&mut Self) -> PromiseOrValue<()>, ) -> PromiseOrValue<()> { - if collected > 0 { - self.op_state = OpState::Payout(PayoutState { - op_id, - receiver: receiver.clone(), - amount: collected, - owner: owner.clone(), - escrow_shares, - burn_shares, - }); - require!(self.idle_balance >= collected, "idle underflow in payout"); - self.update_idle_balance(IdleBalanceDelta::Decrease(collected.into())); - - PromiseOrValue::Promise( - self.underlying_asset - .transfer(receiver.clone(), U128(collected).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(collected), - ), - ), - ) + 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)), + ), + ) + } } impl near_sdk_contract_tools::hook::Hook> for Contract { From 9ed949bc89539a78c7ca543089ee83d6804c8a73 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 10 Nov 2025 11:29:37 +0000 Subject: [PATCH 02/36] feat: supply allocation delta --- common/src/vault.rs | 33 ++++++++ contract/vault/src/impl_callbacks.rs | 2 +- contract/vault/src/lib.rs | 108 +++++++++++++-------------- 3 files changed, 86 insertions(+), 57 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index fbe61ca0..805b0bdf 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -399,6 +399,39 @@ impl AsRef for OpState { } } +#[derive(Debug, Clone)] +#[near(serializers = [borsh, json])] +pub struct Delta { + pub market: AccountId, + pub amount: U128, +} + +impl Delta { + 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), + Harvest(Delta), +} + +impl AsRef for AllocationDelta { + fn as_ref(&self) -> &Delta { + match self { + AllocationDelta::Supply(d) => d, + AllocationDelta::Withdraw(d) => d, + AllocationDelta::Harvest(d) => d, + } + } +} + #[derive(Debug)] #[near(serializers = [json])] pub enum Error { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 03f4be85..b2fbc5fb 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -388,7 +388,7 @@ impl Contract { let collected_next = collected_next.saturating_add(extra_payout); if remaining_next == 0 { - return self.pay_collected( + return self.pay_or_else( op_id, &ctx.receiver, collected_next, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 5002aa7d..103fed22 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -30,13 +30,15 @@ use near_sdk_contract_tools::{owner::Owner, rbac}; use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, + market::ext_market, vault::{ - require_at_least, AllocatingState, AllocationMode, AllocationPlan, AllocationWeights, - Error, Event, IdleBalanceDelta, MarketConfiguration, OpState, PayoutState, PendingValue, - PendingWithdrawal, TimestampNs, VaultConfiguration, WithdrawingState, - AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_1_CHECK_GAS, - ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, - EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, + require_at_least, AllocatingState, AllocationDelta, AllocationMode, AllocationPlan, + AllocationWeights, Error, Event, IdleBalanceDelta, MarketConfiguration, OpState, + PayoutState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, + WithdrawingState, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, + AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, + EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, + MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; pub use wad::*; @@ -402,57 +404,46 @@ impl Contract { /// /// /// NOTE: When we rewrite this we should use a delta based approach - #[payable] - pub fn allocate( - &mut self, - weights: AllocationWeights, - amount: Option, - ) -> PromiseOrValue<()> { - require_at_least(ALLOCATE_GAS); + pub fn reallocate(&mut self, delta: AllocationDelta) -> PromiseOrValue<()> { Self::assert_allocator(); self.ensure_idle(); + 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)]; + + self.plan = Some(plan.clone()); + Event::AllocationPlanSet { + op_id: self.next_op_id.into(), + total: U128(total), + plan: plan + .into_iter() + .map(|(market, amount)| (market, amount.into())) + .collect(), + } + .emit(); - let total = self.clamp_allocation_total(amount.map(|x| x.0)); - - if weights.is_empty() { - if total == 0 { - return self.stop_and_exit(Some(&Error::ZeroAmount)); + self.start_allocation(total) } - let op_id = self.next_op_id; - Event::AllocationRequestedQueue { - op_id: op_id.into(), - total: U128(total), + AllocationDelta::Withdraw(delta) => { + // This is a vault-driven withdrawal from a market; no shares will be burned + let escrow_shares = 0; + // The vault will be the receiver and owner of the assets on payout + let receiver_and_owner = env::current_account_id(); + // This will create withdraw requests, however, start_withdraw and step_withdraw are overloaded + self.start_withdraw( + delta.amount.0, + &receiver_and_owner, + &receiver_and_owner, + escrow_shares, + vec![delta.market], + ) } - .emit(); - self.plan = None; - return self.start_allocation(total); - } - - let weights = weights - .into_iter() - .map(|(m, w)| (m, u128::from(w))) - .collect::>(); - - let sum_weights: u128 = weights.values().sum(); - if sum_weights == 0 { - env::panic_str("Sum of weights is zero"); - } - if total == 0 { - env::panic_str("No funds to allocate"); - } - - let op_id = self.next_op_id; - let weights_for_event: Vec<(AccountId, U128)> = - weights.iter().map(|(m, w)| (m.clone(), U128(*w))).collect(); - Event::AllocationPlanSet { - op_id: op_id.into(), - total: U128(total), - plan: weights_for_event, + AllocationDelta::Harvest(delta) => todo!(), } - .emit(); - self.plan = Some(weights.into_iter().collect()); - - self.start_allocation(total) } // Advance next_withdraw_to_execute to the next present id and return it, or None if none @@ -1079,10 +1070,7 @@ impl Contract { self.next_op_id += 1; // Policy: Idle-first reservation does not mutate idle_balance until payout succeeds. - // TODO: think we have a func for this - let used_idle = self.idle_balance.min(amount); - let remaining = amount.saturating_sub(used_idle); - let collected = used_idle; + let (remaining, collected_from_idle) = self.idle_delta(amount); self.pending_market_exec.clear(); self.withdraw_route = route; @@ -1092,7 +1080,7 @@ impl Contract { index: Default::default(), remaining, receiver: receiver.clone(), - collected, + collected: collected_from_idle, owner: owner.clone(), escrow_shares, }); @@ -1114,6 +1102,7 @@ impl Contract { }; if remaining == 0 { + // Already fully covered by idle => payout self.pay( op_id, &receiver, @@ -1204,6 +1193,13 @@ impl Contract { ), ) } + + fn idle_delta(&mut self, amount: u128) -> (u128, u128) { + let used_idle = self.idle_balance.min(amount); + let remaining = amount.saturating_sub(used_idle); + let collected = used_idle; + (remaining, collected) + } } impl near_sdk_contract_tools::hook::Hook> for Contract { From 3e9e3d8b194461049a97c25b50d99b5662feea5e Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 10 Nov 2025 15:36:06 +0000 Subject: [PATCH 03/36] refactor: remove allocation mode --- common/src/vault.rs | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 805b0bdf..50b39773 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -19,33 +19,6 @@ pub type ActualIdx = u32; pub type AllocationWeights = Vec<(AccountId, U128)>; pub type AllocationPlan = Vec<(AccountId, u128)>; -#[derive(Clone, Debug, Default)] -#[near(serializers = [json, borsh])] -pub enum AllocationMode { - /// When eager makes sense - /// - /// • Retail/auto-pilot vaults: users expect deposits to “start earning” immediately without an active allocator. - /// • Small/simple vaults: stable caps/ordering, few markets; operational simplicity > fine-grained control. - /// • Integrations that assume quick deployment of idle assets. - /// - /// Risks/trade-offs of eager - /// - /// • Gas burden on depositors: ft_transfer_call into your vault must carry enough gas for multi-hop allocation. - /// Under-provisioned gas leads to partial allocations and extra callbacks. - /// • Timing control: depositors implicitly decide when allocation runs, which can fight the allocator’s planned rebalancing - /// cadence. - /// • Thrashing: many small deposits can trigger many allocation passes. - /// • Current code is “eager-ish but incomplete”: it only auto-starts when Idle, and does not auto-restart after the op. Deposits - /// that arrive during an allocation stay idle until someone triggers another pass. - /// - /// Behaviour - /// • On deposit: if Idle and idle_balance ≥ min_batch, start_allocation(idle_balance). - /// • Eager allocation can still honor a per-op plan if one is set (plan wins); otherwise fall back to supply_queue order. - Eager { min_batch: U128 }, - #[default] - Lazy, -} - /// Parsed from the string parameter `msg` passed by `*_transfer_call` to /// `*_on_transfer` calls. #[near(serializers = [json])] @@ -78,8 +51,6 @@ impl MarketConfiguration { #[derive(Clone)] #[near(serializers = [json, borsh])] pub struct VaultConfiguration { - /// The allocation mode for this vault. - pub mode: AllocationMode, /// The account that owns this vault. pub owner: AccountId, /// The account that can submit allocation plans. See [AllocationMode]. From 839abd1df22e86973a68eae5eb84ad00275bf927 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 10 Nov 2025 15:37:22 +0000 Subject: [PATCH 04/36] refactor: move alloc plan to alloc state --- common/src/vault.rs | 2 ++ contract/vault/src/impl_callbacks.rs | 20 +++++++---- contract/vault/src/impl_token_receiver.rs | 16 --------- contract/vault/src/lib.rs | 44 +++++++++++------------ 4 files changed, 37 insertions(+), 45 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 50b39773..52d1a3cd 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -212,6 +212,8 @@ pub struct AllocatingState { 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)] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index b2fbc5fb..6fe9d9ff 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -11,7 +11,7 @@ use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - AllocatingState, Event, IdleBalanceDelta, PayoutState, WithdrawingState, + AllocatingState, AllocationPlan, Event, IdleBalanceDelta, PayoutState, WithdrawingState, EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_03_SETTLE_GAS, GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, }, @@ -92,13 +92,13 @@ impl Contract { accepted: U128, remaining_before: U128, ) -> PromiseOrValue<()> { - let (i, _remaining_ctx) = match self.ctx_allocating(op_id) { + let (i, _, plan) = match self.ctx_allocating(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), }; - if i != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(i, market_index))); + if i != &market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(*i, market_index))); } let SupplyReconciliation { @@ -151,6 +151,8 @@ impl Contract { } .emit(); + let plan = plan.iter().filter(|m| m.0 != market).cloned().collect(); + if let Some(rec) = self.markets.get_mut(&market) { rec.principal = new_principal; } @@ -159,6 +161,7 @@ impl Contract { op_id, index: market_index.saturating_add(1), remaining: remaining_next, + plan, }); if remaining_next == 0 { return self.stop_and_exit(None::<&String>); @@ -567,7 +570,6 @@ impl Contract { self.update_idle_balance(IdleBalanceDelta::Increase(s.remaining.into())); - self.plan = None; self.op_state = OpState::Idle; } @@ -643,13 +645,17 @@ impl Contract { } /// Validate current op is Allocating and return (index, remaining) - pub(crate) fn ctx_allocating(&self, op_id: u64) -> Result<(u32, u128), Error> { + pub(crate) fn ctx_allocating( + &self, + op_id: u64, + ) -> Result<(&u32, &u128, &AllocationPlan), Error> { match &self.op_state { OpState::Allocating(AllocatingState { op_id: cur, index, remaining, - }) if *cur == op_id => Ok((*index, *remaining)), + plan, + }) if *cur == op_id => Ok((index, remaining, plan)), _ => Err(Error::NotAllocating), } } diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 7ab74a49..383d8b58 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -133,22 +133,6 @@ impl Contract { self.update_idle_balance(IdleBalanceDelta::Increase(accept.into())); self.last_total_assets = self.last_total_assets.saturating_add(accept); - if let AllocationMode::Eager { min_batch } = self.mode { - if matches!(self.op_state, OpState::Idle) && self.idle_balance >= min_batch.0 { - // Invariant: no overlapping operations - let op_id = self.next_op_id; - Event::AllocationEagerTriggered { - op_id: op_id.into(), - idle_balance: U128(self.idle_balance), - min_batch, - deposit_accepted: U128(accept), - } - .emit(); - self.ensure_idle(); - self.start_allocation(self.idle_balance); - } - } - refund } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 103fed22..34634716 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -120,10 +120,6 @@ pub struct Contract { /// The process in which the vault calculates its assets under management aum: AUM, - /// The mode in which the allocator will operate - mode: AllocationMode, - plan: Option, - /// Performance fee performance_fee: wad::Wad, fee_recipient: AccountId, @@ -194,7 +190,6 @@ impl Contract { name, symbol, decimals, - mode, } = configuration; require!( @@ -219,8 +214,6 @@ impl Contract { idle_balance: 0, op_state: OpState::Idle, next_op_id: 1, - mode, - plan: None, current_withdraw_inflight: None, pending_withdrawals: IterableMap::new( [ @@ -415,31 +408,37 @@ impl Contract { let total = self.clamp_allocation_total(Some(delta.amount.0)); let plan = vec![(delta.market, total)]; - self.plan = Some(plan.clone()); Event::AllocationPlanSet { op_id: self.next_op_id.into(), total: U128(total), plan: plan - .into_iter() + .iter() + .cloned() .map(|(market, amount)| (market, amount.into())) .collect(), } .emit(); - self.start_allocation(total) + self.start_allocation(total, plan) } AllocationDelta::Withdraw(delta) => { - // This is a vault-driven withdrawal from a market; no shares will be burned - let escrow_shares = 0; - // The vault will be the receiver and owner of the assets on payout - let receiver_and_owner = env::current_account_id(); - // This will create withdraw requests, however, start_withdraw and step_withdraw are overloaded - self.start_withdraw( - delta.amount.0, - &receiver_and_owner, - &receiver_and_owner, - escrow_shares, - vec![delta.market], + require_at_least(CREATE_WITHDRAW_REQ_GAS); + + let to_request = self.principal_of(&delta.market).min(delta.amount.0); + require!(to_request > 0, "Insufficient principal"); + + // TODO: proper event + env::log_str(&format!( + "DeltaWithdrawRequestCreated market={} amount={}", + delta.market, to_request + )); + + PromiseOrValue::Promise( + ext_market::ext(delta.market.clone()) + .with_static_gas(CREATE_WITHDRAW_REQ_GAS) + .create_supply_withdrawal_request(BorrowAssetAmount::from(U128( + to_request, + ))), ) } AllocationDelta::Harvest(delta) => todo!(), @@ -846,7 +845,7 @@ impl Contract { } } - fn start_allocation(&mut self, amount: u128) -> PromiseOrValue<()> { + fn start_allocation(&mut self, amount: u128, plan: AllocationPlan) -> PromiseOrValue<()> { if amount == 0 { // Dust request: clear the head and stay Idle to avoid wedging the queue self.remove_inflight_and_advance_head(); @@ -866,6 +865,7 @@ impl Contract { op_id, index: 0, remaining: amount, + plan, }); Event::AllocationStarted { op_id: op_id.into(), From 22fa061e1b5454f9edf9ff61b110bd53fda02329 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 10 Nov 2025 15:38:52 +0000 Subject: [PATCH 05/36] feat: migrate to delta based allocation plans --- contract/vault/src/lib.rs | 149 +++++++++--------------------------- contract/vault/src/tests.rs | 8 +- 2 files changed, 41 insertions(+), 116 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 34634716..96a22845 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -909,42 +909,36 @@ impl Contract { ) } - // Step allocation when a weighted plan is present. - fn step_allocation_with_plan( - &mut self, - op_id: u64, - index: u32, - remaining: u128, - ) -> PromiseOrValue<()> { - if let Some(plan) = &self.plan { - let idx = index as usize; - if let Some((market, weight)) = plan.get(idx) { - let market_id = market.clone(); - - // Sum weights of remaining markets in the plan (including current) - let mut sum_w: u128 = 0; - for (_, w) in plan.iter().skip(idx) { - sum_w = sum_w.saturating_add(*w); - } + 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(); - // Compute weighted target for this step. For the last market (or zero sum), take all remaining. - let target = if sum_w == 0 || idx + 1 == plan.len() { - remaining - } else { - mul_div_floor(remaining.into(), (*weight).into(), sum_w.into()).into() - }; - - let room = self.room_of(&market_id); - let to_supply = room.min(target); - - Event::AllocationStepPlanned { - op_id: op_id.into(), - index, - market: market_id.clone(), - target: U128(target), - room: U128(room), - to_supply: U128(to_supply), - remaining_before: U128(remaining), + let room = self.room_of(&market_id); + let to_supply = room.min(*amount); + + Event::AllocationStepPlanned { + 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, } .emit(); @@ -964,96 +958,23 @@ impl Contract { .emit(); self.op_state = OpState::Allocating(AllocatingState { - op_id, - index: index + 1, - remaining, - }); - 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) - } - } else { - self.stop_and_exit(Some(&Error::NotAllocating)) - } - } - - // Step allocation using the supply_queue order. - fn step_allocation_from_queue( - &mut self, - op_id: u64, - index: u32, - remaining: u128, - ) -> PromiseOrValue<()> { - if let Some(market) = self.supply_queue.iter().nth(index as usize) { - let room = self.room_of(market); - let to_supply = room.min(remaining); - - // Emit planned step event (queue-based) - Event::AllocationStepPlanned { - op_id: op_id.into(), - index, - market: market.clone(), - target: U128(remaining), - room: U128(room), - to_supply: U128(to_supply), - remaining_before: U128(remaining), - planned: false, - } - .emit(); - - if to_supply == 0 { - Event::AllocationStepSkipped { - op_id: op_id.into(), - index, - market: market.clone(), - reason: "no-room".to_string(), - remaining: U128(remaining), - } - .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, to_supply, op_id, index, remaining), - ) - } else { + 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 step_allocation(&mut self) -> PromiseOrValue<()> { - let (op_id, index, remaining) = match &self.op_state { - OpState::Allocating(AllocatingState { - op_id, - index, - remaining, - }) => (*op_id, *index, *remaining), - _ => return self.stop_and_exit(Some(&Error::NotAllocating)), - }; - - if remaining == 0 { - return self.stop_and_exit::(None); - } - - if self.plan.is_some() { - self.step_allocation_with_plan(op_id, index, remaining) - } else { - self.step_allocation_from_queue(op_id, index, remaining) - } - } - fn start_withdraw( &mut self, amount: u128, diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 11b5234b..ea1fb45d 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -253,7 +253,7 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { // Reserve only the amount to allocate (intended behavior) let total = c.get_max_deposit().0.min(c.idle_balance); - c.start_allocation(total); + c.start_allocation(total, vec![]); // Emulate allocation completing successfully: 80 moved to market if let Some(rec) = c.markets.get_mut(&m1) { @@ -269,11 +269,15 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { ); } // Force completion and exit op - if let crate::OpState::Allocating(AllocatingState { op_id, index, .. }) = c.op_state.clone() { + if let crate::OpState::Allocating(AllocatingState { + op_id, index, plan, .. + }) = c.op_state.clone() + { c.op_state = crate::OpState::Allocating(AllocatingState { op_id, index, remaining: 0, + plan, }); } else { panic!("expected Allocating state"); From 615316131afcd4c3354c89dc3828a7f8c660a5f5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 10 Nov 2025 16:06:35 +0000 Subject: [PATCH 06/36] refactor: creating market-side withdraw requests are delta-only --- contract/vault/src/impl_callbacks.rs | 40 +++++++--------------------- contract/vault/src/lib.rs | 14 ++++++++++ 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 6fe9d9ff..d7b92cc5 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -184,7 +184,6 @@ impl Contract { if did_create.is_ok() { self.pending_market_exec.push(market_index); - PromiseOrValue::Value(()) } else { Event::CreateWithdrawalFailed { op_id: op_id.into(), @@ -193,17 +192,8 @@ impl Contract { need, } .emit(); - self.op_state = OpState::Withdrawing(WithdrawingState { - op_id, - index: market_index.saturating_add(1), - remaining: ctx.remaining, - receiver: ctx.receiver.clone(), - collected: ctx.collected, - owner: ctx.owner.clone(), - escrow_shares: ctx.escrow_shares, - }); - self.step_withdraw() } + PromiseOrValue::Value(()) } #[private] @@ -339,6 +329,10 @@ impl Contract { ); let extra = inflow.saturating_sub(principal_delta); + self.with_pending_market_position(market_index, |self_, pos| { + self_.pending_market_exec.remove(pos); + }); + match principal_delta.cmp(&inflow) { Ordering::Greater => { Event::WithdrawalInflowMismatch { @@ -371,7 +365,11 @@ impl Contract { self.update_idle_balance(IdleBalanceDelta::Increase(inflow.into())); } - self.try_settle_pending_market_exec(market_index, creditable, principal_delta); + self.with_pending_market_position(market_index, |self_, pos| { + if creditable == principal_delta { + self_.pending_market_exec.remove(pos); + } + }); // Reconcile remaining/collected based on credited inflow only let WithdrawReconciliation { @@ -699,24 +697,6 @@ impl Contract { .ok_or(Error::MissingMarket(market_index)) } - // Settle pending market exec entry only if fully credited - pub fn try_settle_pending_market_exec( - &mut self, - market_index: u32, - creditable: u128, - principal_drop: u128, - ) { - if let Some(pos) = self - .pending_market_exec - .iter() - .position(|&idx| idx == market_index) - { - if creditable == principal_drop { - self.pending_market_exec.remove(pos); - } - } - } - #[must_use] pub fn compute_withdraw_deltas( before_principal: U128, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 96a22845..72a8f305 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1121,6 +1121,20 @@ impl Contract { let collected = used_idle; (remaining, collected) } + + fn with_pending_market_position( + &mut self, + market_index: u32, + and: impl FnOnce(&mut Self, usize), + ) { + if let Some(pos) = self + .pending_market_exec + .iter() + .position(|&idx| idx == market_index) + { + and(self, pos); + } + } } impl near_sdk_contract_tools::hook::Hook> for Contract { From 6033dec3b29ec6b0ec09974904d5d276d46a8c98 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 09:59:38 +0000 Subject: [PATCH 07/36] chore: helper for payout deltas --- contract/vault/src/impl_callbacks.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index d7b92cc5..2a9d5166 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -384,9 +384,9 @@ impl Contract { ); // If market overpaid beyond principal drop, use the extra to satisfy this withdrawal - let extra_payout = extra.min(remaining_next); - let remaining_next = remaining_next.saturating_sub(extra_payout); - let collected_next = collected_next.saturating_add(extra_payout); + + let (_, remaining_next, collected_next) = + determine_payout_delta(remaining_next, collected_next, extra); if remaining_next == 0 { return self.pay_or_else( @@ -761,9 +761,10 @@ pub fn reconcile_withdraw_outcome( ) -> WithdrawReconciliation { let withdrawn = before_principal.saturating_sub(new_principal); let idle_delta = withdrawn; - let payout_delta = withdrawn.min(remaining_total); - let remaining_next = remaining_total.saturating_sub(payout_delta); - let collected_next = collected_total.saturating_add(payout_delta); + + let (payout_delta, remaining_next, collected_next) = + determine_payout_delta(remaining_total, collected_total, withdrawn); + WithdrawReconciliation { payout_delta, remaining_next, @@ -771,3 +772,14 @@ pub fn reconcile_withdraw_outcome( idle_delta, } } + +pub fn determine_payout_delta( + remaining_total: u128, + collected_total: u128, + withdrawn: u128, +) -> (u128, u128, u128) { + let payout_delta = withdrawn.min(remaining_total); + let remaining_next = remaining_total.saturating_sub(payout_delta); + let collected_next = collected_total.saturating_add(payout_delta); + (payout_delta, remaining_next, collected_next) +} From 952e2e50254a380a707fb5689a4653fedcea2e32 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:01:26 +0000 Subject: [PATCH 08/36] refactor: add a locking type --- common/src/vault.rs | 47 +++++++++++++++++++++++----- contract/vault/src/impl_callbacks.rs | 15 +++------ contract/vault/src/lib.rs | 35 +++------------------ 3 files changed, 48 insertions(+), 49 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 52d1a3cd..d84a8d96 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -548,17 +548,11 @@ pub enum Event { reason: Option, }, - // Eager #[event_version("1.0.0")] - AllocationEagerTriggered { - op_id: U64, - idle_balance: U128, - min_batch: U128, - deposit_accepted: U128, - }, + PerformanceFeeAccrued { recipient: AccountId, shares: U128 }, #[event_version("1.0.0")] - PerformanceFeeAccrued { recipient: AccountId, shares: U128 }, + LockChange { is_locked: bool, market_index: u32 }, // Admin and configuration events #[event_version("1.0.0")] @@ -722,6 +716,43 @@ pub enum Event { }, } +pub struct Locker { + to_lock: Vec, +} + +impl Locker { + pub fn new() -> Self { + Locker { + to_lock: Vec::new(), + } + } + + 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); + } + + pub fn is_locked(&self, i: u32) -> bool { + self.to_lock.contains(&i) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 2a9d5166..0bf04ec7 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -183,7 +183,7 @@ impl Contract { }; if did_create.is_ok() { - self.pending_market_exec.push(market_index); + self.market_execution_lock.lock(market_index); } else { Event::CreateWithdrawalFailed { op_id: op_id.into(), @@ -329,9 +329,7 @@ impl Contract { ); let extra = inflow.saturating_sub(principal_delta); - self.with_pending_market_position(market_index, |self_, pos| { - self_.pending_market_exec.remove(pos); - }); + self.market_execution_lock.unlock(market_index); match principal_delta.cmp(&inflow) { Ordering::Greater => { @@ -365,11 +363,7 @@ impl Contract { self.update_idle_balance(IdleBalanceDelta::Increase(inflow.into())); } - self.with_pending_market_position(market_index, |self_, pos| { - if creditable == principal_delta { - self_.pending_market_exec.remove(pos); - } - }); + self.market_execution_lock.unlock(market_index); // Reconcile remaining/collected based on credited inflow only let WithdrawReconciliation { @@ -408,7 +402,6 @@ impl Contract { .unwrap_or_else(|e| { env::panic_str(&format!("Failed to refund escrowed shares {e}")) }); - self_.pending_market_exec.clear(); self_.remove_inflight_and_advance_head(); self_.withdraw_route.clear(); self_.op_state = OpState::Idle; @@ -515,7 +508,7 @@ impl Contract { // If this fails, this is a serious issue as above .unwrap_or_else(|e| env::log_str(&e.to_string())); } - self.pending_market_exec.clear(); + self.market_execution_lock.unlock(market_index); self.remove_inflight_and_advance_head(); self.withdraw_route.clear(); self.op_state = OpState::Idle; diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 72a8f305..b33ecb3f 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -158,7 +158,7 @@ pub struct Contract { next_withdraw_to_execute: u64, // indices of markets with created requests (per withdrawing op) - pending_market_exec: Vec, + market_execution_lock: Locker, // Keeper-provided withdraw route for the current Withdrawing op withdraw_route: Vec, @@ -224,7 +224,7 @@ impl Contract { ), next_withdraw_id: 0, next_withdraw_to_execute: 0, - pending_market_exec: Vec::new(), + market_execution_lock: Vec::new(), withdraw_route: Vec::new(), }; @@ -345,6 +345,7 @@ impl Contract { return self.stop_and_exit(Some(&e)); }; + self.market_execution_lock.lock(market_index); PromiseOrValue::Promise( ext_ft_core::ext(self.underlying_asset.contract_id().into()) .with_static_gas(Gas::from_tgas(5)) @@ -616,7 +617,7 @@ impl Contract { } pub fn has_pending_market_withdrawal(&self) -> bool { - !self.pending_market_exec.is_empty() + !self.market_execution_lock.is_empty() } pub fn get_current_withdraw_request_id(&self) -> Option { @@ -624,18 +625,6 @@ impl Contract { } } -#[derive(Debug, Clone, Copy)] -pub(crate) struct EscrowSettlement { - pub to_burn: u128, - pub refund: u128, -} - -impl From for (u128, u128) { - fn from(tuple: EscrowSettlement) -> Self { - (tuple.to_burn, tuple.refund) - } -} - /* ----- Private Helpers ----- */ impl Contract { // Principal (vault-supplied) units currently recorded for a market @@ -993,7 +982,7 @@ impl Contract { // Policy: Idle-first reservation does not mutate idle_balance until payout succeeds. let (remaining, collected_from_idle) = self.idle_delta(amount); - self.pending_market_exec.clear(); + self.market_execution_lock.clear(); self.withdraw_route = route; self.op_state = OpState::Withdrawing(WithdrawingState { @@ -1121,20 +1110,6 @@ impl Contract { let collected = used_idle; (remaining, collected) } - - fn with_pending_market_position( - &mut self, - market_index: u32, - and: impl FnOnce(&mut Self, usize), - ) { - if let Some(pos) = self - .pending_market_exec - .iter() - .position(|&idx| idx == market_index) - { - and(self, pos); - } - } } impl near_sdk_contract_tools::hook::Hook> for Contract { From dad77eb715c52c2835042c2c0fa72c085dc3aa1a Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:04:30 +0000 Subject: [PATCH 09/36] refactor!: we allow wedging the queue so that the allocator can exec_next --- contract/vault/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index b33ecb3f..321c60e9 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -836,8 +836,6 @@ impl Contract { fn start_allocation(&mut self, amount: u128, plan: AllocationPlan) -> PromiseOrValue<()> { if amount == 0 { - // Dust request: clear the head and stay Idle to avoid wedging the queue - self.remove_inflight_and_advance_head(); return PromiseOrValue::Value(()); } self.ensure_idle(); From 6e1426768bbba1b65f39136a2aee28e31776ae26 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:05:11 +0000 Subject: [PATCH 10/36] fix: we lock specifically the market index, not the first --- contract/vault/src/lib.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 321c60e9..342074ef 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -337,10 +337,6 @@ impl Contract { Err(e) => return self.stop_and_exit(Some(&e)), }; - let Some(market_index) = self.pending_market_exec.first().copied() else { - env::panic_str("No pending market withdrawal request to execute"); - }; - if let Err(e) = self.resolve_withdraw_market(market_index) { return self.stop_and_exit(Some(&e)); }; From 0ae0169222d2c4a2ea8f5154aed3488f228205bb Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:10:04 +0000 Subject: [PATCH 11/36] chore: escrow helper --- common/src/vault.rs | 21 ++++++++++++ contract/vault/src/lib.rs | 67 +++---------------------------------- contract/vault/src/tests.rs | 7 ++-- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index d84a8d96..cdd555e2 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -405,6 +405,27 @@ impl AsRef for AllocationDelta { } } +#[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 { diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 342074ef..73910cc8 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -673,46 +673,6 @@ impl Contract { .emit(); } - fn create_withdraw_request_for_market( - &mut self, - op_id: u64, - index: u32, - remaining: u128, - receiver: &AccountId, - collected: u128, - owner: &AccountId, - escrow_shares: u128, - market: AccountId, - ) -> PromiseOrValue<()> { - let have = self.principal_of(&market); - let to_request = have.min(remaining); - if to_request == 0 { - self.op_state = OpState::Withdrawing(WithdrawingState { - op_id, - index: index + 1, - remaining, - receiver: receiver.clone(), - collected, - owner: owner.clone(), - escrow_shares, - }); - env::log_str(&format!( - "Skipping withdrawal for market {market} (have {have}, remaining {remaining})" - )); - return self.step_withdraw(); - } - PromiseOrValue::Promise( - ext_market::ext(market.clone()) - .with_static_gas(CREATE_WITHDRAW_REQ_GAS) - .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(to_request))) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) - .withdraw_01_handle_create_request(op_id, index, U128(to_request)), - ), - ) - } - /// 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. @@ -740,7 +700,7 @@ impl Contract { .into() } - pub(crate) fn compute_effective_totals( + pub fn compute_effective_totals( cur_assets: Number, last_total_assets: Number, performance_fee: wad::Wad, @@ -757,21 +717,12 @@ impl Contract { (new_total_supply, new_total_assets) } - pub(crate) fn clamp_allocation_total(&self, requested: Option) -> u128 { + 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(crate) fn compute_escrow_settlement( - escrow_shares: u128, - burn_shares: u128, - ) -> EscrowSettlement { - let to_burn = burn_shares.min(escrow_shares); - let refund = escrow_shares.saturating_sub(to_burn); - EscrowSettlement { to_burn, refund } - } - 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; @@ -1016,17 +967,9 @@ impl Contract { escrow_shares, ); } - if let Some(market) = self.withdraw_route.get(index as usize) { - self.create_withdraw_request_for_market( - op_id, - index, - remaining, - &receiver, - collected, - &owner, - escrow_shares, - market.clone(), - ) + if self.withdraw_route.get(index as usize).is_some() { + // FIXME: emit an event NeedsExecution(blah) + return PromiseOrValue::Value(()); } else { let requested = collected.saturating_add(remaining); let burn_shares = Self::compute_burn_shares(escrow_shares, collected, requested); diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index ea1fb45d..343274e9 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -25,6 +25,7 @@ use rstest::{fixture, rstest}; use templar_common::asset::FungibleAsset; use templar_common::vault::AllocatingState; use templar_common::vault::Error; +use templar_common::vault::EscrowSettlement; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; use templar_common::vault::PayoutState; @@ -377,13 +378,13 @@ fn compute_escrow_settlement_burns_min_and_refunds_rest() { let vault_id = accounts(0); setup_env(&vault_id, &vault_id, vec![]); - let s1: (u128, u128) = Contract::compute_escrow_settlement(100, 40).into(); + let s1: (u128, u128) = EscrowSettlement::new(100, 40).into(); assert_eq!(s1, (40u128, 60u128)); - let s2: (u128, u128) = Contract::compute_escrow_settlement(100, 200).into(); + let s2: (u128, u128) = EscrowSettlement::new(100, 200).into(); assert_eq!(s2, (100u128, 0u128)); - let s3: (u128, u128) = Contract::compute_escrow_settlement(0, 50).into(); + let s3: (u128, u128) = EscrowSettlement::new(0, 50).into(); assert_eq!(s3, (0u128, 0u128)); } From f02de8f2d06de9d990b4323646d3e6f55a18eb84 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:43:58 +0000 Subject: [PATCH 12/36] feat: allow clearing the lock --- common/src/vault.rs | 9 ++++++++- contract/vault/src/lib.rs | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index cdd555e2..79669c12 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -628,7 +628,7 @@ pub enum Event { #[event_version("1.0.0")] MarketRemovalRevoked { market: AccountId }, #[event_version("1.0.0")] - WithdrawQueueUpdated { markets: Vec }, + WithdrawExecutionRequired { op_id: U64, market_index: u32 }, // User flows #[event_version("1.0.0")] @@ -737,6 +737,7 @@ pub enum Event { }, } +#[near(serializers = [borsh, serde])] pub struct Locker { to_lock: Vec, } @@ -769,6 +770,12 @@ impl Locker { 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) } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 73910cc8..10a34697 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -939,10 +939,10 @@ impl Contract { owner: owner.clone(), escrow_shares, }); - self.step_withdraw() + self.pay_or_signal_next_withdraw() } - fn step_withdraw(&mut self) -> PromiseOrValue<()> { + fn pay_or_signal_next_withdraw(&mut self) -> PromiseOrValue<()> { let OpState::Withdrawing(WithdrawingState { op_id, index, From 455340a61ffc4ea587b0c064f9cb45b5e4d309d1 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:44:09 +0000 Subject: [PATCH 13/36] test: default plans --- contract/vault/src/tests.rs | 6 ++++++ test-utils/src/lib.rs | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 343274e9..906b9e64 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1581,6 +1581,7 @@ fn after_supply_1_check_allocating_not_allocating_index() { op_id, index: 0u32, remaining: 0u128, + plan: Default::default(), }); c.supply_01_handle_transfer( @@ -1616,6 +1617,7 @@ fn after_supply_1_check_allocating() { op_id, index: 0u32, remaining: 0u128, + plan: Default::default(), }); c.supply_01_handle_transfer( @@ -1632,6 +1634,7 @@ fn after_supply_1_check_allocating() { OpState::Allocating(AllocatingState { op_id, index: 0, + plan: Default::default(), remaining: 0u128 }) ); @@ -1996,6 +1999,7 @@ fn ctx_allocating_ok_and_err() { op_id: 42, index: 3, remaining: 77, + plan: Default::default(), }); let ok = c.ctx_allocating(42).expect("ctx_allocating should succeed"); @@ -2071,6 +2075,7 @@ fn after_supply_2_read_missing_position_stops() { op_id: 1, index: 0, remaining: 10, + plan: Default::default(), }); // Missing position -> stop_and_exit @@ -2098,6 +2103,7 @@ fn after_supply_2_read_read_failed_stops() { op_id: 7, index: 0, remaining: 100, + plan: Default::default(), }); // Read failure -> stop_and_exit diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 90b31e7e..0efe71bc 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -159,7 +159,6 @@ pub fn vault_configuration( name: "Vault".to_string(), symbol: "VAULT".to_string(), decimals: NonZero::new(24).unwrap(), - mode: templar_common::vault::AllocationMode::Lazy, } } From c61f67c3f45dec4f1379d8acd49c6249dff99f32 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:44:34 +0000 Subject: [PATCH 14/36] feat: allow the allocator to update the market index to avoid deadlocks --- contract/vault/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 10a34697..4a4c11ff 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -342,6 +342,13 @@ impl Contract { }; 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(Gas::from_tgas(5)) From dacc8254323b5ececb01f65a6e93095eb874525d Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:44:49 +0000 Subject: [PATCH 15/36] feat: enable parking inflight withdraws --- contract/vault/src/lib.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 4a4c11ff..7ae32549 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -365,6 +365,26 @@ impl Contract { ) } + /// Allocator/Curator/Owner only. Cancels the current in-flight withdrawal: + /// - Refunds escrowed shares to the owner + /// - Releases the current market execution lock + /// - Clears withdraw state and dequeues the pending request (advance head) + /// + /// If the vault is not currently Withdrawing, this is a no-op. + pub fn cancel_in_flight_withdrawal(&mut self) -> PromiseOrValue<()> { + Self::assert_allocator(); + + match &self.op_state { + OpState::Withdrawing(_) => { + // 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(()) + } + _ => 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(); From 23328afe428209abb7d94cc8242b52019acefd12 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:45:12 +0000 Subject: [PATCH 16/36] feat: signal to the allocator that a withdrawal execution is required --- contract/vault/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 7ae32549..6a457269 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -995,7 +995,11 @@ impl Contract { ); } if self.withdraw_route.get(index as usize).is_some() { - // FIXME: emit an event NeedsExecution(blah) + Event::WithdrawExecutionRequired { + op_id: op_id.into(), + market_index: index, + } + .emit(); return PromiseOrValue::Value(()); } else { let requested = collected.saturating_add(remaining); From a951b5916c0f1639a59f02f91374a495f3d420b3 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 10:46:55 +0000 Subject: [PATCH 17/36] chore: remove double clear --- contract/vault/src/lib.rs | 46 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 6a457269..a5d6ff85 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -32,13 +32,12 @@ use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, market::ext_market, vault::{ - require_at_least, AllocatingState, AllocationDelta, AllocationMode, AllocationPlan, - AllocationWeights, Error, Event, IdleBalanceDelta, MarketConfiguration, OpState, - PayoutState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, - WithdrawingState, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, - EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, - MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, + require_at_least, AllocatingState, AllocationDelta, AllocationPlan, Error, Event, + IdleBalanceDelta, Locker, MarketConfiguration, OpState, PayoutState, PendingValue, + PendingWithdrawal, TimestampNs, VaultConfiguration, WithdrawingState, + AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, + EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_GAS, MAX_TIMELOCK_NS, + MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; pub use wad::*; @@ -116,22 +115,29 @@ impl From for MarketRecord { 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::Wad, + 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 offsets used only in conversions/previews to harden edge cases virtual_shares: u128, virtual_assets: u128, - // Merged market record: cfg + pending_cap + principal (single persisted map; no per-entry storage keys) + /// Markets controlled by the vault markets: BTreeMap, /// Any pending change to the vault's timelock @@ -144,14 +150,8 @@ pub struct Contract { /// Ordered list of market IDs for deposit allocation supply_queue: BTreeSet, - // id of the pending withdrawal being executed, if any + // Id of the pending withdrawal being executed, if any current_withdraw_inflight: Option, - - /// underlying held by vault - idle_balance: u128, - op_state: OpState, - next_op_id: u64, - /// Pending withdrawals queue (vault-level, FIFO by id) pending_withdrawals: IterableMap, next_withdraw_id: u64, @@ -224,7 +224,7 @@ impl Contract { ), next_withdraw_id: 0, next_withdraw_to_execute: 0, - market_execution_lock: Vec::new(), + market_execution_lock: Locker::new(), withdraw_route: Vec::new(), }; @@ -547,7 +547,6 @@ impl Contract { name: meta.name, symbol: meta.symbol, decimals: NonZeroU8::new(meta.decimals).expect("Decimals must be non-zero"), - mode: self.mode.clone(), } } @@ -730,13 +729,14 @@ impl Contract { pub fn compute_effective_totals( cur_assets: Number, last_total_assets: Number, - performance_fee: wad::Wad, + 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); @@ -954,9 +954,7 @@ impl Contract { // Policy: Idle-first reservation does not mutate idle_balance until payout succeeds. let (remaining, collected_from_idle) = self.idle_delta(amount); - self.market_execution_lock.clear(); self.withdraw_route = route; - self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: Default::default(), From 009031ee769466e0c8a5ba61146d19d684fb1d7b Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 11:01:57 +0000 Subject: [PATCH 18/36] fix: do not reset to 0 since it would race idles --- contract/vault/src/impl_callbacks.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 0bf04ec7..176187ac 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -210,10 +210,11 @@ impl Contract { }; let principal = self.principal_of(&market); - let before_balance = before_balance.unwrap_or(U128(0)); + let before_balance = before_balance.unwrap_or(U128(self.idle_balance)); PromiseOrValue::Promise( ext_market::ext(market.clone()) + // NOTE: gas might be incorrect here .with_static_gas(Gas::from_tgas( EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS.as_tgas() * (u64::from(batch_limit.unwrap_or(1))), From c87875aa612bf6ca732425bfed26bc1733495207 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 11:03:09 +0000 Subject: [PATCH 19/36] chore: cleanup some double unlocks and helpers --- contract/vault/src/governance.rs | 2 + contract/vault/src/impl_callbacks.rs | 96 ++++++++++++++--------- contract/vault/src/impl_token_receiver.rs | 4 +- contract/vault/src/lib.rs | 73 ++++++++--------- 4 files changed, 94 insertions(+), 81 deletions(-) diff --git a/contract/vault/src/governance.rs b/contract/vault/src/governance.rs index 1ab90184..9d6d980d 100644 --- a/contract/vault/src/governance.rs +++ b/contract/vault/src/governance.rs @@ -1,3 +1,5 @@ +use templar_common::vault::MAX_QUEUE_LEN; + use super::*; #[near] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 176187ac..5803e399 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -3,7 +3,7 @@ use core::cmp::Ordering; use std::fmt::Display; -use crate::{near, Contract, ContractExt, Error, EscrowSettlement, Nep141Controller, OpState}; +use crate::{near, Contract, ContractExt, Error, Nep141Controller, OpState}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{env, json_types::U128, AccountId, Gas, NearToken, PromiseError, PromiseOrValue}; use near_sdk_contract_tools::ft::{Nep141Burn, Nep141Transfer}; @@ -11,8 +11,8 @@ use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - AllocatingState, AllocationPlan, Event, IdleBalanceDelta, PayoutState, WithdrawingState, - EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_03_SETTLE_GAS, + AllocatingState, AllocationPlan, EscrowSettlement, Event, IdleBalanceDelta, PayoutState, + WithdrawingState, EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_03_SETTLE_GAS, GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, }, }; @@ -284,6 +284,7 @@ impl Contract { before: principal, } .emit(); + return self.stop_and_exit(Some(&Error::PositionReadFailed)); } }; @@ -330,8 +331,6 @@ impl Contract { ); let extra = inflow.saturating_sub(principal_delta); - self.market_execution_lock.unlock(market_index); - match principal_delta.cmp(&inflow) { Ordering::Greater => { Event::WithdrawalInflowMismatch { @@ -379,7 +378,6 @@ impl Contract { ); // If market overpaid beyond principal drop, use the extra to satisfy this withdrawal - let (_, remaining_next, collected_next) = determine_payout_delta(remaining_next, collected_next, extra); @@ -411,35 +409,19 @@ impl Contract { ); } - match principal_delta.cmp(&inflow) { - Ordering::Less | Ordering::Equal if principal_delta > 0 => { - // Fully executed for this market: advance to next and continue - self.op_state = OpState::Withdrawing(WithdrawingState { - op_id, - index: market_index.saturating_add(1), - remaining: remaining_next, - receiver: ctx.receiver, - collected: collected_next, - owner: ctx.owner, - escrow_shares: ctx.escrow_shares, - }); - self.step_withdraw() - } - _ => { - // Partial or zero inflow: do not advance; keeper must re-execute this market later - self.op_state = OpState::Withdrawing(WithdrawingState { - op_id, - index: market_index, - remaining: remaining_next, - receiver: ctx.receiver, - collected: collected_next, - owner: ctx.owner, - escrow_shares: ctx.escrow_shares, - }); - PromiseOrValue::Value(()) - } - } + let next_state = withdrawal_state_if_principal( + principal_delta, + inflow, + op_id, + market_index, + remaining_next, + collected_next, + ctx, + ); + self.op_state = next_state; + self.pay_or_signal_next_withdraw() } + /// Cash flow: /// - Runs in Payout context after funds were credited in after_exec_withdraw_read. /// - On success: idle_balance was pre-decremented before transfer; burn a portion of escrow_shares and refund the rest to the owner. @@ -478,7 +460,7 @@ impl Contract { let EscrowSettlement { to_burn: burn_shares, refund, - } = Self::compute_escrow_settlement(escrow_shares, burn_shares); + } = EscrowSettlement::new(escrow_shares, burn_shares); // Burn only the proportional shares and refund the remainder to the owner. if burn_shares > 0 { @@ -509,7 +491,6 @@ impl Contract { // If this fails, this is a serious issue as above .unwrap_or_else(|e| env::log_str(&e.to_string())); } - self.market_execution_lock.unlock(market_index); self.remove_inflight_and_advance_head(); self.withdraw_route.clear(); self.op_state = OpState::Idle; @@ -581,6 +562,8 @@ impl Contract { } .emit(); + self.market_execution_lock.unlock(s.index); + let owner = s.owner.clone(); if s.escrow_shares > 0 { @@ -613,6 +596,8 @@ impl Contract { self.transfer_unchecked(&env::current_account_id(), &owner, s.escrow_shares) .unwrap_or_else(|e| env::log_str(&e.to_string())); } + + self.market_execution_lock.clear(); self.remove_inflight_and_advance_head(); self.withdraw_route.clear(); self.op_state = OpState::Idle; @@ -653,7 +638,7 @@ impl Contract { } /// Validate current op is Withdrawing and return context tuple - pub(crate) fn ctx_withdrawing(&self, op_id: u64) -> Result<&WithdrawingState, Error> { + pub fn ctx_withdrawing(&self, op_id: u64) -> Result<&WithdrawingState, Error> { match &self.op_state { OpState::Withdrawing(s) if s.op_id == op_id => Ok(s), _ => Err(Error::NotWithdrawing), @@ -777,3 +762,40 @@ pub fn determine_payout_delta( let collected_next = collected_total.saturating_add(payout_delta); (payout_delta, remaining_next, collected_next) } + +pub fn withdrawal_state_if_principal( + principal_delta: u128, + inflow: u128, + op_id: u64, + market_index: u32, + remaining_next: u128, + collected_next: u128, + ctx: WithdrawingState, +) -> OpState { + match principal_delta.cmp(&inflow) { + Ordering::Less | Ordering::Equal if principal_delta > 0 => { + // Fully executed for this market: advance to next and continue + OpState::Withdrawing(WithdrawingState { + op_id, + index: market_index.saturating_add(1), + remaining: remaining_next, + receiver: ctx.receiver, + collected: collected_next, + owner: ctx.owner, + escrow_shares: ctx.escrow_shares, + }) + } + _ => { + // Partial or zero inflow: do not advance; keeper must re-execute this market later + OpState::Withdrawing(WithdrawingState { + op_id, + index: market_index, + remaining: remaining_next, + receiver: ctx.receiver, + collected: collected_next, + owner: ctx.owner, + escrow_shares: ctx.escrow_shares, + }) + } + } +} diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 383d8b58..13c5a9b7 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -2,9 +2,7 @@ use crate::{Contract, ContractExt, OpState}; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; use near_sdk_contract_tools::ft::{Nep141Controller as _, Nep141Mint}; -use templar_common::vault::{ - require_at_least, AllocationMode, DepositMsg, Event, IdleBalanceDelta, SUPPLY_GAS, -}; +use templar_common::vault::{require_at_least, DepositMsg, Event, IdleBalanceDelta, SUPPLY_GAS}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index a5d6ff85..70db83b1 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -168,15 +168,6 @@ pub struct Contract { impl Contract { #[allow(clippy::unwrap_used, reason = "Infallible")] #[init] - /// Initializes a new vault. - /// - `owner_id`: account that controls Owner-only actions. - /// - `curator_id`: manages markets and is also granted the Allocator role. - /// - `guardian_id`: can revoke pending governance actions. - /// - `underlying_token_id`: NEP-141 underlying asset managed by the vault. - /// - `initial_timelock_sec`: governance timelock in seconds. - /// - `fee_recipient`: account to receive performance fees. - /// - `skim_recipient`: account to receive skimmed tokens. - /// - `name`/`symbol`/`decimals`: metadata for the share token. #[must_use] pub fn new(configuration: VaultConfiguration) -> Self { let VaultConfiguration { @@ -282,9 +273,9 @@ impl Contract { PromiseOrValue::Value(()) } - /// Executes the next pending withdrawal request - /// This defers creating market-side withdrawal requests until explicitly invoked. - pub fn execute_next_withdrawal_request(&mut self, route: Vec) -> PromiseOrValue<()> { + /// 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(); @@ -305,11 +296,10 @@ impl Contract { // Skip dust request to avoid wedging the queue self.current_withdraw_inflight = Some(id); self.remove_inflight_and_advance_head(); - return self.execute_next_withdrawal_request(route); + return self.execute_withdrawal(route); } self.current_withdraw_inflight = Some(id); - env::log_str(&format!("WithdrawalExecutionStarted id={id}")); return self.start_withdraw( pending.expected_assets, &receiver, @@ -322,24 +312,25 @@ impl Contract { PromiseOrValue::Value(()) } - /// Executes one created market withdrawal request in the current Withdrawing op. - /// Allocator only. - pub fn execute_next_market_withdrawal( + /// 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(); - let _ctx = match self.ctx_withdrawing(op_id.into()) { - Ok(v) => v, + let ctx = match self.ctx_withdrawing(op_id.0) { + Ok(s) => s.clone(), Err(e) => return self.stop_and_exit(Some(&e)), }; if let Err(e) = self.resolve_withdraw_market(market_index) { return self.stop_and_exit(Some(&e)); - }; + } self.market_execution_lock.lock(market_index); @@ -465,7 +456,7 @@ impl Contract { ))), ) } - AllocationDelta::Harvest(delta) => todo!(), + AllocationDelta::Harvest(delta) => todo!("Implement Harvest"), } } @@ -900,25 +891,25 @@ impl Contract { room: U128(room), to_supply: U128(to_supply), remaining_before: U128(remaining), - planned: true, + planned: true, + } + .emit(); + + if to_supply == 0 { + Event::AllocationStepSkipped { + op_id: op_id.into(), + index, + market: market_id.clone(), + reason: if room == 0 { + "no-room".to_string() + } else { + "zero-target".to_string() + }, + remaining: U128(remaining), } .emit(); - if to_supply == 0 { - Event::AllocationStepSkipped { - op_id: op_id.into(), - index, - market: market_id.clone(), - reason: if room == 0 { - "no-room".to_string() - } else { - "zero-target".to_string() - }, - remaining: U128(remaining), - } - .emit(); - - self.op_state = OpState::Allocating(AllocatingState { + self.op_state = OpState::Allocating(AllocatingState { op_id, index: index + 1, remaining, @@ -927,10 +918,10 @@ impl Contract { return self.step_allocation(); } - PromiseOrValue::Promise( - self.supply_and_then(&market_id, to_supply, op_id, index, remaining), - ) - } else { + 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) } From e3d23f9b5dc82fd11b9003f783657b9dfb72560f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 11 Nov 2025 11:40:11 +0000 Subject: [PATCH 20/36] refactor: remove stupid field-state withdraws --- contract/vault/src/impl_callbacks.rs | 8 +-- contract/vault/src/lib.rs | 74 +++++++++++++--------------- 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 5803e399..80072695 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -401,7 +401,7 @@ impl Contract { .unwrap_or_else(|e| { env::panic_str(&format!("Failed to refund escrowed shares {e}")) }); - self_.remove_inflight_and_advance_head(); + self_.pop_head(); self_.withdraw_route.clear(); self_.op_state = OpState::Idle; PromiseOrValue::Value(()) @@ -491,7 +491,7 @@ impl Contract { // If this fails, this is a serious issue as above .unwrap_or_else(|e| env::log_str(&e.to_string())); } - self.remove_inflight_and_advance_head(); + self.pop_head(); self.withdraw_route.clear(); self.op_state = OpState::Idle; } @@ -572,7 +572,7 @@ impl Contract { .unwrap_or_else(|e| env::log_str(&e.to_string())); } - self.remove_inflight_and_advance_head(); + self.pop_head(); self.withdraw_route.clear(); self.op_state = OpState::Idle; } @@ -598,7 +598,7 @@ impl Contract { } self.market_execution_lock.clear(); - self.remove_inflight_and_advance_head(); + self.pop_head(); self.withdraw_route.clear(); self.op_state = OpState::Idle; } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 70db83b1..37b3fbff 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -150,11 +150,8 @@ pub struct Contract { /// Ordered list of market IDs for deposit allocation supply_queue: BTreeSet, - // Id of the pending withdrawal being executed, if any - current_withdraw_inflight: Option, /// Pending withdrawals queue (vault-level, FIFO by id) pending_withdrawals: IterableMap, - next_withdraw_id: u64, next_withdraw_to_execute: u64, // indices of markets with created requests (per withdrawing op) @@ -205,7 +202,6 @@ impl Contract { idle_balance: 0, op_state: OpState::Idle, next_op_id: 1, - current_withdraw_inflight: None, pending_withdrawals: IterableMap::new( [ b'v'.into_storage_key().as_slice(), @@ -213,7 +209,6 @@ impl Contract { ] .concat(), ), - next_withdraw_id: 0, next_withdraw_to_execute: 0, market_execution_lock: Locker::new(), withdraw_route: Vec::new(), @@ -280,9 +275,6 @@ impl Contract { self.ensure_idle(); Self::assert_allocator(); - if self.current_withdraw_inflight.is_some() { - env::panic_str("A pending withdrawal is already in-flight"); - } if let Some(id) = self.peek_next_pending_withdrawal_id() { let pending = self @@ -294,12 +286,10 @@ impl Contract { if pending.expected_assets == 0 { // Skip dust request to avoid wedging the queue - self.current_withdraw_inflight = Some(id); - self.remove_inflight_and_advance_head(); + self.pop_head(); return self.execute_withdrawal(route); } - self.current_withdraw_inflight = Some(id); return self.start_withdraw( pending.expected_assets, &receiver, @@ -460,38 +450,36 @@ impl Contract { } } - // Advance next_withdraw_to_execute to the next present id and return it, or None if none - fn peek_next_pending_withdrawal_id(&mut self) -> Option { - let mut id = self.next_withdraw_to_execute; - while id < self.next_withdraw_id { - if self.pending_withdrawals.get(&id).is_some() { - self.next_withdraw_to_execute = id; - return Some(id); - } - id = id.saturating_add(1); - } - self.next_withdraw_to_execute = id; - None + // Derive queue tail = head + pending.len() + fn queue_tail(&self) -> u64 { + self.next_withdraw_to_execute + (self.pending_withdrawals.len() as u64) } - // Remove the in-flight pending (success or explicit abort) and advance head past it - fn remove_inflight_and_advance_head(&mut self) { - if let Some(id) = self.current_withdraw_inflight.take() { - let _ = self.pending_withdrawals.remove(&id); - self.next_withdraw_to_execute = id.saturating_add(1); - Event::WithdrawDequeued { index: id.into() }.emit(); + // Return the current head id if queue is non-empty + fn peek_next_pending_withdrawal_id(&self) -> Option { + let tail = self.queue_tail(); + if self.next_withdraw_to_execute < tail { + Some(self.next_withdraw_to_execute) + } else { + None } } - // Keep the head pending but clear in-flight so it can be retried later - fn park_inflight_head_for_retry(&mut self) { - if let Some(current_withdraw_inflight) = self.current_withdraw_inflight { - Event::WithdrawalParked { - id: current_withdraw_inflight.into(), - } - .emit(); + // Remove the head pending withdrawal and advance the queue head + 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::WithdrawDequeued { index: id.into() }.emit(); + } + + // Keep the head pending and emit telemetry; can be retried later + fn park_head_for_retry(&mut self) { + Event::WithdrawalParked { + id: self.next_withdraw_to_execute.into(), } - self.current_withdraw_inflight = None; + .emit(); } } @@ -634,7 +622,12 @@ impl Contract { } pub fn get_current_withdraw_request_id(&self) -> Option { - self.current_withdraw_inflight.map(Into::into) + match &self.op_state { + OpState::Withdrawing(_) | OpState::Payout(_) => { + Some(self.next_withdraw_to_execute.into()) + } + _ => None, + } } } @@ -664,8 +657,7 @@ impl Contract { escrow_shares: u128, expected_assets: u128, ) { - let id = self.next_withdraw_id; - self.next_withdraw_id = self.next_withdraw_id.saturating_add(1); + let id = self.queue_tail(); let requested_at = env::block_timestamp(); self.pending_withdrawals.insert( @@ -1004,7 +996,7 @@ impl Contract { |self_| { self_.withdraw_route.clear(); self_.op_state = OpState::Idle; - self_.park_inflight_head_for_retry(); + self_.park_head_for_retry(); PromiseOrValue::Value(()) }, ) From 5ca9789acd795329fd6c8c2d3a3fbcecaa3524a6 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 12 Nov 2025 10:23:00 +0000 Subject: [PATCH 21/36] test: fix tests after queue changes --- common/src/vault.rs | 10 ++ contract/vault/src/lib.rs | 5 +- contract/vault/src/tests.rs | 288 ++++++++++++++---------------------- 3 files changed, 126 insertions(+), 177 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 79669c12..bcc2d548 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -380,6 +380,12 @@ pub struct Delta { } 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") } @@ -779,6 +785,10 @@ impl Locker { 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() + } } #[cfg(test)] diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 37b3fbff..dbb4c782 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -150,7 +150,7 @@ pub struct Contract { /// Ordered list of market IDs for deposit allocation supply_queue: BTreeSet, - /// Pending withdrawals queue (vault-level, FIFO by id) + /// Pending withdrawals queue pending_withdrawals: IterableMap, next_withdraw_to_execute: u64, @@ -275,7 +275,6 @@ impl Contract { self.ensure_idle(); Self::assert_allocator(); - if let Some(id) = self.peek_next_pending_withdrawal_id() { let pending = self .pending_withdrawals @@ -618,7 +617,7 @@ impl Contract { } pub fn has_pending_market_withdrawal(&self) -> bool { - !self.market_execution_lock.is_empty() + !self.market_execution_lock.is_locked_all() } pub fn get_current_withdraw_request_id(&self) -> Option { diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 906b9e64..719672d2 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -24,13 +24,16 @@ use near_sdk_contract_tools::owner::OwnerExternal; use rstest::{fixture, rstest}; use templar_common::asset::FungibleAsset; use templar_common::vault::AllocatingState; +use templar_common::vault::AllocationDelta; +use templar_common::vault::Delta; +use templar_common::vault::DepositMsg; use templar_common::vault::Error; use templar_common::vault::EscrowSettlement; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; use templar_common::vault::PayoutState; +use templar_common::vault::PendingWithdrawal; use templar_common::vault::WithdrawingState; -use templar_common::vault::{AllocationMode, DepositMsg}; #[fixture] fn vault_id_fixture() -> AccountId { @@ -189,6 +192,16 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e escrow_shares: 100, burn_shares: 40, // precomputed proportional burn for test }); + c.pending_withdrawals.insert( + 0, + PendingWithdrawal { + receiver: mk(9), + owner: accounts(1), + escrow_shares: 100, + expected_assets: amount, + requested_at: 0, + }, + ); let supply_before = c.total_supply(); c.payment_01_reconcile_idle_or_refund(Ok(()), op_id, receiver, U128(amount)); @@ -254,7 +267,8 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { // Reserve only the amount to allocate (intended behavior) let total = c.get_max_deposit().0.min(c.idle_balance); - c.start_allocation(total, vec![]); + owner_call_env(env::current_account_id(), &owner()); + c.reallocate(AllocationDelta::Supply(Delta::new(m1.clone(), total))); // Emulate allocation completing successfully: 80 moved to market if let Some(rec) = c.markets.get_mut(&m1) { @@ -270,18 +284,20 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { ); } // Force completion and exit op - if let crate::OpState::Allocating(AllocatingState { - op_id, index, plan, .. - }) = c.op_state.clone() - { - c.op_state = crate::OpState::Allocating(AllocatingState { - op_id, - index, - remaining: 0, - plan, - }); - } else { - panic!("expected Allocating state"); + match c.op_state.clone() { + crate::OpState::Allocating(AllocatingState { + op_id, index, plan, .. + }) => { + c.op_state = crate::OpState::Allocating(AllocatingState { + op_id, + index, + remaining: 0, + plan, + }); + } + s => { + panic!("expected Allocating state, got {:?}", s); + } } let _ = c.stop_and_exit::(None); @@ -299,44 +315,6 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { ); } -#[test] -fn queue_allocation_ignores_stale_plan() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); - setup_env( - &vault_id, - &c.own_get_owner() - .unwrap_or_else(|| env::panic_str("Owner not set")), - vec![], - ); - - // Supply queue has m1; stale plan points to m2 - let m1 = mk(3001); - let m2 = mk(3002); - - let cfg1 = MarketConfiguration { - cap: U128(10), - enabled: true, - removable_at: 0, - }; - c.markets.insert(m1.clone(), cfg1.into()); - c.supply_queue.insert(m1); - - // Stale plan (should be ignored for queue-based allocation) - c.plan = Some(vec![(m2.clone(), 1u128)]); - - c.idle_balance = 5; - - // Run queue-based allocation (weights empty) -> must clear any stale plan - let weights: templar_common::vault::AllocationWeights = vec![]; - let _ = c.allocate(weights, None); - - assert!( - c.plan.is_none(), - "queue-based allocate must ignore and clear any stale plan" - ); -} - #[rstest( escrow, collected, requested, expect, case(100u128, 200u128, 500u128, 40u128), // 40% @@ -859,9 +837,7 @@ fn ft_on_transfer_supply_accepts_full_and_mints_shares( enabled_market_100: (AccountId, MarketConfiguration), ) { let mut c = c_asset_env; - c.mode = AllocationMode::Eager { - min_batch: U128(u128::MAX), - }; + let (m, cfg) = enabled_market_100; c.markets.insert(m.clone(), cfg.into()); c.supply_queue.insert(m); @@ -905,9 +881,7 @@ fn ft_on_transfer_supply_partial_refund_when_capped( enabled_market_100: (AccountId, MarketConfiguration), ) { let mut c = c_asset_env; - c.mode = AllocationMode::Eager { - min_batch: U128(u128::MAX), - }; + let (m, mut cfg) = enabled_market_100; cfg.cap = U128(50); // override cap for this case c.markets.insert(m.clone(), cfg.into()); @@ -951,10 +925,6 @@ fn ft_on_transfer_wrong_token_full_refund_via_receiver() { let mut c = new_test_contract(&mk(42)); // underlying differs from predecessor setup_env(&vault_id, &vault_id, vec![]); - c.mode = AllocationMode::Eager { - min_batch: U128(u128::MAX), - }; - // Provide a market (not used due to wrong token) let m = mk(9003); let cfg = MarketConfiguration { @@ -1018,44 +988,6 @@ fn ft_on_transfer_zero_amount_returns_zero_refund( ); } -#[rstest] -fn ft_on_transfer_eager_mode_triggers_allocation( - c_asset_env: Contract, - enabled_market_100: (AccountId, MarketConfiguration), -) { - let mut c = c_asset_env; - - // Trigger eager allocation with any positive deposit - c.mode = AllocationMode::Eager { min_batch: U128(1) }; - - // Valid market/cap - let (m, cfg) = enabled_market_100; - c.markets.insert(m.clone(), cfg.into()); - c.supply_queue.insert(m); - - let deposit = 5u128; - - let res = c.ft_on_transfer( - c.underlying_asset.contract_id().into(), - U128(deposit), - serde_json::to_string(&DepositMsg::Supply).unwrap(), - ); - - match res { - PromiseOrValue::Value(U128(refund)) => assert_eq!(refund, 0), - _ => panic!("expected Value refund"), - } - - assert!( - matches!(c.op_state, OpState::Allocating { .. }), - "Eager mode must trigger allocation" - ); - assert_eq!( - c.idle_balance, 0, - "idle should be reserved by start_allocation" - ); -} - #[test] #[should_panic = "Invalid deposit msg"] fn mt_on_transfer_invalid_msg_panics() { @@ -1469,18 +1401,7 @@ fn governance_set_fee_recipient_no_fee_does_not_accrue() { let mut c = new_test_contract(&vault_id); let owner = accounts(1); - let mut builder = VMContextBuilder::new(); - builder.current_account_id(vault_id.clone()); - builder.predecessor_account_id(owner.clone()); - builder.signer_account_id(owner.clone()); - builder.attached_deposit(NearToken::from_millinear(5)); - testing_env!( - builder.build(), - test_vm_config(), - RuntimeFeesConfig::test(), - Default::default(), - vec![] - ); + owner_call_env(vault_id, &owner); // Seed supply and simulate profit, but fee = 0 c.deposit_unchecked(&owner, 1_000) @@ -1508,6 +1429,21 @@ fn governance_set_fee_recipient_no_fee_does_not_accrue() { assert_eq!(c.fee_recipient, new_recipient); } +fn owner_call_env(vault_id: AccountId, owner: &AccountId) { + let mut builder = VMContextBuilder::new(); + builder.current_account_id(vault_id.clone()); + builder.predecessor_account_id(owner.clone()); + builder.signer_account_id(owner.clone()); + builder.attached_deposit(NearToken::from_millinear(5)); + testing_env!( + builder.build(), + test_vm_config(), + RuntimeFeesConfig::test(), + Default::default(), + vec![] + ); +} + #[test] #[should_panic = "Refusing to skim the underlying token"] fn skim_rejects_underlying_token() { @@ -1558,7 +1494,6 @@ fn after_supply_1_check_allocating_not_allocating(c_max: Contract) { ); assert_eq!(c.op_state, OpState::Idle); - assert_eq!(c.plan, None); } #[test] @@ -1594,7 +1529,6 @@ fn after_supply_1_check_allocating_not_allocating_index() { ); assert_eq!(c.op_state, OpState::Idle); - assert_eq!(c.plan, None); } #[test] @@ -1638,7 +1572,6 @@ fn after_supply_1_check_allocating() { remaining: 0u128 }) ); - assert_eq!(c.plan, None); } #[rstest] @@ -1741,55 +1674,6 @@ fn after_skim_balance_positive_returns_promise() { } } -/// Property: Create-withdraw failure skips to next market and if collected>0 ends in Payout -#[rstest( - collected => [1u128, 10u128], - need => [1u128, 5u128] -)] -fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - c.idle_balance = collected; - - // Single-market route so advancing index reaches end-of-route - let market = mk(8); - c.withdraw_route = vec![market.clone()]; - c.markets.insert( - market.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 100, - }, - ); - - c.op_state = OpState::Withdrawing(WithdrawingState { - op_id: 7, - index: 0, - remaining: need, - receiver: mk(9), - collected, - owner: accounts(1), - escrow_shares: 0, - }); - - let res = - c.withdraw_01_handle_create_request(Err(near_sdk::PromiseError::Failed), 7, 0, U128(need)); - match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise after skipping to payout at end-of-queue"), - } - assert_eq!(c.idle_balance, 0); - - match &c.op_state { - OpState::Payout(PayoutState { amount, .. }) => { - assert_eq!(*amount, collected, "Payout amount must equal collected"); - } - other => panic!("Unexpected state: {other:?}"), - } -} - /// Property: Exec-withdraw read failure assumes unchanged principal and does not credit idle #[rstest( before => [0u128, 1u128, 100u128], @@ -1797,6 +1681,8 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { collected => [1u128, 2u128] )] fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collected: u128) { + use templar_common::vault::PendingWithdrawal; + let vault_id = accounts(0); setup_env(&vault_id, &vault_id, vec![]); let mut c = new_test_contract(&vault_id); @@ -1824,6 +1710,18 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect escrow_shares: 0, }); + // Stub a withdrawal + c.pending_withdrawals.insert( + 0, + PendingWithdrawal { + receiver: mk(9), + owner: accounts(1), + escrow_shares: 0, + expected_assets: collected, + requested_at: 0, + }, + ); + let res = c.execute_withdraw_02_reconcile_position( Err(near_sdk::PromiseError::Failed), 99, @@ -1886,6 +1784,17 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde escrow_shares: 0, }); + c.pending_withdrawals.insert( + real_idx as u64, + PendingWithdrawal { + receiver: mk(9), + owner: accounts(1), + escrow_shares: 0, + expected_assets: 1, + requested_at: 0, + }, + ); + let call_op = if pass_op { real_op } else { real_op + 1 }; let call_idx = if pass_index { real_idx } else { real_idx + 1 }; @@ -1940,6 +1849,16 @@ fn refund_path_consistency() { owner: owner.clone(), escrow_shares: 10, }); + c.pending_withdrawals.insert( + c.queue_tail(), + PendingWithdrawal { + owner: owner.clone(), + receiver: mk(9), + escrow_shares: 10, + expected_assets: 0, + requested_at: 0, + }, + ); let supply_before = c.total_supply(); let vault_before = c.balance_of(&near_sdk::env::current_account_id()); @@ -2003,7 +1922,7 @@ fn ctx_allocating_ok_and_err() { }); let ok = c.ctx_allocating(42).expect("ctx_allocating should succeed"); - assert_eq!(ok, (3, 77)); + assert_eq!(ok, (&3, &77, &Default::default())); // Wrong op_id => error assert!(c.ctx_allocating(43).is_err()); @@ -2196,7 +2115,7 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { } #[rstest] -fn after_exec_withdraw_read_advances_when_remaining( +fn after_exec_withdraw_read_instant_payout_when_remaining_0( mut c: Contract, owner: AccountId, receiver: AccountId, @@ -2219,7 +2138,7 @@ fn after_exec_withdraw_read_advances_when_remaining( c.op_state = OpState::Withdrawing(WithdrawingState { op_id, index, - remaining: 100, + remaining: 10, receiver: receiver.clone(), collected: 0, owner: owner.clone(), @@ -2241,6 +2160,7 @@ fn after_exec_withdraw_read_advances_when_remaining( // Settle with the inflow equal to the reported principal delta // before = 0 // after = 10 + // We now queue up for execution let res2 = c.execute_withdraw_03_settle( Ok(U128(record.principal)), // after_balance op_id, @@ -2249,10 +2169,6 @@ fn after_exec_withdraw_read_advances_when_remaining( U128(0), U128(before_balance), ); - match res2 { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise to proceed to payout after advancing"), - } match &c.op_state { OpState::Payout(PayoutState { @@ -2347,16 +2263,28 @@ fn handles_extreme_boundaries_correctly() { fn stop_and_exit_payout_refunds_and_idle(mut c: Contract, owner: AccountId, receiver: AccountId) { use near_sdk_contract_tools::ft::Nep141Controller as _; let escrow: u128 = 10; + let amount = 77; // Seed escrowed shares into the vault's own account c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); + c.pending_withdrawals.insert( + c.queue_tail(), + PendingWithdrawal { + owner: owner.clone(), + receiver: receiver.clone(), + escrow_shares: escrow, + expected_assets: amount, + requested_at: 0, + }, + ); + // Enter Payout with non-zero escrow c.op_state = OpState::Payout(PayoutState { op_id: 123, receiver: receiver.clone(), - amount: 77, + amount, owner: owner.clone(), escrow_shares: escrow, burn_shares: escrow, @@ -2392,15 +2320,27 @@ fn stop_and_exit_payout_zero_escrow_just_idle( receiver: AccountId, ) { // Enter Payout with zero escrow; no transfers should occur + let amount = 1; c.op_state = OpState::Payout(PayoutState { op_id: 7, - receiver, - amount: 1, + receiver: receiver.clone(), + amount, owner: owner.clone(), escrow_shares: 0, burn_shares: 0, }); + c.pending_withdrawals.insert( + c.queue_tail(), + PendingWithdrawal { + owner: owner.clone(), + receiver: receiver.clone(), + escrow_shares: 0, + expected_assets: amount, + requested_at: 0, + }, + ); + let supply_before = c.ft_total_supply(); let vault_before = c.ft_balance_of(near_sdk::env::current_account_id()); let owner_before = c.ft_balance_of(owner.clone()); From e78858f3d73fb57592f1c950a8ee40b01802647e Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 12 Nov 2025 10:37:45 +0000 Subject: [PATCH 22/36] test: ditto integration tests --- contract/vault/examples/gas_report.rs | 8 +++++-- contract/vault/tests/happy_path.rs | 28 +++++++++++++++------- contract/vault/tests/invariants.rs | 11 ++++++--- test-utils/src/controller/vault.rs | 34 +++++++++++---------------- 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index a4fd0777..37400cb3 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -2,6 +2,7 @@ use near_sdk::{json_types::U128, Gas}; use rand::Rng as _; +use templar_common::vault::{AllocationDelta, Delta}; use test_utils::{setup_test, ContractController}; #[tokio::main] @@ -21,8 +22,8 @@ async fn main() { let max = c.borrow_asset.balance_of(user1.id()).await; let g = || rand::thread_rng().gen_range(0..=max); + let m = c.market.contract().id().clone(); - let weights = vec![(c.market.contract().id().clone(), U128(1))]; let user1_amount = max / ITERATIONS as u128; // Run supplies concurrently. @@ -39,7 +40,10 @@ async fn main() { let mut allocation_gas_average = 0f64; for _ in 0..ITERATIONS { let allocation_gas = vault - .allocate(&vault_curator, weights.clone(), Some(U128(user1_amount))) + .reallocate( + &vault_curator, + AllocationDelta::Supply(Delta::new(m.clone(), user1_amount)), + ) .await .total_gas_burnt .as_gas() as f64; diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 6769e3c7..9c394212 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -3,7 +3,11 @@ use near_sdk::json_types::U128; use near_workspaces::{network::Sandbox, Worker}; use rstest::rstest; -use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; +use templar_common::{ + interest_rate_strategy::InterestRateStrategy, + number::Decimal, + vault::{AllocationDelta, Delta}, +}; use test_utils::{ controller::vault::UnifiedVaultController, setup_test, worker, ContractController, UnifiedMarketController, @@ -40,9 +44,11 @@ async fn happy(#[future(awt)] worker: Worker) { println!("After supply of {}: {}", amount.0, after_supply_balance); c.collateralize(&borrow_user, 2000).await; - let weights = vec![(c.market.contract().id().clone(), U128(1))]; vault - .allocate(&vault_curator, weights.clone(), Some(amount)) + .reallocate( + &vault_curator, + AllocationDelta::Supply(Delta::new(c.market.contract().id().clone(), amount)), + ) .await; assert_eq!( @@ -93,7 +99,7 @@ async fn happy(#[future(awt)] worker: Worker) { // Plan the withdraw route (single market) and execute it via allocator methods let withdraw_route = vec![c.market.contract().id().clone()]; vault - .execute_next_withdrawal_request(&vault_curator, withdraw_route.clone()) + .execute_withdrawal(&vault_curator, withdraw_route.clone()) .await; let op_id = vault .vault @@ -101,7 +107,7 @@ async fn happy(#[future(awt)] worker: Worker) { .await .expect("Failed to get withdrawing op id"); vault - .execute_next_market_withdrawal(&vault_curator, op_id) + .execute_market_withdrawal(&vault_curator, op_id, 0, None) .await; assert_eq!( @@ -120,8 +126,12 @@ async fn happy(#[future(awt)] worker: Worker) { // Resupply and wait vault.supply(&supply_user, amount.0).await; - // FIXME:Storage issue: Error: Error { repr: Custom { kind: Execution, error: ActionError(ActionError { index: Some(0), kind: FunctionCallError(ExecutionError("Smart contract panicked: Storage error: Account vault0251007104533-70674114756315 has insufficient balance: 0.005 NEAR available, but attempted to use 0.008 NEAR")) }) } } - vault.allocate(&vault_curator, weights, Some(amount)).await; + vault + .reallocate( + &vault_curator, + AllocationDelta::Supply(Delta::new(c.market.contract().id().clone(), amount)), + ) + .await; harvest(&c, &vault).await; println!( @@ -142,7 +152,7 @@ async fn happy(#[future(awt)] worker: Worker) { // Plan the withdraw route (single market) and execute it via allocator methods let withdraw_route = vec![c.market.contract().id().clone()]; vault - .execute_next_withdrawal_request(&vault_curator, withdraw_route.clone()) + .execute_withdrawal(&vault_curator, withdraw_route.clone()) .await; let op_id = vault .vault @@ -150,7 +160,7 @@ async fn happy(#[future(awt)] worker: Worker) { .await .expect("Failed to get withdrawing operation ID"); vault - .execute_next_market_withdrawal(&vault_curator, op_id) + .execute_market_withdrawal(&vault_curator, op_id, 0, None) .await; } diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 3ad7f04e..64231a12 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -1,5 +1,6 @@ use near_workspaces::{network::Sandbox, Worker}; use rstest::rstest; +use templar_common::vault::{AllocationDelta, Delta}; use test_utils::{setup_test, worker, ContractController as _}; #[rstest] @@ -28,11 +29,15 @@ async fn state_machine_is_locked_when_another_op_is_running( extract(vault, c, vault_owner) accounts(supply_user, borrow_user) ); + let m = c.market.contract().id().clone(); let amount = 1000; vault.supply(&supply_user, amount).await; - futures::future::select_all( - (0..100).map(|_| Box::pin(vault.allocate(&vault_owner, vec![], Some(1.into())))), - ) + futures::future::select_all((0..100).map(|_| { + Box::pin(vault.reallocate( + &vault_owner, + AllocationDelta::Supply(Delta::new(m.clone(), 1)), + )) + })) .await; } diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index e3a61556..25eef3f8 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -12,7 +12,7 @@ use near_workspaces::{ network::Sandbox, result::ExecutionSuccess, types::SecretKey, Account, Contract, Worker, }; use std::{env, ops::Deref}; -use templar_common::vault::{AllocationWeights, DepositMsg, VaultConfiguration}; +use templar_common::vault::{AllocationDelta, AllocationWeights, DepositMsg, VaultConfiguration}; use tokio::sync::OnceCell; #[derive(Clone)] @@ -83,16 +83,19 @@ impl VaultController { // Allocator/curator/owner-gated: begins allocation across markets. #[call(exec, tgas(300))] - pub fn allocate(weights: AllocationWeights, amount: Option); + pub fn reallocate(delta: AllocationDelta); #[call(exec, tgas(30), deposit(NearToken::from_yoctonear(2560000000000000000000)))] pub fn withdraw(amount: U128, receiver: AccountId); #[call(exec, tgas(300))] - pub fn execute_next_withdrawal_request(route: Vec); + pub fn execute_withdrawal(route: Vec); #[call(exec, tgas(300))] - pub fn execute_next_market_withdrawal(op_id: U64); + pub fn execute_market_withdrawal(op_id: U64, market_index: u32, batch_limit: Option); + + #[call(exec, tgas(300))] + pub fn cancel_in_flight_withdrawal(); #[call(exec, tgas(300), deposit(NearToken::from_yoctonear(2560000000000000000000)))] pub fn redeem(shares: U128, receiver: AccountId); @@ -272,16 +275,8 @@ impl UnifiedVaultController { self.set_supply_queue(owner, markets).await; } - pub async fn allocate( - &self, - allocator: &Account, - weights: AllocationWeights, - amount: Option, - ) -> ExecutionSuccess { - let e = self - .vault - .allocate(allocator, weights, amount.unwrap_or(1000.into())) - .await; + pub async fn allocate(&self, allocator: &Account, delta: AllocationDelta) -> ExecutionSuccess { + let e = self.vault.reallocate(allocator, delta).await; if self.debug { print_execution(&e); } @@ -313,24 +308,23 @@ impl UnifiedVaultController { allocator: &Account, route: Vec, ) -> ExecutionSuccess { - let e = self - .vault - .execute_next_withdrawal_request(allocator, route) - .await; + let e = self.vault.execute_withdrawal(allocator, route).await; if self.debug { print_execution(&e); } e } - pub async fn execute_next_market_withdrawal( + pub async fn execute_market_withdrawal( &self, allocator: &Account, op_id: U64, + market_index: u32, + batch_limit: Option, ) -> ExecutionSuccess { let e = self .vault - .execute_next_market_withdrawal(allocator, op_id) + .execute_market_withdrawal(allocator, op_id, market_index, batch_limit) .await; if self.debug { print_execution(&e); From 59b5a5a52cbc2d99f316103a60179be9fa268544 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 12 Nov 2025 17:28:44 +0000 Subject: [PATCH 23/36] test: update happy path to create withdraw requests --- common/src/vault.rs | 82 +++++++++++------------ contract/vault/examples/gas_report.rs | 2 +- contract/vault/src/impl_callbacks.rs | 22 ++++-- contract/vault/src/impl_token_receiver.rs | 6 -- contract/vault/src/lib.rs | 7 +- contract/vault/src/tests.rs | 2 +- contract/vault/tests/happy_path.rs | 2 +- 7 files changed, 67 insertions(+), 56 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index bcc2d548..46869258 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -6,7 +6,10 @@ use near_sdk::{ near, require, AccountId, Gas, Promise, PromiseOrValue, }; -use crate::asset::{BorrowAsset, FungibleAsset}; +use crate::{ + asset::{BorrowAsset, FungibleAsset}, + supply::SupplyPosition, +}; pub type TimestampNs = u64; @@ -508,13 +511,13 @@ impl IdleBalanceDelta { #[near(event_json(standard = "templar-vault"))] pub enum Event { #[event_version("1.0.0")] - MintedShares { amount: U128, receiver: AccountId }, + IdleBalanceUpdated { prev: U128, delta: IdleBalanceDelta }, #[event_version("1.0.0")] - AllocationStarted { op_id: U64, remaining: U128 }, + PerformanceFeeAccrued { recipient: AccountId, shares: U128 }, #[event_version("1.0.0")] - IdleBalanceUpdated { prev: U128, delta: IdleBalanceDelta }, + LockChange { is_locked: bool, market_index: u32 }, - // Allocation lifecycle (plan/request) + // Allocation #[event_version("1.0.0")] AllocationRequestedQueue { op_id: U64, total: U128 }, #[event_version("1.0.0")] @@ -523,8 +526,8 @@ pub enum Event { total: U128, plan: Vec<(AccountId, U128)>, }, - - // Per-step planning and outcomes + #[event_version("1.0.0")] + AllocationStarted { op_id: U64, remaining: U128 }, #[event_version("1.0.0")] AllocationStepPlanned { op_id: U64, @@ -563,8 +566,6 @@ pub enum Event { refunded: U128, remaining_after: U128, }, - - // Completion and stop #[event_version("1.0.0")] AllocationCompleted { op_id: u64 }, #[event_version("1.0.0")] @@ -575,12 +576,6 @@ pub enum Event { reason: Option, }, - #[event_version("1.0.0")] - PerformanceFeeAccrued { recipient: AccountId, shares: U128 }, - - #[event_version("1.0.0")] - LockChange { is_locked: bool, market_index: u32 }, - // Admin and configuration events #[event_version("1.0.0")] CuratorSet { account: AccountId }, @@ -594,7 +589,6 @@ pub enum Event { FeeRecipientSet { account: AccountId }, #[event_version("1.0.0")] PerformanceFeeSet { fee: U128 }, - #[event_version("1.0.0")] TimelockSet { seconds: U64 }, #[event_version("1.0.0")] @@ -606,6 +600,15 @@ pub enum Event { #[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, @@ -613,27 +616,14 @@ pub enum Event { }, #[event_version("1.0.0")] SupplyCapRaiseRevoked { market: AccountId }, - #[event_version("1.0.0")] SupplyCapSet { market: AccountId, new_cap: U128 }, - #[event_version("1.0.0")] - MarketEnabled { market: AccountId }, - #[event_version("1.0.0")] - MarketAlreadyInWithdrawQueue { market: AccountId }, - #[event_version("1.0.0")] - WithdrawQueueMarketAdded { market: AccountId }, + #[event_version("1.0.0")] WithdrawDequeued { index: U64 }, #[event_version("1.0.0")] WithdrawalParked { id: U64 }, #[event_version("1.0.0")] - MarketRemovalSubmitted { - market: AccountId, - removable_at: U64, - }, - #[event_version("1.0.0")] - MarketRemovalRevoked { market: AccountId }, - #[event_version("1.0.0")] WithdrawExecutionRequired { op_id: U64, market_index: u32 }, // User flows @@ -672,19 +662,27 @@ pub enum Event { // Withdrawal read diagnostics #[event_version("1.0.0")] - WithdrawalPositionReadFailed { + CreateWithdrawalFailed { op_id: U64, market: AccountId, index: u32, - before: U128, + need: U128, }, #[event_version("1.0.0")] - CreateWithdrawalFailed { + WithdrawalInflowMismatch { op_id: U64, market: AccountId, index: u32, - need: U128, + delta: U128, + inflow: U128, + }, + #[event_version("1.0.0")] + WithdrawalOverpayCredited { + op_id: U64, + market: AccountId, + index: u32, + extra: U128, }, // Payout and stop diagnostics @@ -720,27 +718,29 @@ pub enum Event { }, #[event_version("1.0.0")] - WithdrawalPositionMissing { + ReportedPosition { op_id: U64, market: AccountId, index: u32, - before: U128, + position: SupplyPosition, }, #[event_version("1.0.0")] - WithdrawalInflowMismatch { + PositionReadFailed { op_id: U64, market: AccountId, index: u32, - delta: U128, - inflow: U128, + before: U128, }, #[event_version("1.0.0")] - WithdrawalOverpayCredited { + PositionMissing { op_id: U64, market: AccountId, index: u32, - extra: U128, + before: U128, }, + + #[event_version("1.0.0")] + VaultBalance { amount: U128 }, } #[near(serializers = [borsh, serde])] diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index 37400cb3..4c7221eb 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -84,7 +84,7 @@ async fn main() { let mut execute_withdraw_gas_average = 0f64; for _ in 0..ITERATIONS { let execute_gas = vault - .execute_next_withdrawal(&vault_curator, withdraw_route.clone()) + .execute_withdrawal(&vault_curator, withdraw_route.clone()) .await .total_gas_burnt .as_gas() as f64; diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 80072695..4c21decd 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -197,7 +197,7 @@ impl Contract { } #[private] - pub fn execute_withdraw_01_call_market_fetch_position( + pub fn execute_withdraw_01_execute_withdraw_fetch_position( &mut self, #[callback_result] before_balance: Result, op_id: u64, @@ -212,6 +212,11 @@ impl Contract { let principal = self.principal_of(&market); let before_balance = before_balance.unwrap_or(U128(self.idle_balance)); + Event::VaultBalance { + amount: before_balance, + } + .emit(); + PromiseOrValue::Promise( ext_market::ext(market.clone()) // NOTE: gas might be incorrect here @@ -264,9 +269,18 @@ impl Contract { }; let reported_principal: u128 = match position { - Ok(Some(position)) => position.get_deposit().total().into(), + Ok(Some(position)) => { + Event::ReportedPosition { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + position: position.clone(), + } + .emit(); + position.get_deposit().total().into() + } Ok(None) => { - Event::WithdrawalPositionMissing { + Event::PositionMissing { op_id: op_id.into(), market: market.clone(), index: market_index, @@ -277,7 +291,7 @@ impl Contract { 0 } Err(_) => { - Event::WithdrawalPositionReadFailed { + Event::PositionReadFailed { op_id: op_id.into(), market: market.clone(), index: market_index, diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 13c5a9b7..16512d0d 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -122,12 +122,6 @@ impl Contract { self.mint(&Nep141Mint::new(shares, &sender_id)) .unwrap_or_else(|_| env::panic_str("Failed to mint shares")); - Event::MintedShares { - amount: shares.into(), - receiver: sender_id.clone(), - } - .emit(); - self.update_idle_balance(IdleBalanceDelta::Increase(accept.into())); self.last_total_assets = self.last_total_assets.saturating_add(accept); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index dbb4c782..7079b757 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -282,6 +282,7 @@ impl Contract { .unwrap_or_else(|| env::panic_str("pending vanished unexpectedly")); let owner = pending.owner.clone(); let receiver = pending.receiver.clone(); + env::log_str(&format!("Executing withdrawal for {pending:?}")); if pending.expected_assets == 0 { // Skip dust request to avoid wedging the queue @@ -332,11 +333,12 @@ impl Contract { PromiseOrValue::Promise( ext_ft_core::ext(self.underlying_asset.contract_id().into()) .with_static_gas(Gas::from_tgas(5)) + .with_unused_gas_weight(0) .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) - .with_static_gas(EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS) - .execute_withdraw_01_call_market_fetch_position( + .with_unused_gas_weight(100) + .execute_withdraw_01_execute_withdraw_fetch_position( op_id.into(), market_index, batch_limit, @@ -964,6 +966,7 @@ impl Contract { }; if remaining == 0 { + // FIXME: event for coveredbyidle // Already fully covered by idle => payout self.pay( op_id, diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 719672d2..452413a4 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -2103,7 +2103,7 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { escrow_shares: 0, }); - let res = c.execute_withdraw_01_call_market_fetch_position(Ok(U128(1)), op_id, 0, None); + let res = c.execute_withdraw_01_execute_withdraw_fetch_position(Ok(U128(1)), op_id, 0, None); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to read supply position after exec"), diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 9c394212..4b2d1e11 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -107,7 +107,7 @@ async fn happy(#[future(awt)] worker: Worker) { .await .expect("Failed to get withdrawing op id"); vault - .execute_market_withdrawal(&vault_curator, op_id, 0, None) + .execute_market_withdrawal(&vault_curator, op_id, 0, Some(10)) .await; assert_eq!( From 9b94575315fa48713069f70cfcbb56b193af7303 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 11:12:20 +0000 Subject: [PATCH 24/36] refactor: clean up events --- common/src/vault.rs | 135 ++++++++++++++++----------- contract/vault/src/impl_callbacks.rs | 48 ++++++---- contract/vault/src/lib.rs | 117 ++++++++++++++++++----- contract/vault/tests/happy_path.rs | 23 +++-- test-utils/src/controller/vault.rs | 2 +- 5 files changed, 221 insertions(+), 104 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 46869258..522701ee 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -508,6 +508,50 @@ impl IdleBalanceDelta { } } +#[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 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, +} + #[near(event_json(standard = "templar-vault"))] pub enum Event { #[event_version("1.0.0")] @@ -515,12 +559,12 @@ pub enum Event { #[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")] - AllocationRequestedQueue { op_id: U64, total: U128 }, - #[event_version("1.0.0")] AllocationPlanSet { op_id: U64, total: U128, @@ -529,7 +573,7 @@ pub enum Event { #[event_version("1.0.0")] AllocationStarted { op_id: U64, remaining: U128 }, #[event_version("1.0.0")] - AllocationStepPlanned { + AllocationStepPlan { op_id: U64, index: u32, market: AccountId, @@ -538,14 +582,7 @@ pub enum Event { to_supply: U128, remaining_before: U128, planned: bool, - }, - #[event_version("1.0.0")] - AllocationStepSkipped { - op_id: U64, - index: u32, - market: AccountId, - reason: String, - remaining: U128, + reason: Option, }, #[event_version("1.0.0")] AllocationTransferFailed { @@ -573,7 +610,7 @@ pub enum Event { op_id: U64, index: u32, remaining: U128, - reason: Option, + reason: Option, }, // Admin and configuration events @@ -620,11 +657,9 @@ pub enum Event { SupplyCapSet { market: AccountId, new_cap: U128 }, #[event_version("1.0.0")] - WithdrawDequeued { index: U64 }, + WithdrawQueueUpdate { action: QueueAction, id: U64 }, #[event_version("1.0.0")] - WithdrawalParked { id: U64 }, - #[event_version("1.0.0")] - WithdrawExecutionRequired { op_id: U64, market_index: u32 }, + WithdrawQueueStatus { status: QueueStatus, id: Option }, // User flows #[event_version("1.0.0")] @@ -641,23 +676,32 @@ pub enum Event { expected_assets: U128, requested_at: U64, }, - - // Allocation read/settlement diagnostics #[event_version("1.0.0")] - AllocationPositionMissing { - op_id: U64, - index: u32, - market: AccountId, - attempted: U128, - accepted: U128, + WithdrawPreview { shares: U128, receiver: AccountId }, + #[event_version("1.0.0")] + WithdrawProgress { + phase: String, + 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")] - AllocationPositionReadFailed { + SupplyWithdrawRequestCreated { market: AccountId, amount: U128 }, + + // 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 @@ -670,19 +714,14 @@ pub enum Event { }, #[event_version("1.0.0")] - WithdrawalInflowMismatch { - op_id: U64, - market: AccountId, - index: u32, - delta: U128, - inflow: U128, - }, - #[event_version("1.0.0")] - WithdrawalOverpayCredited { + WithdrawalAccounting { + kind: WithdrawalAccountingKind, op_id: U64, market: AccountId, index: u32, - extra: U128, + delta: Option, + inflow: Option, + extra: Option, }, // Payout and stop diagnostics @@ -698,17 +737,17 @@ pub enum Event { index: u32, remaining: U128, collected: U128, - reason: Option, + reason: Option, }, #[event_version("1.0.0")] PayoutStopped { op_id: U64, receiver: AccountId, amount: U128, - reason: Option, + reason: Option, }, #[event_version("1.0.0")] - OperationStoppedWhileIdle { reason: Option }, + OperationStoppedWhileIdle { reason: Option }, // Skim and deposits #[event_version("1.0.0")] @@ -718,25 +757,13 @@ pub enum Event { }, #[event_version("1.0.0")] - ReportedPosition { - op_id: U64, - market: AccountId, - index: u32, - position: SupplyPosition, - }, - #[event_version("1.0.0")] - PositionReadFailed { + WithdrawPositionReport { + outcome: PositionReportOutcome, op_id: U64, market: AccountId, index: u32, - before: U128, - }, - #[event_version("1.0.0")] - PositionMissing { - op_id: U64, - market: AccountId, - index: u32, - before: U128, + position: Option, + before: Option, }, #[event_version("1.0.0")] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 4c21decd..17e481f8 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -12,6 +12,7 @@ use templar_common::{ supply::SupplyPosition, vault::{ AllocatingState, AllocationPlan, EscrowSettlement, Event, IdleBalanceDelta, PayoutState, + Reason, AllocationPositionIssueKind, WithdrawalAccountingKind, PositionReportOutcome, WithdrawingState, EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_03_SETTLE_GAS, GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, }, @@ -112,24 +113,26 @@ impl Contract { &remaining_before.0, ), Ok(None) => { - Event::AllocationPositionMissing { + Event::AllocationPositionIssue { op_id: op_id.into(), index: market_index, market: market.clone(), attempted, accepted, + kind: AllocationPositionIssueKind::Missing, } .emit(); return self.stop_and_exit(Some(&Error::MissingSupplyPosition)); } Err(_) => { - Event::AllocationPositionReadFailed { + Event::AllocationPositionIssue { op_id: op_id.into(), index: market_index, market: market.clone(), attempted, accepted, + kind: AllocationPositionIssueKind::ReadFailed, } .emit(); return self.stop_and_exit(Some(&Error::PositionReadFailed)); @@ -270,32 +273,38 @@ impl Contract { let reported_principal: u128 = match position { Ok(Some(position)) => { - Event::ReportedPosition { + Event::WithdrawPositionReport { + outcome: PositionReportOutcome::Ok, op_id: op_id.into(), market: market.clone(), index: market_index, - position: position.clone(), + position: Some(position.clone()), + before: None, } .emit(); position.get_deposit().total().into() } Ok(None) => { - Event::PositionMissing { + Event::WithdrawPositionReport { + outcome: PositionReportOutcome::Missing, op_id: op_id.into(), market: market.clone(), index: market_index, - before: principal, + position: None, + before: Some(principal), } .emit(); // Treat missing position as zero principal and continue to balance settlement 0 } Err(_) => { - Event::PositionReadFailed { + Event::WithdrawPositionReport { + outcome: PositionReportOutcome::ReadFailed, op_id: op_id.into(), market: market.clone(), index: market_index, - before: principal, + position: None, + before: Some(principal), } .emit(); @@ -347,21 +356,26 @@ impl Contract { match principal_delta.cmp(&inflow) { Ordering::Greater => { - Event::WithdrawalInflowMismatch { + Event::WithdrawalAccounting { + kind: WithdrawalAccountingKind::InflowMismatch, op_id: op_id.into(), market: market.clone(), index: market_index, - delta: U128(principal_delta), - inflow: U128(inflow), + delta: Some(U128(principal_delta)), + inflow: Some(U128(inflow)), + extra: None, } .emit(); } Ordering::Less => { - Event::WithdrawalOverpayCredited { + Event::WithdrawalAccounting { + kind: WithdrawalAccountingKind::OverpayCredited, op_id: op_id.into(), market: market.clone(), index: market_index, - extra: U128(extra), + delta: None, + inflow: None, + extra: Some(U128(extra)), } .emit(); } @@ -550,7 +564,7 @@ impl Contract { op_id: s.op_id.into(), index: s.index, remaining: U128(s.remaining), - reason: Some(m.to_string()), + reason: Some(Reason::Other(m.to_string())), } }) .emit(); @@ -572,7 +586,7 @@ impl Contract { index: s.index, remaining: U128(s.remaining), collected: U128(s.collected), - reason: msg.map(std::string::ToString::to_string), + reason: msg.map(|m| Reason::Other(m.to_string())), } .emit(); @@ -601,7 +615,7 @@ impl Contract { op_id: (s.op_id).into(), receiver: s.receiver.clone(), amount: U128(s.amount), - reason: msg.map(std::string::ToString::to_string), + reason: msg.map(|m| Reason::Other(m.to_string())), } .emit(); @@ -627,7 +641,7 @@ impl Contract { OpState::Payout(_) => self.stop_and_exit_payout(msg), OpState::Idle => { Event::OperationStoppedWhileIdle { - reason: msg.map(std::string::ToString::to_string), + reason: msg.map(|m| Reason::Other(m.to_string())), } .emit(); } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 7079b757..e0a96979 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -32,12 +32,12 @@ use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, market::ext_market, vault::{ - require_at_least, AllocatingState, AllocationDelta, AllocationPlan, Error, Event, - IdleBalanceDelta, Locker, MarketConfiguration, OpState, PayoutState, PendingValue, - PendingWithdrawal, TimestampNs, VaultConfiguration, WithdrawingState, - AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, - EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_GAS, MAX_TIMELOCK_NS, - MIN_TIMELOCK_NS, WITHDRAW_GAS, + require_at_least, AllocatingState, AllocationDelta, AllocationPlan, Error, Event, Reason, + QueueStatus, QueueAction, IdleBalanceDelta, Locker, MarketConfiguration, OpState, + PayoutState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, + WithdrawingState, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, + CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_GAS, + MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; pub use wad::*; @@ -233,6 +233,11 @@ impl Contract { pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { require_at_least(WITHDRAW_GAS); let shares_needed = self.preview_withdraw(amount).0; + Event::WithdrawPreview { + shares: U128(shares_needed), + receiver: receiver.clone(), + } + .emit(); self.redeem(U128(shares_needed), receiver) } @@ -280,11 +285,35 @@ impl Contract { .pending_withdrawals .get(&id) .unwrap_or_else(|| env::panic_str("pending vanished unexpectedly")); + Event::WithdrawProgress { + phase: "execution_started".to_string(), + 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(); - env::log_str(&format!("Executing withdrawal for {pending:?}")); if pending.expected_assets == 0 { + Event::WithdrawProgress { + phase: "skipped_dust".to_string(), + 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); @@ -297,6 +326,12 @@ impl Contract { pending.escrow_shares, route, ); + } else { + Event::WithdrawQueueStatus { + status: QueueStatus::Empty, + id: None, + } + .emit(); } PromiseOrValue::Value(()) @@ -433,11 +468,11 @@ impl Contract { let to_request = self.principal_of(&delta.market).min(delta.amount.0); require!(to_request > 0, "Insufficient principal"); - // TODO: proper event - env::log_str(&format!( - "DeltaWithdrawRequestCreated market={} amount={}", - delta.market, to_request - )); + Event::SupplyWithdrawRequestCreated { + market: delta.market.clone(), + amount: U128(to_request), + } + .emit(); PromiseOrValue::Promise( ext_market::ext(delta.market.clone()) @@ -460,8 +495,18 @@ impl Contract { 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 } } @@ -472,12 +517,17 @@ impl Contract { 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::WithdrawDequeued { index: id.into() }.emit(); + Event::WithdrawQueueUpdate { + action: QueueAction::Dequeued, + id: id.into(), + } + .emit(); } // Keep the head pending and emit telemetry; can be retried later fn park_head_for_retry(&mut self) { - Event::WithdrawalParked { + Event::WithdrawQueueUpdate { + action: QueueAction::Parked, id: self.next_withdraw_to_execute.into(), } .emit(); @@ -748,7 +798,12 @@ impl Contract { let recipient = self.fee_recipient.clone(); let _ = self .mint(&Nep141Mint::new(minted, &recipient)) - .inspect_err(|e| env::log_str(&format!("Failed to mint {e}"))); + .inspect_err(|e| { + Event::PerformanceFeeMintFailed { + error: e.to_string(), + } + .emit() + }); Event::PerformanceFeeAccrued { recipient, shares: U128(minted), @@ -876,7 +931,7 @@ impl Contract { let room = self.room_of(&market_id); let to_supply = room.min(*amount); - Event::AllocationStepPlanned { + Event::AllocationStepPlan { op_id: op_id.into(), index, market: market_id.clone(), @@ -885,20 +940,25 @@ impl Contract { to_supply: U128(to_supply), remaining_before: U128(remaining), planned: true, + reason: None, } .emit(); if to_supply == 0 { - Event::AllocationStepSkipped { + Event::AllocationStepPlan { op_id: op_id.into(), index, market: market_id.clone(), - reason: if room == 0 { - "no-room".to_string() + target: U128(*amount), + room: U128(room), + to_supply: U128(0), + remaining_before: U128(remaining), + planned: false, + reason: Some(if room == 0 { + Reason::NoRoom } else { - "zero-target".to_string() - }, - remaining: U128(remaining), + Reason::ZeroTarget + }), } .emit(); @@ -978,9 +1038,16 @@ impl Contract { ); } if self.withdraw_route.get(index as usize).is_some() { - Event::WithdrawExecutionRequired { - op_id: op_id.into(), - market_index: index, + Event::WithdrawProgress { + phase: "execution_required".to_string(), + 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(); return PromiseOrValue::Value(()); diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 4b2d1e11..195502e4 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -83,24 +83,33 @@ async fn happy(#[future(awt)] worker: Worker) { harvest(&c, &vault).await; - let supply_position = c.get_supply_position(v).await.unwrap(); - assert_eq!( - u128::from(supply_position.get_deposit().active), + u128::from(c.get_supply_position(v).await.unwrap().get_deposit().active), amount.0, "Supply position should match amount of tokens supplied to contract", ); - let user_balance = c.borrow_asset.balance_of(supply_user.id()).await; + let balance_before_withdraw = c.borrow_asset.balance_of(supply_user.id()).await; vault.withdraw(&supply_user, amount, None).await; - // Ensure deposits are activated before we attempt to route and execute the withdrawal + harvest(&c, &vault).await; + + let mkt = c.market.contract().id(); + + vault + .reallocate( + &vault_curator, + AllocationDelta::Withdraw(Delta::new(mkt.clone(), amount)), + ) + .await; + // Plan the withdraw route (single market) and execute it via allocator methods - let withdraw_route = vec![c.market.contract().id().clone()]; + let withdraw_route = vec![mkt.clone()]; vault .execute_withdrawal(&vault_curator, withdraw_route.clone()) .await; + let op_id = vault .vault .get_withdrawing_op_id() @@ -112,7 +121,7 @@ async fn happy(#[future(awt)] worker: Worker) { assert_eq!( c.borrow_asset.balance_of(supply_user.id()).await, - amount.0 + user_balance, + amount.0 + balance_before_withdraw, "Supply user should have received their tokens back" ); diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 25eef3f8..82306023 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -303,7 +303,7 @@ impl UnifiedVaultController { e } - pub async fn execute_next_withdrawal( + pub async fn execute_withdrawal( &self, allocator: &Account, route: Vec, From 6b079ffb137f6e107292c52ed46bfd23ab750a3f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 11:20:02 +0000 Subject: [PATCH 25/36] chore: fmt and lint --- contract/vault/src/impl_callbacks.rs | 4 ++-- contract/vault/src/impl_token_receiver.rs | 2 +- contract/vault/src/lib.rs | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 17e481f8..b6d54683 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -11,8 +11,8 @@ use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - AllocatingState, AllocationPlan, EscrowSettlement, Event, IdleBalanceDelta, PayoutState, - Reason, AllocationPositionIssueKind, WithdrawalAccountingKind, PositionReportOutcome, + AllocatingState, AllocationPlan, AllocationPositionIssueKind, EscrowSettlement, Event, + IdleBalanceDelta, PayoutState, PositionReportOutcome, Reason, WithdrawalAccountingKind, WithdrawingState, EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_03_SETTLE_GAS, GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, }, diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 16512d0d..d408546c 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -2,7 +2,7 @@ use crate::{Contract, ContractExt, OpState}; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; use near_sdk_contract_tools::ft::{Nep141Controller as _, Nep141Mint}; -use templar_common::vault::{require_at_least, DepositMsg, Event, IdleBalanceDelta, SUPPLY_GAS}; +use templar_common::vault::{require_at_least, DepositMsg, IdleBalanceDelta, SUPPLY_GAS}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index e0a96979..dbb6547b 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1,7 +1,7 @@ #![allow(clippy::needless_pass_by_value)] use std::{ - collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashSet}, num::NonZeroU8, }; @@ -32,11 +32,11 @@ use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, market::ext_market, vault::{ - require_at_least, AllocatingState, AllocationDelta, AllocationPlan, Error, Event, Reason, - QueueStatus, QueueAction, IdleBalanceDelta, Locker, MarketConfiguration, OpState, - PayoutState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, + require_at_least, AllocatingState, AllocationDelta, AllocationPlan, Error, Event, + IdleBalanceDelta, Locker, MarketConfiguration, OpState, PayoutState, PendingValue, + PendingWithdrawal, QueueAction, QueueStatus, Reason, TimestampNs, VaultConfiguration, WithdrawingState, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, - CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_GAS, + CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; @@ -488,7 +488,7 @@ impl Contract { // Derive queue tail = head + pending.len() fn queue_tail(&self) -> u64 { - self.next_withdraw_to_execute + (self.pending_withdrawals.len() as u64) + self.next_withdraw_to_execute + u64::from(self.pending_withdrawals.len()) } // Return the current head id if queue is non-empty @@ -802,7 +802,7 @@ impl Contract { Event::PerformanceFeeMintFailed { error: e.to_string(), } - .emit() + .emit(); }); Event::PerformanceFeeAccrued { recipient, @@ -1050,7 +1050,7 @@ impl Contract { requested_at: None, } .emit(); - return PromiseOrValue::Value(()); + PromiseOrValue::Value(()) } else { let requested = collected.saturating_add(remaining); let burn_shares = Self::compute_burn_shares(escrow_shares, collected, requested); From b5fcce80f9250eced63ac010164900e8e39106e4 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 11:29:04 +0000 Subject: [PATCH 26/36] test: cancel inflight withdraw --- contract/vault/src/tests.rs | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 452413a4..5fdb3a43 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -2360,3 +2360,146 @@ fn stop_and_exit_payout_zero_escrow_just_idle( "Owner balance unchanged" ); } + +#[test] +fn cancel_in_flight_withdrawal_refunds_and_dequeues() { + let vault_id = accounts(0); + let owner = accounts(1); + setup_env(&vault_id, &owner, vec![]); + + let mut c = new_test_contract(&vault_id); + + // Seed escrowed shares into the vault's own account + let escrow: u128 = 10; + c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + // Enqueue a pending withdrawal at the head + let id_before = c.queue_tail(); + let receiver = mk(9); + c.pending_withdrawals.insert( + id_before, + PendingWithdrawal { + owner: owner.clone(), + receiver: receiver.clone(), + escrow_shares: escrow, + expected_assets: 1, + requested_at: 0, + }, + ); + + // Simulate an in-flight withdrawing state + c.withdraw_route = vec![mk(1001)]; + c.op_state = OpState::Withdrawing(WithdrawingState { + op_id: 42, + index: 0, + remaining: 1, + receiver, + collected: 0, + owner: owner.clone(), + escrow_shares: escrow, + }); + + let supply_before = c.total_supply(); + let vault_before = c.balance_of(&near_sdk::env::current_account_id()); + let owner_before = c.balance_of(&owner); + let len_before = c.pending_withdrawals.len(); + + let res = c.cancel_in_flight_withdrawal(); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) from cancel_in_flight_withdrawal"), + } + + // Escrow refunded, head advanced, state reset + assert!(matches!(c.op_state, OpState::Idle), "vault should return to Idle"); + assert!(c.withdraw_route.is_empty(), "withdraw route must be cleared"); + assert_eq!(c.total_supply(), supply_before, "no supply change"); + assert_eq!( + c.balance_of(&near_sdk::env::current_account_id()), + vault_before.saturating_sub(escrow), + "vault should refund escrow to owner" + ); + assert_eq!( + c.balance_of(&owner), + owner_before.saturating_add(escrow), + "owner should receive escrow refund" + ); + assert_eq!( + c.pending_withdrawals.len(), + len_before.saturating_sub(1), + "queue should dequeue the in-flight request" + ); + assert_eq!( + c.next_withdraw_to_execute, + id_before.saturating_add(1), + "head should advance by one" + ); +} + +#[test] +fn cancel_in_flight_withdrawal_noop_when_not_withdrawing() { + let vault_id = accounts(0); + let owner = accounts(1); + setup_env(&vault_id, &owner, vec![]); + + let mut c = new_test_contract(&vault_id); + c.op_state = OpState::Idle; + + // Capture baseline + let len_before = c.pending_withdrawals.len(); + let head_before = c.next_withdraw_to_execute; + let supply_before = c.total_supply(); + let vault_before = c.balance_of(&near_sdk::env::current_account_id()); + let owner_before = c.balance_of(&owner); + + let res = c.cancel_in_flight_withdrawal(); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) from cancel_in_flight_withdrawal"), + } + + // No changes expected when not Withdrawing + assert!(matches!(c.op_state, OpState::Idle)); + assert_eq!(c.pending_withdrawals.len(), len_before); + assert_eq!(c.next_withdraw_to_execute, head_before); + assert_eq!(c.total_supply(), supply_before); + assert_eq!( + c.balance_of(&near_sdk::env::current_account_id()), + vault_before + ); + assert_eq!(c.balance_of(&owner), owner_before); +} + +#[rstest( + idle, amount, remaining, collected, + case(0u128, 0u128, 0u128, 0u128), + case(100u128, 0u128, 0u128, 0u128), + case(0u128, 50u128, 50u128, 0u128), + case(100u128, 50u128, 50u128, 50u128), + case(100u128, 100u128, 0u128, 100u128), + case(100u128, 150u128, 50u128, 100u128), + case(u128::MAX, 1u128, 0u128, 1u128), + case(1u128, u128::MAX, u128::MAX - 1u128, 1u128), +)] +fn idle_delta_cases(mut c: Contract, idle: u128, amount: u128, remaining: u128, collected: u128) { + // Arrange + c.idle_balance = idle; + let idle_before = c.idle_balance; + + // Act + let (rem, coll) = c.idle_delta(amount); + + // Assert + assert_eq!(rem, remaining, "remaining should match expected"); + assert_eq!(coll, collected, "collected should match expected"); + assert_eq!( + rem.saturating_add(coll), + amount, + "invariant: remaining + collected == amount" + ); + assert_eq!( + c.idle_balance, idle_before, + "idle_delta must not mutate idle_balance" + ); +} From ca075b286b34d2ce332ef3353af5ce7142469d35 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 11:29:15 +0000 Subject: [PATCH 27/36] test: peek withdrawal --- contract/vault/src/tests.rs | 113 +++++++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 5fdb3a43..d11c3e84 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -2364,10 +2364,9 @@ fn stop_and_exit_payout_zero_escrow_just_idle( #[test] fn cancel_in_flight_withdrawal_refunds_and_dequeues() { let vault_id = accounts(0); - let owner = accounts(1); - setup_env(&vault_id, &owner, vec![]); - let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); + setup_env(&vault_id, &owner, vec![]); // Seed escrowed shares into the vault's own account let escrow: u128 = 10; @@ -2412,8 +2411,14 @@ fn cancel_in_flight_withdrawal_refunds_and_dequeues() { } // Escrow refunded, head advanced, state reset - assert!(matches!(c.op_state, OpState::Idle), "vault should return to Idle"); - assert!(c.withdraw_route.is_empty(), "withdraw route must be cleared"); + assert!( + matches!(c.op_state, OpState::Idle), + "vault should return to Idle" + ); + assert!( + c.withdraw_route.is_empty(), + "withdraw route must be cleared" + ); assert_eq!(c.total_supply(), supply_before, "no supply change"); assert_eq!( c.balance_of(&near_sdk::env::current_account_id()), @@ -2440,10 +2445,10 @@ fn cancel_in_flight_withdrawal_refunds_and_dequeues() { #[test] fn cancel_in_flight_withdrawal_noop_when_not_withdrawing() { let vault_id = accounts(0); - let owner = accounts(1); + let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - let mut c = new_test_contract(&vault_id); c.op_state = OpState::Idle; // Capture baseline @@ -2503,3 +2508,97 @@ fn idle_delta_cases(mut c: Contract, idle: u128, amount: u128, remaining: u128, "idle_delta must not mutate idle_balance" ); } + +#[test] +fn peek_next_pending_withdrawal_id_empty_returns_none() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let c = new_test_contract(&vault_id); + + assert_eq!(c.pending_withdrawals.len(), 0, "queue should start empty"); + + let head_before = c.next_withdraw_to_execute; + let tail_before = c.queue_tail(); + assert_eq!( + head_before, tail_before, + "empty queue invariant: head == tail" + ); + + let got = c.peek_next_pending_withdrawal_id(); + assert!(got.is_none(), "expected None for empty queue"); + + // Subsequent call still None and state unchanged + let got2 = c.peek_next_pending_withdrawal_id(); + assert!(got2.is_none(), "expected None on repeated peek"); + assert_eq!( + c.next_withdraw_to_execute, head_before, + "peek must not mutate the head" + ); + assert_eq!( + c.pending_withdrawals.len(), + 0, + "peek must not change the queue length" + ); +} + +#[test] +fn peek_next_pending_withdrawal_id_nonempty_returns_head_and_does_not_mutate() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Enqueue two pending withdrawals at tail positions + let head_before = c.next_withdraw_to_execute; + + let id1 = c.queue_tail(); + c.pending_withdrawals.insert( + id1, + PendingWithdrawal { + owner: accounts(1), + receiver: mk(9), + escrow_shares: 1, + expected_assets: 1, + requested_at: 0, + }, + ); + + let id2 = c.queue_tail(); + c.pending_withdrawals.insert( + id2, + PendingWithdrawal { + owner: accounts(2), + receiver: mk(10), + escrow_shares: 2, + expected_assets: 2, + requested_at: 0, + }, + ); + + assert!( + head_before < c.queue_tail(), + "sanity: queue should now be non-empty (head < tail)" + ); + + // Peek should return the current head id + let got = c.peek_next_pending_withdrawal_id(); + assert_eq!( + got, + Some(head_before), + "peek should return the current head id" + ); + + // Ensure peek does not mutate any state + assert_eq!( + c.next_withdraw_to_execute, head_before, + "head must be unchanged by peek" + ); + assert_eq!( + c.pending_withdrawals.len(), + 2, + "peek must not modify queue length" + ); + + // Repeated peek yields the same result + let got2 = c.peek_next_pending_withdrawal_id(); + assert_eq!(got2, Some(head_before)); +} From 5396e48dfcc9ef0badf49e2eed84a37fe43e7bb8 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 11:42:23 +0000 Subject: [PATCH 28/36] test: reallocate withdraw --- contract/vault/src/tests.rs | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index d11c3e84..6d06f945 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -315,6 +315,87 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { ); } +#[test] +#[should_panic = "Insufficient principal"] +fn reallocate_withdraw_insufficient_principal_panics() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); + setup_env(&vault_id, &owner, vec![]); + + // Known market with zero principal -> cannot request any withdrawal + let m = mk(9201); + c.markets.insert( + m.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 0, + }, + ); + + let _ = c.reallocate(AllocationDelta::Withdraw(Delta::new(m, 1))); +} + +#[test] +#[should_panic = "Insufficient principal"] +fn reallocate_withdraw_zero_amount_panics() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); + setup_env(&vault_id, &owner, vec![]); + + // Principal exists but requested amount is zero -> to_request = 0 -> panic + let m = mk(9202); + let rec = MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 0, + }; + c.markets.insert(m.clone(), rec.clone()); + + let _ = c.reallocate(AllocationDelta::Withdraw(Delta::new(m, 100))); +} + +#[test] +fn reallocate_withdraw_returns_promise_and_does_not_mutate() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); + setup_env(&vault_id, &owner, vec![]); + + // Principal exists; request larger than principal should cap to principal internally + let m = mk(9203); + c.markets.insert( + m.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 40, + }, + ); + + let principal_before = c.principal_of(&m); + assert_eq!(principal_before, 40, "sanity: principal set"); + + let res = c.reallocate(AllocationDelta::Withdraw(Delta::new(m.clone(), 100))); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise for withdraw reallocation"), + } + + // No immediate state mutations for withdraw request creation + assert!( + matches!(c.op_state, OpState::Idle), + "reallocate withdraw should not change op_state" + ); + assert_eq!( + c.principal_of(&m), + principal_before, + "principal must not change when only creating a withdraw request" + ); +} + #[rstest( escrow, collected, requested, expect, case(100u128, 200u128, 500u128, 40u128), // 40% From 97cad3113cdc1444747385214d7a3aaaeeaa0083 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 11:42:49 +0000 Subject: [PATCH 29/36] test: execute withdrawal --- contract/vault/src/tests.rs | 163 ++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 6d06f945..6858bc5d 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -2683,3 +2683,166 @@ fn peek_next_pending_withdrawal_id_nonempty_returns_head_and_does_not_mutate() { let got2 = c.peek_next_pending_withdrawal_id(); assert_eq!(got2, Some(head_before)); } + +#[test] +fn execute_withdrawal_empty_queue_noop() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); + setup_env(&vault_id, &owner, vec![]); + + assert!(matches!(c.op_state, OpState::Idle)); + assert_eq!(c.pending_withdrawals.len(), 0, "queue should be empty"); + + let res = c.execute_withdrawal(vec![]); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) when queue is empty"), + } + + assert!( + matches!(c.op_state, OpState::Idle), + "state must remain Idle" + ); + assert_eq!( + c.get_current_withdraw_request_id(), + None, + "no current request id when idle" + ); + assert!(c.withdraw_route.is_empty(), "route must remain empty"); +} + +#[test] +fn execute_withdrawal_skips_dust_and_starts_withdraw() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner_id = c.own_get_owner().unwrap(); + setup_env(&vault_id, &owner_id, vec![]); + + // Prepare a withdraw market so a non-empty route makes sense + let m1 = mk(1234); + c.markets.insert( + m1.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 50, + }, + ); + + // Enqueue a dust head (expected_assets = 0) + let head_before = c.queue_tail(); + c.pending_withdrawals.insert( + head_before, + PendingWithdrawal { + owner: owner_id.clone(), + receiver: mk(9), + escrow_shares: 1, + expected_assets: 0, + requested_at: 0, + }, + ); + + // Followed by a real pending withdrawal + let receiver = mk(10); + let escrow: u128 = 5; + let expected: u128 = 60; + let id1 = c.queue_tail(); + c.pending_withdrawals.insert( + id1, + PendingWithdrawal { + owner: owner_id.clone(), + receiver: receiver.clone(), + escrow_shares: escrow, + expected_assets: expected, + requested_at: 0, + }, + ); + + c.idle_balance = 0; // force route-based execution + + let res = c.execute_withdrawal(vec![m1.clone()]); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) to signal offchain to execute next market"), + } + + // Dust head must be removed and head advanced to the second request + assert_eq!( + c.next_withdraw_to_execute, id1, + "head should advance past dust" + ); + assert_eq!( + c.pending_withdrawals.len(), + 1, + "one item should remain in queue" + ); + assert_eq!( + c.get_current_withdraw_request_id(), + Some(near_sdk::json_types::U64(id1)), + "current request should be the second item" + ); + assert_eq!(c.withdraw_route, vec![m1], "route must be set from input"); + + match &c.op_state { + OpState::Withdrawing(s) => { + assert_eq!(s.index, 0); + assert_eq!( + s.remaining, expected, + "no idle used so remaining equals expected" + ); + assert_eq!(s.collected, 0, "no idle collected"); + assert_eq!(s.owner, owner_id); + assert_eq!(s.receiver, receiver); + assert_eq!(s.escrow_shares, escrow); + } + other => panic!("Expected Withdrawing state, got {:?}", other), + } +} + +#[test] +fn execute_withdrawal_only_dust_drains_queue() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); + setup_env(&vault_id, &owner, vec![]); + + // Two dust entries + let id0 = c.queue_tail(); + c.pending_withdrawals.insert( + id0, + PendingWithdrawal { + owner: owner.clone(), + receiver: mk(9), + escrow_shares: 1, + expected_assets: 0, + requested_at: 0, + }, + ); + let id1 = c.queue_tail(); + c.pending_withdrawals.insert( + id1, + PendingWithdrawal { + owner, + receiver: mk(10), + escrow_shares: 2, + expected_assets: 0, + requested_at: 0, + }, + ); + + let res = c.execute_withdrawal(vec![]); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) after draining dust-only queue"), + } + + assert!(matches!(c.op_state, OpState::Idle), "must remain Idle"); + assert_eq!(c.pending_withdrawals.len(), 0, "queue should be empty"); + assert_eq!( + c.next_withdraw_to_execute, + id1.saturating_add(1), + "head should advance by two" + ); + assert!(c.withdraw_route.is_empty(), "route must remain empty"); +} From 924a17f26e9c6498ff01411c3956188784cf4c40 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 11:52:33 +0000 Subject: [PATCH 30/36] refactor: idle_delta is represented as remaining_coverage --- contract/vault/src/lib.rs | 26 ++++++--- contract/vault/src/tests.rs | 108 ++++++++++++------------------------ 2 files changed, 56 insertions(+), 78 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index dbb6547b..799ddf57 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -683,6 +683,15 @@ impl Contract { } /* ----- Private Helpers ----- */ +// Internal helper value object describing idle coverage for a requested amount. +// remaining_unmet = max(amount - idle_balance, 0) +// collected_from_idle = min(idle_balance, amount) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IdleCoverage { + pub remaining_unmet: u128, + pub collected_from_idle: u128, +} + impl Contract { // Principal (vault-supplied) units currently recorded for a market fn principal_of(&self, market: &AccountId) -> u128 { @@ -996,15 +1005,15 @@ impl Contract { self.next_op_id += 1; // Policy: Idle-first reservation does not mutate idle_balance until payout succeeds. - let (remaining, collected_from_idle) = self.idle_delta(amount); + let cov = self.compute_idle_coverage(amount); self.withdraw_route = route; self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: Default::default(), - remaining, + remaining: cov.remaining_unmet, receiver: receiver.clone(), - collected: collected_from_idle, + collected: cov.collected_from_idle, owner: owner.clone(), escrow_shares, }); @@ -1122,11 +1131,14 @@ impl Contract { ) } - fn idle_delta(&mut self, amount: u128) -> (u128, u128) { + /// Computes how much of `amount` can be covered by idle balance without mutating state. + /// Returns IdleCoverage { remaining_unmet, collected_from_idle }. + fn compute_idle_coverage(&self, amount: u128) -> IdleCoverage { let used_idle = self.idle_balance.min(amount); - let remaining = amount.saturating_sub(used_idle); - let collected = used_idle; - (remaining, collected) + IdleCoverage { + remaining_unmet: amount.saturating_sub(used_idle), + collected_from_idle: used_idle, + } } } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 6858bc5d..f2f64cdc 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -86,7 +86,6 @@ fn c(vault_id: AccountId) -> Contract { new_test_contract(&vault_id) } -// Contract with the env used by after_supply_1_check_* tests #[fixture] fn c_max(vault_id: AccountId) -> Contract { setup_env( @@ -143,7 +142,6 @@ fn fee_accrues_only_on_growth_unit(c_vault_env: Contract) { .unwrap_or_else(|e| env::panic_str(&e.to_string())); c.idle_balance = 1_000; - // Set fee to 10% c.performance_fee = Wad::one() / 10; // Baseline: last_total_assets = current, so no profit => no fee @@ -175,10 +173,8 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e let receiver = mk(7); let owner = accounts(1); - // Seed escrow into vault account (shares held by vault) c.deposit_unchecked(&near_sdk::env::current_account_id(), 100) .unwrap_or_else(|e| env::panic_str(&e.to_string())); - // Seed idle to cover payout c.idle_balance = 1_000; // Partial payout scenario: collected/requested = 200/500 => burn 40% of escrowed shares @@ -190,7 +186,7 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e amount, owner: owner.clone(), escrow_shares: 100, - burn_shares: 40, // precomputed proportional burn for test + burn_shares: 40, }); c.pending_withdrawals.insert( 0, @@ -209,7 +205,6 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e // Idle decreased by payout before payout is initiated // Only burn_shares are burned from total supply assert_eq!(c.total_supply(), supply_before - 40); - // State returns to Idle assert!(matches!(c.op_state, OpState::Idle)); } @@ -219,7 +214,6 @@ fn set_supply_queue_rejects_zero_cap() { let mut c = new_test_contract(&mk(0)); setup_env(&mk(0), &accounts(1), vec![]); - // Unknown market => cap treated as 0 c.set_supply_queue(vec![mk(100)]); } @@ -270,7 +264,6 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { owner_call_env(env::current_account_id(), &owner()); c.reallocate(AllocationDelta::Supply(Delta::new(m1.clone(), total))); - // Emulate allocation completing successfully: 80 moved to market if let Some(rec) = c.markets.get_mut(&m1) { rec.principal = 80; } else { @@ -384,7 +377,6 @@ fn reallocate_withdraw_returns_promise_and_does_not_mutate() { _ => panic!("Expected Promise for withdraw reallocation"), } - // No immediate state mutations for withdraw request creation assert!( matches!(c.op_state, OpState::Idle), "reallocate withdraw should not change op_state" @@ -420,7 +412,7 @@ fn compute_effective_totals_fee_share_and_virtuals() { let cur = 1_500u128.into(); let last = 1_000u128.into(); - let perf = Wad::one() / 10; // 10% + let perf = Wad::one() / 10; let ts = 1_000u128.into(); let vs = 1u128.into(); let va = 1u128.into(); @@ -472,7 +464,6 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { }, ); - // Lower cap to zero: should NOT disable the market anymore c.submit_cap(m.clone(), U128(0)); let cfg_after = &c.markets.get(&m).expect("market must exist").cfg; assert_eq!(cfg_after.cap.0, 0, "cap must be updated to 0"); @@ -480,7 +471,6 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { set_block_ts(&vault_id, &owner, 2); - // Now we can schedule removal c.submit_market_removal(m.clone()); let cfg_after2 = c.markets.get(&m).expect("market must exist"); assert!(cfg_after2.cfg.removable_at > 0, "removal must be scheduled"); @@ -495,7 +485,6 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { let m = mk(8002); - // Start disabled with cap=0 c.markets.insert( m.clone(), MarketRecord { @@ -510,7 +499,6 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { set_ctx(&vault_id, &owner, None, Some(yocto_for_bytes(10_000))); c.submit_cap(m.clone(), U128(raise)); - // Fast-forward timelock to accept the raise set_ctx( &vault_id, &owner, @@ -523,7 +511,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { assert_eq!(cfg1.cap.0, raise); assert!(cfg1.enabled, "market should be enabled after raise"); - // Now lower back to 0 (immediate path) and ensure enabled stays true + // Now lower back to 0 and ensure enabled stays true c.submit_cap(m.clone(), U128(0)); let cfg2 = &c.markets.get(&m).unwrap().cfg; assert_eq!(cfg2.cap.0, 0); @@ -728,10 +716,11 @@ fn set_fee_recipient_accrues_before_switch_variant() { // Seed supply so fee shares can mint c.deposit_unchecked(&accounts(2), 2_000) .unwrap_or_else(|e| env::panic_str(&e.to_string())); + // Simulate profit: last=2000, current=2400 c.idle_balance = 2_400; c.last_total_assets = 2_000; - c.performance_fee = Wad::one() / 20; // 5% + c.performance_fee = Wad::one() / 20; let cur = c.get_total_assets().0; let ts_before = c.total_supply(); @@ -781,6 +770,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates() { // Seed supply so fee shares can mint c.deposit_unchecked(&accounts(1), 1_000) .unwrap_or_else(|e| env::panic_str(&e.to_string())); + // Simulate profit: last=1000, current=1500 c.idle_balance = 1_500; c.last_total_assets = 1_000; @@ -834,12 +824,13 @@ fn set_performance_fee_accrues_with_old_rate_then_updates_variant() { // Seed supply so fee shares can mint c.deposit_unchecked(&accounts(2), 2_000) .unwrap_or_else(|e| env::panic_str(&e.to_string())); + // Simulate profit: last=2000, current=2400 c.idle_balance = 2_400; c.last_total_assets = 2_000; // Old rate = 5%, new rate = 0.5% - c.performance_fee = Wad::one() / 20; // 5% + c.performance_fee = Wad::one() / 20; let cur = c.get_total_assets().0; let ts_before = c.total_supply(); let expect_old = compute_fee_shares( @@ -852,7 +843,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates_variant() { let recipient = c.fee_recipient.clone(); let bal_before = c.balance_of(&recipient); - c.set_performance_fee(Wad::one() / 200); // 0.5% + c.set_performance_fee(Wad::one() / 200); assert_eq!( c.balance_of(&recipient), @@ -884,6 +875,7 @@ fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { // Seed supply so total_supply > 0 c.deposit_unchecked(&accounts(1), 1_000) .unwrap_or_else(|e| env::panic_str(&e.to_string())); + // Loss scenario: last=1000, current=800 c.idle_balance = 800; c.last_total_assets = 1_000; @@ -1001,12 +993,10 @@ fn ft_on_transfer_supply_partial_refund_when_capped( #[test] #[should_panic = "Invalid token ID"] fn ft_on_transfer_wrong_token_full_refund_via_receiver() { - // Underlying token id != predecessor => full refund let vault_id = accounts(0); - let mut c = new_test_contract(&mk(42)); // underlying differs from predecessor + let mut c = new_test_contract(&mk(42)); setup_env(&vault_id, &vault_id, vec![]); - // Provide a market (not used due to wrong token) let m = mk(9003); let cfg = MarketConfiguration { cap: U128(100), @@ -1055,7 +1045,6 @@ fn ft_on_transfer_zero_amount_returns_zero_refund( vec![], ); - // Setup a valid market let (m, cfg) = enabled_market_100; c.markets.insert(m.clone(), cfg.into()); c.supply_queue.insert(m); @@ -1095,7 +1084,7 @@ fn mt_on_transfer_rejects_multiple_tokens() { let _ = c.mt_on_transfer( accounts(2), vec![accounts(2)], - vec!["a".to_string(), "b".to_string()], // len != 1 + vec!["a".to_string(), "b".to_string()], vec![U128(1)], serde_json::to_string(&DepositMsg::Supply).unwrap(), ); @@ -1110,7 +1099,7 @@ fn mt_on_transfer_rejects_invalid_input_lengths() { let _ = c.mt_on_transfer( accounts(3), - vec![accounts(3), accounts(4)], // len != 1 + vec![accounts(3), accounts(4)], vec!["t".to_string()], vec![U128(1)], serde_json::to_string(&DepositMsg::Supply).unwrap(), @@ -1119,7 +1108,6 @@ fn mt_on_transfer_rejects_invalid_input_lengths() { #[test] fn mt_on_transfer_wrong_asset_refunds_full() { - // With default test underlying (NEP-141), is_nep245 should fail; expect full refund let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); let old_ft_id = c.underlying_asset.contract_id().into(); @@ -1134,9 +1122,9 @@ fn mt_on_transfer_wrong_asset_refunds_full() { let res = c.mt_on_transfer( accounts(3), - vec![sender.clone()], // previous_owner_ids - vec![token_id], // token_ids - vec![U128(amount)], // amounts + vec![sender.clone()], + vec![token_id], + vec![U128(amount)], serde_json::to_string(&DepositMsg::Supply).unwrap(), ); match res { @@ -1169,7 +1157,6 @@ fn governance_set_curator_grants_allocator() { let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - // Prepare a market to exercise allocator permission let m1 = mk(9101); let cfg = MarketConfiguration { cap: U128(1), @@ -1181,7 +1168,6 @@ fn governance_set_curator_grants_allocator() { let new_cur = accounts(3); c.set_curator(new_cur.clone()); - // New curator can set supply queue set_ctx( &vault_id, &new_cur, @@ -1202,7 +1188,6 @@ fn governance_set_is_allocator_grant_allows_queue_ops() { let grantee = accounts(4); - // Market to operate on let m1 = mk(9102); let cfg = MarketConfiguration { cap: U128(1), @@ -1211,10 +1196,8 @@ fn governance_set_is_allocator_grant_allows_queue_ops() { }; c.markets.insert(m1.clone(), cfg.into()); - // Grant Allocator role c.set_is_allocator(grantee.clone(), true); - // Grantee can set supply queue set_ctx( &vault_id, &grantee, @@ -1237,7 +1220,6 @@ fn governance_set_is_allocator_revoke_disallows_queue_ops() { let grantee = accounts(12); c.set_is_allocator(grantee.clone(), true); - // Market to attempt on let m1 = mk(9103); let cfg = MarketConfiguration { cap: U128(1), @@ -1270,7 +1252,6 @@ fn governance_accept_guardian_not_yet_panics() { let new_g = accounts(5); c.submit_guardian(new_g); - // Timelock not advanced -> should panic c.accept_guardian(); } @@ -1284,7 +1265,6 @@ fn governance_submit_accept_and_revoke_guardian() { let new_g = accounts(4); c.submit_guardian(new_g.clone()); - // Advance time beyond timelock and accept set_ctx( &vault_id, &owner, @@ -1299,7 +1279,6 @@ fn governance_submit_accept_and_revoke_guardian() { c.submit_guardian(another); c.revoke_pending_guardian(); - // No pending now; accept should no-op (but must not panic) c.accept_guardian(); } @@ -1344,7 +1323,6 @@ fn governance_accept_timelock_without_pending_panics() { let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - // No pending change -> accept should panic c.accept_timelock(); } @@ -1358,11 +1336,9 @@ fn governance_revoke_pending_timelock_then_accept_panics() { let cur = c.get_configuration().initial_timelock_ns; - // Force a pending by first increasing then decreasing c.submit_timelock((cur.0 + 1).into()); c.submit_timelock(cur); - // Revoke the pending change; accept must now panic c.revoke_pending_timelock(); c.accept_timelock(); } @@ -1396,11 +1372,9 @@ fn governance_submit_and_accept_cap_new_market_creates_and_enables() { let m = mk(9105); - // Submit raise for a brand-new market set_ctx(&vault_id, &owner, None, Some(yocto_for_bytes(20_000))); c.submit_cap(m.clone(), U128(5)); - // Advance timelock and accept; attach storage for withdraw queue addition set_ctx( &vault_id, &owner, @@ -1427,11 +1401,9 @@ fn governance_revoke_pending_cap_then_accept_panics() { let m = mk(9106); - // Create pending cap raise for a new market set_ctx(&vault_id, &owner, None, Some(yocto_for_bytes(20_000))); c.submit_cap(m.clone(), U128(7)); - // Revoke, then accepting should panic set_ctx(&vault_id, &owner, None, None); c.revoke_pending_cap(m.clone()); c.accept_cap(m); @@ -1453,12 +1425,10 @@ fn governance_submit_and_revoke_market_removal() { }; c.markets.insert(m.clone(), cfg.into()); - // Submit removal (schedules timelock) c.submit_market_removal(m.clone()); let after = c.markets.get(&m).unwrap(); assert!(after.cfg.removable_at > 0, "removal must be scheduled"); - // Revoke pending removal c.revoke_pending_market_removal(m.clone()); let after2 = c.markets.get(&m).unwrap(); assert_eq!(after2.cfg.removable_at, 0, "removal must be revoked"); @@ -1484,7 +1454,6 @@ fn governance_set_fee_recipient_no_fee_does_not_accrue() { owner_call_env(vault_id, &owner); - // Seed supply and simulate profit, but fee = 0 c.deposit_unchecked(&owner, 1_000) .unwrap_or_else(|e| env::panic_str(&e.to_string())); c.idle_balance = 1_500; @@ -1533,11 +1502,9 @@ fn skim_rejects_underlying_token() { let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - // Set a skim recipient let recipient = accounts(4); c.set_skim_recipient(recipient.clone()); - // Attempt to skim the underlying token -> must panic let underlying: AccountId = c.underlying_asset.contract_id().into(); let _ = c.skim(underlying); } @@ -1550,11 +1517,9 @@ fn skim_rejects_share_token() { let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - // Set a skim recipient let recipient = accounts(4); c.set_skim_recipient(recipient.clone()); - // Attempt to skim the share token (the vault itself) -> must panic let share_token: AccountId = vault_id.clone(); let _ = c.skim(share_token); } @@ -1657,7 +1622,6 @@ fn after_supply_1_check_allocating() { #[rstest] fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { - // Prepare a single-market withdraw queue with non-zero principal let market = mk(8); c.withdraw_route = vec![market.clone()]; let principal = 100; @@ -1689,10 +1653,10 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { } let res2 = c.execute_withdraw_03_settle( - Ok(U128(principal)), // observed after_balance + Ok(U128(principal)), op_id, index, - U128(principal), // before_principal + U128(principal), U128(0), U128(0), ); @@ -1709,7 +1673,6 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { ); // Collected was 70, payouit is 70, idle is 30 - assert_eq!( c.idle_balance, 30, "Idle balance should increase by returned amount" @@ -1745,17 +1708,14 @@ fn after_skim_balance_positive_returns_promise() { let mut c = new_test_contract(&vault_id); - // Positive balance -> Promise to ft_transfer let res = c.skim_01_read_balance(Ok(U128(123)), mk(10), mk(11)); match res { PromiseOrValue::Promise(_) => { //NOTE: one day we will be able to read the promise - //definition :< } _ => panic!("Skim with positive balance must return a Promise"), } } -/// Property: Exec-withdraw read failure assumes unchanged principal and does not credit idle #[rstest( before => [0u128, 1u128, 100u128], need => [0u128, 1u128, 50u128], @@ -1791,7 +1751,6 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect escrow_shares: 0, }); - // Stub a withdrawal c.pending_withdrawals.insert( 0, PendingWithdrawal { @@ -1913,12 +1872,10 @@ fn refund_path_consistency() { }, ); c.withdraw_route = vec![market.clone()]; - // Seed escrowed shares into the vault's own account let owner = accounts(1); c.deposit_unchecked(&near_sdk::env::current_account_id(), 10) .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); - // Withdrawing state with remaining=0 and collected=0 forces refund path let op_id = 77; let index = 0; c.op_state = OpState::Withdrawing(WithdrawingState { @@ -2562,31 +2519,40 @@ fn cancel_in_flight_withdrawal_noop_when_not_withdrawing() { case(0u128, 0u128, 0u128, 0u128), case(100u128, 0u128, 0u128, 0u128), case(0u128, 50u128, 50u128, 0u128), - case(100u128, 50u128, 50u128, 50u128), + case(100u128, 50u128, 0u128, 50u128), case(100u128, 100u128, 0u128, 100u128), case(100u128, 150u128, 50u128, 100u128), case(u128::MAX, 1u128, 0u128, 1u128), case(1u128, u128::MAX, u128::MAX - 1u128, 1u128), )] -fn idle_delta_cases(mut c: Contract, idle: u128, amount: u128, remaining: u128, collected: u128) { - // Arrange +fn compute_idle_coverage_cases( + mut c: Contract, + idle: u128, + amount: u128, + remaining: u128, + collected: u128, +) { c.idle_balance = idle; let idle_before = c.idle_balance; - // Act - let (rem, coll) = c.idle_delta(amount); + let cov = c.compute_idle_coverage(amount); - // Assert - assert_eq!(rem, remaining, "remaining should match expected"); - assert_eq!(coll, collected, "collected should match expected"); assert_eq!( - rem.saturating_add(coll), + cov.remaining_unmet, remaining, + "remaining should match expected" + ); + assert_eq!( + cov.collected_from_idle, collected, + "collected should match expected" + ); + assert_eq!( + cov.remaining_unmet.saturating_add(cov.collected_from_idle), amount, "invariant: remaining + collected == amount" ); assert_eq!( c.idle_balance, idle_before, - "idle_delta must not mutate idle_balance" + "compute_idle_coverage must not mutate idle_balance" ); } From 1ede5e4d274056f3762a73384c5e422a8fc12eea Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 12:43:24 +0000 Subject: [PATCH 31/36] refactor: remove harvest --- common/src/vault.rs | 7 ++++--- contract/vault/src/lib.rs | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 522701ee..0b7a0b4c 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -401,7 +401,6 @@ impl Delta { pub enum AllocationDelta { Supply(Delta), Withdraw(Delta), - Harvest(Delta), } impl AsRef for AllocationDelta { @@ -409,7 +408,6 @@ impl AsRef for AllocationDelta { match self { AllocationDelta::Supply(d) => d, AllocationDelta::Withdraw(d) => d, - AllocationDelta::Harvest(d) => d, } } } @@ -659,7 +657,10 @@ pub enum Event { #[event_version("1.0.0")] WithdrawQueueUpdate { action: QueueAction, id: U64 }, #[event_version("1.0.0")] - WithdrawQueueStatus { status: QueueStatus, id: Option }, + WithdrawQueueStatus { + status: QueueStatus, + id: Option, + }, // User flows #[event_version("1.0.0")] diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 799ddf57..e40bc6d5 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -36,8 +36,8 @@ use templar_common::{ IdleBalanceDelta, Locker, MarketConfiguration, OpState, PayoutState, PendingValue, PendingWithdrawal, QueueAction, QueueStatus, Reason, TimestampNs, VaultConfiguration, WithdrawingState, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, - CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, - MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, + CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + WITHDRAW_GAS, }, }; pub use wad::*; @@ -482,7 +482,6 @@ impl Contract { ))), ) } - AllocationDelta::Harvest(delta) => todo!("Implement Harvest"), } } From ad6da2d1cff0c0d0f1b4695c5424ce4d14e3acd0 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 12:44:25 +0000 Subject: [PATCH 32/36] test: use proper fixtures --- contract/vault/src/tests.rs | 164 ++++++++++++++---------------------- 1 file changed, 61 insertions(+), 103 deletions(-) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index f2f64cdc..7ca40306 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -36,31 +36,31 @@ use templar_common::vault::PendingWithdrawal; use templar_common::vault::WithdrawingState; #[fixture] -fn vault_id_fixture() -> AccountId { +fn vault_id() -> AccountId { accounts(0) } #[fixture] -fn c_vault_env(vault_id_fixture: AccountId) -> Contract { - setup_env(&vault_id_fixture, &vault_id_fixture, vec![]); - new_test_contract(&vault_id_fixture) +fn c_vault_env(#[default(vault_id())] vault_id: AccountId) -> Contract { + setup_env(&vault_id, &vault_id, vec![]); + new_test_contract(&vault_id) } #[fixture] -fn c_owner_env(vault_id_fixture: AccountId) -> Contract { - let c = new_test_contract(&vault_id_fixture); +fn c_owner_env(#[default(vault_id())] vault_id: AccountId) -> Contract { + let c = new_test_contract(&vault_id); let owner = c .own_get_owner() .unwrap_or_else(|| env::panic_str("Owner not set")); - setup_env(&vault_id_fixture, &owner, vec![]); + setup_env(&vault_id, &owner, vec![]); c } #[fixture] -fn c_asset_env(vault_id_fixture: AccountId) -> Contract { - let c = new_test_contract(&vault_id_fixture); +fn c_asset_env(#[default(vault_id())] vault_id: AccountId) -> Contract { + let c = new_test_contract(&vault_id); let asset: AccountId = c.underlying_asset.contract_id().into(); - setup_env(&vault_id_fixture, &asset, vec![]); + setup_env(&vault_id, &asset, vec![]); c } @@ -75,15 +75,33 @@ fn enabled_market_100() -> (AccountId, MarketConfiguration) { (m, cfg) } -#[fixture] -fn vault_id() -> AccountId { - accounts(0) -} +type MarketFixture = (AccountId, u128, bool, u128, bool); #[fixture] -fn c(vault_id: AccountId) -> Contract { +fn c(vault_id: AccountId, #[default(Vec::new())] markets: Vec) -> Contract { setup_env(&vault_id, &vault_id, vec![]); - new_test_contract(&vault_id) + let mut c = new_test_contract(&vault_id); + + println!("Markets to do {:?}", markets); + for (market_id, cap, enabled, principal, in_supply_queue) in markets { + c.markets.insert( + market_id.clone(), + MarketRecord { + cfg: MarketConfiguration { + cap: U128(cap), + enabled, + removable_at: 0, + }, + pending_cap: None, + principal, + }, + ); + if in_supply_queue { + c.supply_queue.insert(market_id.clone()); + } + } + + c } #[fixture] @@ -235,26 +253,9 @@ fn execute_supply_wrong_token_refunds_full(c_vault_env: Contract) { } #[rstest] -fn start_allocation_reserves_only_amount(c_vault_env: Contract) { - let mut c = c_vault_env; - - // Configure a single market with cap = 80 in the supply queue - let m1 = mk(2000); - let cfg = MarketConfiguration { - cap: U128(80), - enabled: true, - removable_at: 0, - }; - c.markets.insert( - m1.clone(), - MarketRecord { - cfg, - pending_cap: None, - principal: 0, - }, - ); - c.supply_queue.insert(m1.clone()); - +fn start_allocation_reserves_only_amount( + #[with(vault_id(), vec![(mk(2000), 80, true, 0, true)])] mut c: Contract, +) { // Idle = 100, so max_room (80) should clamp allocation c.idle_balance = 100; assert_eq!(c.get_max_deposit().0, 80, "sanity: max room must be 80"); @@ -262,6 +263,7 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { // Reserve only the amount to allocate (intended behavior) let total = c.get_max_deposit().0.min(c.idle_balance); owner_call_env(env::current_account_id(), &owner()); + let m1 = mk(2000); c.reallocate(AllocationDelta::Supply(Delta::new(m1.clone(), total))); if let Some(rec) = c.markets.get_mut(&m1) { @@ -1621,18 +1623,12 @@ fn after_supply_1_check_allocating() { } #[rstest] -fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { - let market = mk(8); +fn after_exec_withdraw_read_none_to_payout( + #[with(vault_id(), vec![(mk(8), 0, true, 100, false)])] mut c: Contract, +) { + let (market, _record) = c.markets.clone().into_iter().next().unwrap(); + let principal = 100u128; c.withdraw_route = vec![market.clone()]; - let principal = 100; - c.markets.insert( - market.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal, - }, - ); let op_id = 42; let index = 0; @@ -1667,7 +1663,7 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { } assert_eq!( - c.markets.get(&market).map_or(u128::MAX, |r| r.principal), + c.markets.get(&market).map(|r| r.principal).unwrap(), 0, "Market principal should be updated to 0" ); @@ -1855,22 +1851,11 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde } } -#[test] -fn refund_path_consistency() { +#[rstest] +fn refund_path_consistency(#[with(vault_id(), vec![(mk(8), 0, true, 10, false)])] mut c: Contract) { use near_sdk_contract_tools::ft::Nep141Controller as _; - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); let market = mk(8); - c.markets.insert( - market.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 10, - }, - ); c.withdraw_route = vec![market.clone()]; let owner = accounts(1); c.deposit_unchecked(&near_sdk::env::current_account_id(), 10) @@ -2083,20 +2068,12 @@ fn after_supply_2_read_read_failed_stops() { #[rstest] fn after_create_withdraw_req_success_returns_promise( - mut c: Contract, + #[with(vault_id(), vec![(mk(50), 0, true, 100, false)])] mut c: Contract, receiver: AccountId, owner: AccountId, ) { let market = mk(50); c.withdraw_route = vec![market.clone()]; - c.markets.insert( - market.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 100, - }, - ); c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 21, @@ -2118,17 +2095,11 @@ fn after_create_withdraw_req_success_returns_promise( } #[rstest] -fn after_exec_withdraw_req_returns_promise(mut c: Contract) { +fn after_exec_withdraw_req_returns_promise( + #[with(vault_id(), vec![(mk(60), 0, true, 10, false)])] mut c: Contract, +) { let market = mk(60); c.withdraw_route = vec![market.clone()]; - c.markets.insert( - market.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 10, - }, - ); let op_id = 33; c.op_state = OpState::Withdrawing(WithdrawingState { @@ -2154,20 +2125,15 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { #[rstest] fn after_exec_withdraw_read_instant_payout_when_remaining_0( + #[with(vault_id(), vec![(mk(70), 0, true, 10, false), (mk(71), 0, true, 0, false)])] mut c: Contract, owner: AccountId, receiver: AccountId, ) { let m1 = mk(70); - let record = MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 10, - }; - c.markets.insert(m1.clone(), record.clone()); - let m2 = mk(71); c.withdraw_route = vec![m1.clone(), m2.clone()]; + let record_principal = 10u128; let op_id = 0; let index = 0; @@ -2199,11 +2165,11 @@ fn after_exec_withdraw_read_instant_payout_when_remaining_0( // before = 0 // after = 10 // We now queue up for execution - let res2 = c.execute_withdraw_03_settle( - Ok(U128(record.principal)), // after_balance + c.execute_withdraw_03_settle( + Ok(U128(record_principal)), // after_balance op_id, index, - U128(record.principal), // before_principal + U128(record_principal), // before_principal U128(0), U128(before_balance), ); @@ -2218,7 +2184,7 @@ fn after_exec_withdraw_read_instant_payout_when_remaining_0( burn_shares, }) => { assert_eq!(*op_id, 0); - assert_eq!(*amount, before_balance + record.principal); + assert_eq!(*amount, before_balance + record_principal); assert_eq!(*escrow_shares, 0); assert_eq!(*burn_shares, 0); assert_eq!(*r, receiver); @@ -2678,23 +2644,15 @@ fn execute_withdrawal_empty_queue_noop() { assert!(c.withdraw_route.is_empty(), "route must remain empty"); } -#[test] -fn execute_withdrawal_skips_dust_and_starts_withdraw() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); +#[rstest] +fn execute_withdrawal_skips_dust_and_starts_withdraw( + #[with(vault_id(), vec![(mk(1234), 0, true, 50, false)])] mut c: Contract, +) { let owner_id = c.own_get_owner().unwrap(); - setup_env(&vault_id, &owner_id, vec![]); + setup_env(&near_sdk::env::current_account_id(), &owner_id, vec![]); // Prepare a withdraw market so a non-empty route makes sense let m1 = mk(1234); - c.markets.insert( - m1.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 50, - }, - ); // Enqueue a dust head (expected_assets = 0) let head_before = c.queue_tail(); From 03afb3b434111e4f1d78a11037d30701403f711b Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 14 Nov 2025 12:49:26 +0000 Subject: [PATCH 33/36] chore: lints --- common/src/vault.rs | 12 +++--------- contract/vault/src/lib.rs | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 0b7a0b4c..3c4eccbe 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -390,7 +390,7 @@ impl Delta { } } pub fn validate(&self) { - require!(self.amount.0 > 0, "Delta amount must be greater than zero") + require!(self.amount.0 > 0, "Delta amount must be greater than zero"); } } @@ -406,8 +406,7 @@ pub enum AllocationDelta { impl AsRef for AllocationDelta { fn as_ref(&self) -> &Delta { match self { - AllocationDelta::Supply(d) => d, - AllocationDelta::Withdraw(d) => d, + AllocationDelta::Supply(d) | AllocationDelta::Withdraw(d) => d, } } } @@ -771,18 +770,13 @@ pub enum Event { VaultBalance { amount: U128 }, } +#[derive(Default)] #[near(serializers = [borsh, serde])] pub struct Locker { to_lock: Vec, } impl Locker { - pub fn new() -> Self { - Locker { - to_lock: Vec::new(), - } - } - pub fn lock(&mut self, i: u32) { if self.is_locked(i) { env::panic_str("Market is locked for index"); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index e40bc6d5..21dbb323 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -210,7 +210,7 @@ impl Contract { .concat(), ), next_withdraw_to_execute: 0, - market_execution_lock: Locker::new(), + market_execution_lock: Locker::default(), withdraw_route: Vec::new(), }; From fd7a758f1271996a0cfcbbed65b9a8137efc2b6d Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 17 Nov 2025 09:48:13 +0000 Subject: [PATCH 34/36] fix: lints --- contract/vault/src/impl_callbacks.rs | 3 +++ contract/vault/src/lib.rs | 13 ++++++------- test-utils/src/controller/vault.rs | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index b6d54683..9838b58d 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -666,6 +666,9 @@ impl Contract { } /// Validate current op is Withdrawing and return context tuple + /// + /// # Errors + /// Returns an error if the operation is not currently withdrawing. pub fn ctx_withdrawing(&self, op_id: u64) -> Result<&WithdrawingState, Error> { match &self.op_state { OpState::Withdrawing(s) if s.op_id == op_id => Ok(s), diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 21dbb323..02dd2982 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -326,13 +326,12 @@ impl Contract { pending.escrow_shares, route, ); - } else { - Event::WithdrawQueueStatus { - status: QueueStatus::Empty, - id: None, - } - .emit(); } + Event::WithdrawQueueStatus { + status: QueueStatus::Empty, + id: None, + } + .emit(); PromiseOrValue::Value(()) } @@ -1131,7 +1130,7 @@ impl Contract { } /// Computes how much of `amount` can be covered by idle balance without mutating state. - /// Returns IdleCoverage { remaining_unmet, collected_from_idle }. + /// Returns `IdleCoverage`. fn compute_idle_coverage(&self, amount: u128) -> IdleCoverage { let used_idle = self.idle_balance.min(amount); IdleCoverage { diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 82306023..7499bef9 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -12,7 +12,7 @@ use near_workspaces::{ network::Sandbox, result::ExecutionSuccess, types::SecretKey, Account, Contract, Worker, }; use std::{env, ops::Deref}; -use templar_common::vault::{AllocationDelta, AllocationWeights, DepositMsg, VaultConfiguration}; +use templar_common::vault::{AllocationDelta, DepositMsg, VaultConfiguration}; use tokio::sync::OnceCell; #[derive(Clone)] From d47442b8d76dd59a97ef83df6fe338a0f6a9a865 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 17 Nov 2025 10:23:21 +0000 Subject: [PATCH 35/36] ci: unnecessary timeout of tests --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98c82b71..9436366b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ on: workflow_call: inputs: skip_coverage: - description: 'Skip coverage job' + description: "Skip coverage job" required: false default: false type: boolean @@ -18,7 +18,7 @@ concurrency: permissions: contents: read - id-token: write # Required for OIDC with Codecov + id-token: write # Required for OIDC with Codecov jobs: code-formatting: @@ -46,6 +46,7 @@ jobs: tests: name: Tests + timeout-minutes: 40 runs-on: ubuntu-latest steps: - name: Checkout repository From c5508d188ea6d9756241f8524e35b3f3b0b1db19 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 18 Nov 2025 09:47:24 +0000 Subject: [PATCH 36/36] ci: try timeout on imprt --- .github/workflows/deploy-staging.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index d53e87ee..c9682abd 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -36,6 +36,7 @@ jobs: if: needs.check-changes.outputs.should-run-contracts == 'true' uses: ./.github/workflows/test.yml with: + timeout-minutes: 40 skip_coverage: true gas-report: