From 6e4bb5e5cdde4483a8227a0ba2cbd7248e063312 Mon Sep 17 00:00:00 2001 From: Donovan Dall Date: Wed, 1 Oct 2025 16:06:30 +0100 Subject: [PATCH 001/121] feat: initial vault contract --- Cargo.lock | 77 +- Cargo.toml | 1 + common/src/asset.rs | 31 + common/src/lib.rs | 3 + common/src/vault.rs | 61 ++ contract/vault/Cargo.toml | 50 + contract/vault/examples/receipt_gas.rs | 50 + contract/vault/src/aux.rs | 52 + contract/vault/src/impl_callbacks.rs | 518 ++++++++++ contract/vault/src/impl_token_receiver.rs | 125 +++ contract/vault/src/lib.rs | 1077 +++++++++++++++++++++ contract/vault/src/wad.rs | 98 ++ contract/vault/tests/conversions.rs | 126 +++ contract/vault/tests/invariants.rs | 34 + 14 files changed, 2273 insertions(+), 30 deletions(-) create mode 100644 common/src/vault.rs create mode 100644 contract/vault/Cargo.toml create mode 100644 contract/vault/examples/receipt_gas.rs create mode 100644 contract/vault/src/aux.rs create mode 100644 contract/vault/src/impl_callbacks.rs create mode 100644 contract/vault/src/impl_token_receiver.rs create mode 100644 contract/vault/src/lib.rs create mode 100644 contract/vault/src/wad.rs create mode 100644 contract/vault/tests/conversions.rs create mode 100644 contract/vault/tests/invariants.rs diff --git a/Cargo.lock b/Cargo.lock index fab70481..e4dbec65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1489,19 +1489,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if 1.0.0", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if 1.0.0", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", ] [[package]] @@ -2036,6 +2036,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -2110,9 +2119,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libm" @@ -2149,9 +2158,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -2254,7 +2263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.52.0", ] @@ -2530,7 +2539,7 @@ dependencies = [ "easy-ext", "enum-map", "hex", - "itertools", + "itertools 0.12.1", "near-crypto", "near-fmt", "near-parameters", @@ -2731,7 +2740,7 @@ dependencies = [ "rand", "rayon", "ripemd", - "rustix 1.0.8", + "rustix 1.1.2", "serde", "sha2", "sha3", @@ -3629,14 +3638,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags 2.6.0", "errno", "libc", - "linux-raw-sys 0.9.4", + "linux-raw-sys 0.11.0", "windows-sys 0.52.0", ] @@ -3704,9 +3713,9 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" dependencies = [ "dyn-clone", "schemars_derive", @@ -3716,9 +3725,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" dependencies = [ "proc-macro2", "quote", @@ -4417,9 +4426,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", - "rustix 1.0.8", + "rustix 1.1.2", "windows-sys 0.52.0", ] @@ -4566,6 +4575,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "templar-vault-contract" +version = "1.1.0" +dependencies = [ + "getrandom 0.2.15", + "itertools 0.14.0", + "near-contract-standards", + "near-sdk", + "near-sdk-contract-tools", + "near-workspaces", + "rstest", + "templar-common", + "templar-relayer", + "test-utils", + "tokio", +] + [[package]] name = "test-utils" version = "0.1.0" @@ -5084,7 +5110,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -5120,15 +5146,6 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 5af85adb..f88ab02f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ futures = "0.3.31" getrandom = { version = "0.2", features = ["custom"] } hex = { version = "0.4.3", features = ["serde"] } hex-literal = "0.4" +itertools = "0.14.0" near-contract-standards = "5.17.2" near-crypto = "0.31.1" near-jsonrpc-client = "0.18.0" diff --git a/common/src/asset.rs b/common/src/asset.rs index e3c72727..d6c70453 100644 --- a/common/src/asset.rs +++ b/common/src/asset.rs @@ -94,6 +94,37 @@ impl FungibleAsset { } } + #[allow(clippy::missing_panics_doc, clippy::unwrap_used)] + pub fn transfer_call( + &self, + receiver_id: &AccountId, + amount: FungibleAssetAmount, + msg: Option<&str>, + ) -> Promise { + let msg = msg.unwrap_or_default().to_string(); + match self.kind { + FungibleAssetKind::Nep141(ref contract_id) => ext_ft_core::ext(contract_id.clone()) + .with_static_gas(Self::GAS_FT_TRANSFER) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .ft_transfer_call(receiver_id.clone(), u128::from(amount).into(), None, msg), + FungibleAssetKind::Nep245 { + ref contract_id, + ref token_id, + } => Promise::new(contract_id.clone()).function_call( + "mt_transfer_call".into(), + serde_json::to_vec(&json!({ + "receiver_id": receiver_id, + "token_id": token_id, + "amount": amount, + "msg": msg, + })) + .unwrap(), + NearToken::from_yoctonear(1), + Self::GAS_MT_TRANSFER, + ), + } + } + #[cfg(not(target_arch = "wasm32"))] pub fn transfer_call_action( &self, diff --git a/common/src/lib.rs b/common/src/lib.rs index 37b0ddd9..0d5b2781 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -14,8 +14,11 @@ pub mod registry; pub mod snapshot; pub mod supply; pub mod time_chunk; +pub mod vault; pub mod withdrawal_queue; +pub use primitive_types; + /// Approximation of `1 / (1000 * 60 * 60 * 24 * 365.2425)`. /// /// exact = 0.00000000003168873850681143096456210346297... diff --git a/common/src/vault.rs b/common/src/vault.rs new file mode 100644 index 00000000..c0d8fe8a --- /dev/null +++ b/common/src/vault.rs @@ -0,0 +1,61 @@ +use near_sdk::{json_types::U128, near, AccountId, Gas}; + +use crate::supply::SupplyPosition; + +pub type TimestampNs = u64; + +// FIXME: +pub const GAS_XFER: Gas = Gas::from_tgas(4); +pub const GAS_CB: Gas = Gas::from_tgas(30); +pub const ONE_YOCTO: u128 = 1; + +pub const MIN_TIMELOCK_NS: u64 = 86_400_000_000_000; // 1 day +pub const MAX_TIMELOCK_NS: u64 = 30 * 86_400_000_000_000; // 30 days +pub const MAX_QUEUE_LEN: usize = 64; + +/// Parsed from the string parameter `msg` passed by `*_transfer_call` to +/// `*_on_transfer` calls. +#[near(serializers = [json])] +pub enum DepositMsg { + /// Add the attached tokens to the sender's vault position. + Supply, +} + +#[derive(Clone, Default)] +#[near] +pub struct MarketConfiguration { + // Supply cap for this market (in underlying asset units) + pub cap: u128, + // Whether market is enabled for deposits/withdrawals + pub enabled: bool, + // Timestamp (ns) after which market can be removed (if pending removal) + pub removable_at: TimestampNs, +} + +#[near_sdk::ext_contract(ext_self)] +pub trait Callbacks { + fn after_supply_1_check(&mut self, op_id: u64, market_index: u32, attempted: U128) -> bool; + fn after_supply_2_read( + &mut self, + op_id: u64, + market_index: u32, + before: U128, + attempted: U128, + refunded: U128, + ) -> bool; + + fn after_create_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; + fn after_exec_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; + + fn after_send_to_user(&mut self, op_id: u64, receiver: AccountId, amount: U128) -> bool; + + fn after_skim_balance(&mut self, token: AccountId, recipient: AccountId) -> bool; +} + +#[derive(Clone)] +#[near] +pub struct PendingValue { + pub value: T, + // Timestamp when this pending value can be finalized + pub valid_at: TimestampNs, +} diff --git a/contract/vault/Cargo.toml b/contract/vault/Cargo.toml new file mode 100644 index 00000000..c3b377df --- /dev/null +++ b/contract/vault/Cargo.toml @@ -0,0 +1,50 @@ +[package] +edition.workspace = true +license.workspace = true +name = "templar-vault-contract" +repository.workspace = true +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +# fields to configure build with WASM reproducibility, according to specs +# in https://github.com/near/NEPs/blob/master/neps/nep-0330.md +[package.metadata.near.reproducible_build] +# docker image, descriptor of build environment +image = "sourcescan/cargo-near:0.13.4-rust-1.85.0" +# tag after colon above serves only descriptive purpose; image is identified by digest +image_digest = "sha256:a9d8bee7b134856cc8baa142494a177f2ba9ecfededfcdd38f634e14cca8aae2" +# build command inside of docker container +# if docker image from default gallery is used https://hub.docker.com/r/sourcescan/cargo-near/tags, +# the command may be any combination of flags of `cargo-near`, +# supported by respective version of binary inside the container besides `--no-locked` flag +container_build_command = [ + "cargo", + "near", + "build", + "non-reproducible-wasm", + "--locked", +] + +[dependencies] +getrandom.workspace = true +near-sdk.workspace = true +near-sdk-contract-tools.workspace = true +near-contract-standards.workspace = true +templar-common.workspace = true +itertools.workspace = true + +[dev-dependencies] +near-sdk = { workspace = true, features = ["unit-testing"] } +near-workspaces.workspace = true +rstest.workspace = true +test-utils.workspace = true +tokio.workspace = true +templar-relayer = { path = "../../service/relayer" } + +[lints] +workspace = true + +# [[example]] +# name = "receipt_gas" diff --git a/contract/vault/examples/receipt_gas.rs b/contract/vault/examples/receipt_gas.rs new file mode 100644 index 00000000..fe677a60 --- /dev/null +++ b/contract/vault/examples/receipt_gas.rs @@ -0,0 +1,50 @@ +#![allow(clippy::wildcard_imports)] + +use templar_common::fee::Fee; +use test_utils::*; + +#[tokio::main] +async fn main() { + setup_test!( + extract(c, insurance_yield_user) + accounts(borrow_user, supply_user, liquidator_user) + config(|c| { + c.borrow_origination_fee = Fee::zero(); + }) + ); + + tokio::join!( + c.supply_and_harvest_until_activation(&supply_user, 20_000), + c.collateralize(&borrow_user, 13_000), + ); + + c.borrow(&borrow_user, 10_000).await; + + // c.repay(&borrow_user, 10_000).await; + + // c.set_collateral_asset_price(0.85).await; + c.liquidate( + &liquidator_user, + borrow_user.id(), + 13_000.into(), + 11_000.into(), + ) + .await; + + // c.liquidate(&liquidator_user, borrow_user.id(), 11_000) + // .await; + + // let r = c + // .withdraw_static_yield(&insurance_yield_user, None, None) + // .await; + + c.create_supply_withdrawal_request(&supply_user, 1_000) + .await; + let r = c.execute_next_supply_withdrawal_request(&supply_user).await; + + for receipt in r.receipt_outcomes() { + eprintln!("{}: {}", receipt.executor_id, receipt.gas_burnt); + } + + eprintln!("Total gas: {}", r.total_gas_burnt); +} diff --git a/contract/vault/src/aux.rs b/contract/vault/src/aux.rs new file mode 100644 index 00000000..b0e77b92 --- /dev/null +++ b/contract/vault/src/aux.rs @@ -0,0 +1,52 @@ +use crate::{AccountId, Contract, Nep145Controller, Nep145ForceUnregister, env, near, serde_json}; + +impl Contract { + /* ----- Storage ----- */ + fn charge_for_storage(&mut self, account_id: &AccountId, storage_consumption: u64) { + // Invariant: Storage charging saturates and panics on failure to avoid negative balances. + self.lock_storage( + account_id, + env::storage_byte_cost().saturating_mul(u128::from(storage_consumption)), + ) + .unwrap_or_else(|e| env::panic_str(&format!("Storage error: {e}"))); + } + + fn refund_for_storage(&mut self, account_id: &AccountId, storage_consumption: u64) { + // Invariant: Storage refunds saturate and panic on failure to preserve accounting integrity. + self.unlock_storage( + account_id, + env::storage_byte_cost().saturating_mul(u128::from(storage_consumption)), + ) + .unwrap_or_else(|e| env::panic_str(&format!("Storage error: {e}"))); + } +} + +#[derive(Clone, Debug)] +#[near(serializers = [json])] +/// Indicates the JSON return payload shape expected by token receiver callbacks. +pub enum ReturnStyle { + /// Return payload shape for NEP-141 `ft_transfer_call` (a bare amount). + Nep141FtTransferCall, + /// Return payload shape for NEP-245 `mt_transfer_call` (a single-element array). + Nep245MtTransferCall, +} + +// TODO: use this +impl ReturnStyle { + pub fn serialize( + &self, + amount: templar_common::asset::FungibleAssetAmount, + ) -> serde_json::Value { + match self { + Self::Nep141FtTransferCall => serde_json::json!(amount), + Self::Nep245MtTransferCall => serde_json::json!([amount]), + } + } +} + +impl near_sdk_contract_tools::hook::Hook> for Contract { + fn hook(_: &mut Self, _: &Nep145ForceUnregister, _: impl FnOnce(&mut Self) -> R) -> R { + // Invariant: Force unregister must fail to preserve FT ledger integrity. + env::panic_str("force unregistration is not supported") + } +} diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs new file mode 100644 index 00000000..51e9b90d --- /dev/null +++ b/contract/vault/src/impl_callbacks.rs @@ -0,0 +1,518 @@ +use std::fmt::Display; + +use crate::{Contract, ContractExt, Error, GAS_CB, GAS_XFER, Nep141Controller, OpState, ext_self, near}; +use near_contract_standards::fungible_token::core::ext_ft_core; +use near_sdk::{ + env, json_types::U128, serde_json, AccountId, Gas, NearToken, Promise, PromiseError, + PromiseOrValue, +}; +use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; +use templar_common::{market::ext_market, supply::SupplyPosition}; + +#[near] +impl Contract { + const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(20); + const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(20); + + #[private] + pub fn after_supply_1_check( + &mut self, + #[callback_result] supply_refund: Result, + op_id: u64, + market_index: u32, + attempted: U128, + ) -> PromiseOrValue<()> { + // Invariant: Index drift or stale op_id results in a graceful stop + match &self.op_state { + OpState::Allocating { op_id: cur, .. } if *cur == op_id => {} + _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), + } + + let Some(market) = self.supply_queue.get(market_index) else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + }; + + // If the transfer failed, do not attempt to reconcile; stop and leave remaining untouched + if supply_refund.is_err() { + env::log_str(&format!( + "after_supply_1_check: transfer failed; stopping (op_id={}, market={}, index={}, attempted={})", + op_id, market, market_index, attempted.0 + )); + return self.stop_and_exit(Some(&Error::MarketTransferFailed)); + } + + let before = self.market_supply.get(market).unwrap_or(&0); + + let fetch_pos = ext_market::ext(market.clone()) + .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) + .get_supply_position(env::current_account_id()); + + PromiseOrValue::Promise( + fetch_pos.then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_supply_2_read( + op_id, + market_index, + U128(*before), + attempted, + supply_refund.unwrap_or(U128(0)), + ), + ), + ) + } + + // FIXME: no panics in this function! This will cause to spin if the op changes + #[private] + pub fn after_supply_2_read( + &mut self, + #[callback_result] position: Result, PromiseError>, + op_id: u64, + market_index: u32, + before: U128, + attempted: U128, + refunded: U128, + ) -> PromiseOrValue<()> { + let (idx, rem) = match &self.op_state { + OpState::Allocating { + op_id: cur, + index, + remaining, + } if *cur == op_id => (*index, *remaining), + _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), + }; + + // Invariant: Index drift or stale op_id results in a graceful stop + if idx != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + } + + let Some(market) = self.supply_queue.get(market_index) else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + }; + + let (new_principal, remaining_next) = match position { + Ok(Some(position)) => { + let new_principal: u128 = position.get_deposit().total().into(); + let accepted = new_principal.saturating_sub(before.0); + let remaining = rem.saturating_sub(accepted); + (new_principal, remaining) + } + Ok(None) => { + env::log_str(&format!( + "after_supply_2_read: position None; stopping (op_id={}, market={}, index={}, attempted={}, refunded={})", + op_id, market, market_index, attempted.0, refunded.0 + )); + return self.stop_and_exit(Some(&Error::MissingSupplyPosition)); + } + Err(_) => { + env::log_str(&format!( + "after_supply_2_read: position read failed; stopping (op_id={}, market={}, index={}, attempted={}, refunded={})", + op_id, market, market_index, attempted.0, refunded.0 + )); + return self.stop_and_exit(Some(&Error::PositionReadFailed)); + } + }; + + self.market_supply.insert(market.clone(), new_principal); + // Invariant: withdraw_queue gains any market with new_principal > 0 + if new_principal > 0 && !self.withdraw_queue.iter().any(|m| m == market) { + self.withdraw_queue.push(market.clone()); + } + + self.op_state = OpState::Allocating { + op_id, + index: market_index + 1, + remaining: remaining_next, + }; + self.step_allocation(); + PromiseOrValue::Value(()) + } + + #[private] + pub fn after_create_withdraw_req( + &mut self, + #[callback_result] did_create: Result<(), PromiseError>, + op_id: u64, + market_index: u32, + need: U128, + ) -> PromiseOrValue<()> { + let (idx, rem, recv, coll, owner, escrow_shares) = match &self.op_state { + OpState::Withdrawing { + op_id: cur, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + } if *cur == op_id => ( + *index, + *remaining, + receiver.clone(), + *collected, + owner.clone(), + *escrow_shares, + ), + _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), + }; + + // Invariant: Index drift or stale op_id results in a graceful stop + if idx != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + } + + let Some(market) = self.withdraw_queue.get(market_index) else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + }; + + if let Ok(()) = did_create { PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(GAS_XFER) + .execute_next_supply_withdrawal_request() + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_exec_withdraw_req(op_id, market_index, need), + ), + ) } else { + env::log_str("create_supply_withdrawal_request failed; moving to next market"); + self.op_state = OpState::Withdrawing { + op_id, + index: market_index + 1, + remaining: rem, + receiver: recv, + collected: coll, + owner, + escrow_shares, + }; + self.step_withdraw() + } + } + + #[private] + pub fn after_exec_withdraw_req( + &mut self, + op_id: u64, + market_index: u32, + need: U128, + ) -> PromiseOrValue<()> { + let (idx, _rem, _recv, _coll, _owner, _escrow_shares) = match &self.op_state { + OpState::Withdrawing { + op_id: cur, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + } if *cur == op_id => ( + *index, + *remaining, + receiver.clone(), + *collected, + owner.clone(), + *escrow_shares, + ), + _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), + }; + + // Invariant: Index drift or stale op_id results in a graceful stop + if idx != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + } + + let Some(market) = self.withdraw_queue.get(market_index) else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + }; + + // Verify actual withdrawal by reading market position after execution + let before = *self.market_supply.get(market).unwrap_or(&0); + PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) + .get_supply_position(env::current_account_id()) + .then( + Promise::new(env::current_account_id()).function_call( + "after_exec_withdraw_read".to_string(), + serde_json::to_vec(&serde_json::json!({ + "op_id": op_id, + "market_index": market_index, + "before": U128(before), + "need": need, + })) + .expect("json"), + NearToken::from_yoctonear(0), + GAS_CB, + ), + ), + ) + } + + #[private] + pub fn after_exec_withdraw_read( + &mut self, + #[callback_result] position: Result, PromiseError>, + op_id: u64, + market_index: u32, + before: U128, + need: U128, + ) -> PromiseOrValue<()> { + let (idx, rem, recv, coll, owner, escrow_shares) = match &self.op_state { + OpState::Withdrawing { + op_id: cur, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + } if *cur == op_id => ( + *index, + *remaining, + receiver.clone(), + *collected, + owner.clone(), + *escrow_shares, + ), + _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), + }; + + if idx != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + } + + let Some(market) = self.withdraw_queue.get(market_index) else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + }; + + let before_principal = before.0; + let new_principal = match position { + Ok(Some(position)) => { + let np: u128 = position.get_deposit().total().into(); + np + } + Ok(None) => { + // No position => treat as principal = 0 + env::log_str(&format!( + "after_exec_withdraw_read: no position; treating principal as 0 (op_id={}, market={}, index={}, before={}, need={})", + op_id, market, market_index, before_principal, need.0 + )); + 0 + } + Err(_) => { + env::log_str(&format!( + "after_exec_withdraw_read: get_supply_position failed; op_id={}, market={}, index={}, assuming no change (before={}, need={})", + op_id, market, market_index, before_principal, need.0 + )); + before_principal + } + }; + + let withdrawn = before_principal.saturating_sub(new_principal); + let credited = withdrawn.min(need.0); + + // Update accounting to match market state + self.market_supply.insert(market.clone(), new_principal); + let remaining = rem.saturating_sub(credited); + let collected = coll.saturating_add(credited); + if credited > 0 { + self.idle_balance = self.idle_balance.saturating_add(credited); + } + + if remaining == 0 { + if collected > 0 { + self.op_state = OpState::Payout { + op_id, + receiver: recv.clone(), + amount: collected, + owner: owner.clone(), + escrow_shares, + }; + PromiseOrValue::Promise( + self.underlying_asset + .clone() + .transfer(recv.clone(), U128(collected).into()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_send_to_user(op_id, recv, U128(collected)), + ), + ) + } else { + // Nothing collected; refund escrowed shares + let self_id = env::current_account_id(); + self.withdraw_unchecked(&self_id, escrow_shares) + .expect("Failed to release escrowed shares"); + self.deposit_unchecked(&owner, escrow_shares); + self.op_state = OpState::Idle; + PromiseOrValue::Value(()) + } + } else { + self.op_state = OpState::Withdrawing { + op_id, + index: market_index + 1, + remaining, + receiver: recv, + collected, + owner, + escrow_shares, + }; + self.step_withdraw() + } + } + + #[private] + pub fn after_send_to_user( + &mut self, + #[callback_result] result: Result<(), PromiseError>, + op_id: u64, + receiver: AccountId, + amount: U128, + ) -> bool { + let (owner, escrow_shares, payout_amount) = match &self.op_state { + OpState::Payout { + op_id: cur, + receiver: r, + amount: a, + owner, + escrow_shares, + } if *cur == op_id && *r == receiver => (owner.clone(), *escrow_shares, *a), + _ => { + env::log_str("after_send_to_user: unexpected op_state; ignoring"); + return false; + } + }; + + if let Ok(()) = result { + // Invariant: On payout success, idle_balance -= payout_amount and escrowed shares are burned + self.idle_balance = self.idle_balance.saturating_sub(payout_amount); + self.withdraw_unchecked(&env::current_account_id(), escrow_shares) + .expect("Failed to burn escrowed shares"); + self.op_state = OpState::Idle; + true + } else { + // Invariant: On payout failure, refund escrow to owner and leave idle_balance unchanged + #[allow(clippy::expect_used, reason = "No side effects")] + self.transfer_unchecked(&env::current_account_id(), &owner, escrow_shares) + .expect("Failed to release escrowed shares"); + self.op_state = OpState::Idle; + false + } + } + + fn stop_and_exit_allocating( + &mut self, + msg: Option<&T>, + ) { + if let Some(msg) = msg { + env::log_str(format!("Allocation stopped: {msg}").as_str()); + } + if let OpState::Allocating { remaining, .. } = &self.op_state { + if *remaining > 0 { + self.idle_balance = self.idle_balance.saturating_add(*remaining); + } + } + self.op_state = OpState::Idle; + } + + /// Stop helper for Withdrawing: refund escrowed shares to owner and go Idle. + fn stop_and_exit_withdrawing( + &mut self, + msg: Option<&T>, + ) { + if let Some(msg) = msg { + env::log_str(format!("Withdrawal stopped: {msg}").as_str()); + } + // Take copies to avoid holding immutable borrows across mutable self calls. + let (owner_acc, escrow) = match &self.op_state { + OpState::Withdrawing { + owner, + escrow_shares, + .. + } => (Some(owner.clone()), *escrow_shares), + _ => (None, 0), + }; + if let (Some(owner_acc), escrow) = (owner_acc, escrow) { + if escrow > 0 { + let self_id = env::current_account_id(); + #[allow(clippy::expect_used, reason = "No side effects")] + self.transfer_unchecked(&self_id, &owner_acc, escrow) + .expect("Failed to release escrowed shares"); + } + } + self.op_state = OpState::Idle; + } + + /// Payout: refund escrowed shares to owner and go Idle. + fn stop_and_exit_payout(&mut self, msg: Option<&T>) { + if let Some(msg) = msg { + env::log_str(format!("Payout stopped: {msg}").as_str()); + } + // Take copies to avoid holding immutable borrows across mutable self calls. + let (owner_acc, escrow) = match &self.op_state { + OpState::Payout { + owner, + escrow_shares, + .. + } => (Some(owner.clone()), *escrow_shares), + _ => (None, 0), + }; + if let (Some(owner_acc), escrow) = (owner_acc, escrow) { + if escrow > 0 { + let self_id = env::current_account_id(); + #[allow(clippy::expect_used, reason = "No side effects")] + self.transfer_unchecked(&self_id, &owner_acc, escrow) + .expect("Failed to release escrowed shares"); + } + } + self.op_state = OpState::Idle; + } + + pub(crate) fn stop_and_exit( + &mut self, + msg: Option<&T>, + ) -> PromiseOrValue<()> { + match self.op_state { + OpState::Allocating { .. } => self.stop_and_exit_allocating(msg), + OpState::Withdrawing { .. } => self.stop_and_exit_withdrawing(msg), + OpState::Payout { .. } => self.stop_and_exit_payout(msg), + OpState::Idle => { + if let Some(msg) = msg { + env::log_str(format!("Operation stopped: {msg:?}").as_str()); + } + self.op_state = OpState::Idle; + } + } + PromiseOrValue::Value(()) + } + + #[private] + pub fn after_skim_balance( + &mut self, + #[callback_result] balance: Result, + token: AccountId, + recipient: AccountId, + ) -> PromiseOrValue<()> { + let amount = match balance { + Ok(U128(v)) if v > 0 => v, + _ => { + // Invariant: Skim does nothing for zero balance (no-op cross-call avoided). + env::log_str(&format!( + "Tried to skim; token={token}, recipient={recipient}" + )); + return PromiseOrValue::Value(()); + } + }; + if amount == 0 { + PromiseOrValue::Value(()) + } else { + PromiseOrValue::Promise( + ext_ft_core::ext(token) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .ft_transfer(recipient, U128(amount), None), + ) + } + } +} diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs new file mode 100644 index 00000000..378fc83a --- /dev/null +++ b/contract/vault/src/impl_token_receiver.rs @@ -0,0 +1,125 @@ +use crate::{aux::ReturnStyle, Contract, ContractExt, OpState}; +use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; +use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; +use templar_common::vault::DepositMsg; + +#[allow(clippy::wildcard_imports)] +use near_sdk_contract_tools::mt::*; + +#[near] +impl FungibleTokenReceiver for Contract { + /// NEP-141 token receiver for deposits. + /// Expects a JSON-encoded `DepositMsg` in `msg` (currently only `Supply` is supported). + /// Returns the unused amount to refund to the sender as required by NEP-141. + fn ft_on_transfer( + &mut self, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue { + const RETURN_STYLE: ReturnStyle = ReturnStyle::Nep141FtTransferCall; + + let msg = near_sdk::serde_json::from_str::(&msg) + .unwrap_or_else(|_| env::panic_str("Invalid deposit msg")); + + let asset_id = env::predecessor_account_id(); + + match msg { + DepositMsg::Supply => { + let refund = self.execute_supply(sender_id, asset_id, amount.into()); + PromiseOrValue::Value(refund.into()) + } + } + } +} + +#[near] +impl Nep245Receiver for Contract { + /// NEP-245 multi-token receiver for deposits. + /// Only accepts a single token ID and amount. The token ID must match the underlying asset. + /// Returns a one-element vector with the unused amount to refund to the sender. + fn mt_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_ids: Vec, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> PromiseOrValue> { + const RETURN_STYLE: ReturnStyle = ReturnStyle::Nep245MtTransferCall; + + // NEP-245: This could be an authorized account ID. We only care about + // the actual previous owner. + let _ = sender_id; + + let msg = near_sdk::serde_json::from_str::(&msg) + .unwrap_or_else(|_| env::panic_str("Invalid deposit msg")); + + require!( + token_ids.len() == 1, + "This contract only accepts one token at a time." + ); + require!( + previous_owner_ids.len() == 1 && amounts.len() == 1, + "Invalid input length" + ); + + let token_id = &token_ids[0]; + let sender_id = previous_owner_ids[0].clone(); + let amount = amounts[0]; + + match msg { + DepositMsg::Supply => { + let refund = self.execute_supply( + sender_id.clone(), + token_id + .parse() + .unwrap_or_else(|_| env::panic_str("Invalid token ID")), + amount.into(), + ); + + PromiseOrValue::Value(vec![U128(refund)]) + } + } + } +} + +impl Contract { + pub(crate) fn execute_supply( + &mut self, + sender_id: AccountId, + token_id: AccountId, + deposit: u128, + ) -> u128 { + // Invariant: Only the underlying token is accepted; others are fully refunded + if token_id != self.underlying_asset.contract_id() { + env::log_str("Only underlying asset is supported"); + return deposit; + }; + + if deposit == 0 { + env::log_str("Deposit is zero"); + return 0; + } + + self.internal_accrue_fee(); + + let max = self.max_deposit().0; + let accept = deposit.min(max); + let refund = deposit - accept; + + let shares = self.preview_deposit(U128(accept)).0; + self.mint_shares(&sender_id, shares); + + self.idle_balance = self.idle_balance.saturating_add(accept); + self.last_total_assets = self.last_total_assets.saturating_add(accept); + + if matches!(self.op_state, OpState::Idle) { + // Invariant: no overlapping operations + env::log_str("Starting allocation"); + self.start_allocation(self.idle_balance); + } + + refund + } +} diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs new file mode 100644 index 00000000..ab3c2eda --- /dev/null +++ b/contract/vault/src/lib.rs @@ -0,0 +1,1077 @@ +#![allow(clippy::needless_pass_by_value)] + +use near_contract_standards::fungible_token::core::ext_ft_core; +use near_sdk::{ + env, + json_types::U128, + near, serde_json, + store::{IterableMap, LookupMap, Vector}, + AccountId, BorshStorageKey, IntoStorageKey, NearToken, PanicOnDefault, Promise, PromiseOrValue, +}; +use near_sdk_contract_tools::rbac::Rbac; +use near_sdk_contract_tools::{ + ft::{ + nep141::GAS_FOR_FT_TRANSFER_CALL, ContractMetadata, FungibleToken, Nep141Controller, + Nep148Controller, + }, + standard::nep145::{Nep145Controller, Nep145ForceUnregister}, + Owner, Rbac, +}; +use near_sdk_contract_tools::{owner::Owner, rbac}; +use templar_common::{ + asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, + vault::{ + ext_self, MarketConfiguration, PendingValue, TimestampNs, GAS_CB, GAS_XFER, MAX_QUEUE_LEN, + MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + }, +}; +pub use wad::*; + +pub mod aux; +pub mod impl_callbacks; +pub mod impl_token_receiver; +pub mod wad; + +#[derive(Debug, Clone)] +#[near(serializers = [json, borsh])] +/// Operation state machine for asynchronous allocation, withdrawal, and payout flows. +pub enum OpState { + Idle, + Allocating { + op_id: u64, + index: u32, + remaining: u128, + }, + Withdrawing { + op_id: u64, + index: u32, + remaining: u128, + collected: u128, + receiver: AccountId, + owner: AccountId, + escrow_shares: u128, + }, + Payout { + op_id: u64, + receiver: AccountId, + amount: u128, + owner: AccountId, + escrow_shares: u128, + }, +} + +#[near] +#[derive(BorshStorageKey)] +/// Internal storage keys used by persistent collections. +pub enum StorageKey { + Config, + PendingCaps, + SupplyQueue, + WithdrawQueue, + MarketSupply, +} + +#[derive(BorshStorageKey)] +#[near] +/// Role-based access control roles for privileged actions. +pub enum Role { + /// Primary operator for market configuration and policy. + /// Can submit/accept cap changes and market removals, and is implicitly granted the Allocator role. + Curator, + /// Safety backstop that can revoke pending governance changes (e.g., timelock/guardian). + /// Has no authority to change caps or queues on its own. + Guardian, + /// Operational role for queue maintenance. + /// May set the supply/withdraw queues while the vault is Idle; cannot modify caps/timelocks/guardian. + Allocator, +} + +type ExpectedIdx = u32; +type ActualIdx = u32; + +#[derive(Debug)] +#[near(serializers = [json])] +pub enum Error { + // Invariant: Index drift or stale op_id results in a graceful stop + IndexDrifted(ExpectedIdx, ActualIdx), + // Invariant: Attempting to work on a market that is missing from the withdraw queue + MissingMarket(u32), + NotWithdrawing(OpState), + NotAllocating(OpState), + MarketTransferFailed, + MissingSupplyPosition, + PositionReadFailed, + // Invariant: Insufficient liquidity across all markets to satisfy withdrawal + InsufficientLiquidity, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +#[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] +// FIXME: #[nep145(force_unregister_hook = "Self")] +#[rbac(roles = "Role", crate = "crate")] +#[near(contract_state)] +/// Vault contract that issues shares over an underlying fungible asset and allocates liquidity +/// across configured markets. Implements 4626-like deposit/withdraw semantics. +pub struct Contract { + underlying_asset: FungibleAsset, + /// configuration per market (market ID -> MarketConfig) + config: IterableMap, + + // TODO: decimal offset for virtual shares + /// Performance fee (as WAD fraction) + performance_fee: wad::WADFraction, + fee_recipient: AccountId, + skim_recipient: AccountId, + /// Last recorded total assets (for fee accrual) + last_total_assets: u128, + + // Virtual offsets used only in conversions/previews to harden edge cases + virtual_shares: u128, + virtual_assets: u128, + + /// Any pending change to the vault's cap, TODO: u256 + pending_cap: IterableMap>, + /// Any pending change to the vault's timelock + pending_timelock: Option>, + /// Any pending change to the vault's guardian + pending_guardian: Option>, + /// Current timelock duration for governance actions (ns) + timelock_ns: TimestampNs, + + // Ordered list of market IDs for deposit allocation + supply_queue: Vector, + // Ordered list of market IDs for withdrawal prioritytr + withdraw_queue: Vector, + + // vault's supplied principal per market (borrow-asset units) + market_supply: LookupMap, + + // underlying held by vault + idle_balance: u128, + op_state: OpState, + next_op_id: u64, + + // Storage usage + storage_usage_supply: u64, + storage_usage_role: u64, +} + +#[near] +impl Contract { + #[allow(clippy::unwrap_used, reason = "Infallible")] + #[allow(clippy::too_many_arguments, reason = "Constructor")] + #[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. + pub fn new( + owner_id: AccountId, + curator_id: AccountId, + guardian_id: AccountId, + underlying_token_id: FungibleAsset, + initial_timelock_sec: u32, + fee_recipient: AccountId, + skim_recipient: AccountId, + name: String, + symbol: String, + // TODO: decide if should assert decimals as underlying + decimals: u8, + ) -> Self { + let timelock_ns = u64::from(initial_timelock_sec) * 1_000_000_000; + assert!( + (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&timelock_ns), + "timelock bounds" + ); + + let prefix = b"v"; + // TODO: this is copied from market, make a helper + let prefix = prefix.into_storage_key(); + macro_rules! key { + ($key: ident) => { + [ + prefix.as_slice(), + StorageKey::$key.into_storage_key().as_slice(), + ] + .concat() + }; + } + + // TODO: this but with roles and other storage we set + // let storage_usage_1 = env::storage_usage(); + // market.finalized_snapshots.flush(); + // let storage_usage_2 = env::storage_usage(); + // let storage_usage_snapshot = storage_usage_2.saturating_sub(storage_usage_1); + let storage_usage_supply = env::storage_usage(); + let storage_usage_role = env::storage_usage(); + + let mut contract = Self { + underlying_asset: underlying_token_id, + timelock_ns, + performance_fee: Default::default(), + fee_recipient, + skim_recipient, + config: IterableMap::new(key!(Config)), + pending_cap: IterableMap::new(key!(PendingCaps)), + pending_timelock: None, + pending_guardian: None, + supply_queue: Vector::new(key!(SupplyQueue)), + withdraw_queue: Vector::new(key!(WithdrawQueue)), + market_supply: LookupMap::new(key!(MarketSupply)), + last_total_assets: 0, + virtual_shares: 1, + virtual_assets: 1, + idle_balance: 0, + op_state: OpState::Idle, + next_op_id: 1, + storage_usage_supply, + storage_usage_role, + }; + contract.set_metadata(&ContractMetadata::new(name, symbol, decimals)); + Owner::init(&mut contract, &owner_id); + Rbac::add_role(&mut contract, &curator_id, &Role::Curator); + Rbac::add_role(&mut contract, &curator_id, &Role::Allocator); + Rbac::add_role(&mut contract, &guardian_id, &Role::Guardian); + + contract + } + + /// Sets the Curator account. Also grants/removes the Allocator role accordingly. + pub fn set_curator(&mut self, account: AccountId) { + Self::require_owner(); + Self::with_members_of(&Role::Curator, |members| { + assert!( + members.len() < 2, + "Invariant violation: Cannot Have more than 1 Curator" + ); + assert!( + !members.contains(&account), + "Curator already set to this account" + ); + members.iter().for_each(|m| { + self.remove_role(&m, &Role::Curator); + self.remove_role(&m, &Role::Allocator); + }); + }); + Self::add_role(self, &account, &Role::Curator); + Self::add_role(self, &account, &Role::Allocator); + env::log_str(&format!("Curator set to {account}")); + env::log_str(&format!("Allocator role for {account}")); + } + + /// Grants or revokes the Allocator role for `account`. + pub fn set_is_allocator(&mut self, account: AccountId, allowed: bool) { + Self::require_owner(); + if allowed { + Self::add_role(self, &account, &Role::Allocator); + } else { + self.remove_role(&account, &Role::Allocator); + } + env::log_str(&format!("Allocator role for {account} set to {allowed}")); + } + + /// Proposes a new Guardian. If a Guardian already exists, starts a timelock; otherwise sets immediately. + pub fn submit_guardian(&mut self, new_g: AccountId) { + Self::require_owner(); + let mut guardian_occupied = false; + + Self::with_members_of(&Role::Guardian, |members| { + assert!( + members.len() < 2, + "Invariant violation: Cannot Have more than 1 Guardian" + ); + assert!(!members.contains(&new_g), "Already set to this address"); + guardian_occupied = !members.is_empty(); + }); + assert!( + self.pending_guardian.is_none(), + "Guardian change already pending" + ); + if guardian_occupied { + let valid_at = env::block_timestamp() + self.timelock_ns; + self.pending_guardian = Some(PendingValue { + value: new_g, + valid_at, + }); + } else { + Self::add_role(self, &new_g, &Role::Guardian); + } + } + + /// Accepts the pending Guardian change after the timelock has elapsed. + pub fn accept_guardian(&mut self) { + Self::require_owner(); + + let p = self.pending_guardian.clone(); + + if let Some(p) = &p { + assert!(env::block_timestamp() >= p.valid_at, "not yet"); + Self::with_members_of(&Role::Guardian, |members| { + members.iter().for_each(|m| { + self.remove_role(&m, &Role::Guardian); + }); + Self::add_role(self, &p.value, &Role::Guardian); + }); + self.pending_guardian = None; + } + } + + /// Revokes any pending Guardian change. + pub fn revoke_pending_guardian(&mut self) { + Self::assert_guardian_or_owner(); + self.pending_guardian = None; + } + + /// Sets the recipient account for skimmed tokens. + pub fn set_skim_recipient(&mut self, account: AccountId) { + Self::require_owner(); + assert!( + account != self.skim_recipient, + "Already set to this address" + ); + self.skim_recipient = account.clone(); + env::log_str(&format!("Skim recipient set to {account}")); + } + + /// Sets the performance fee recipient. Accrues pending fees with the current recipient first. + pub fn set_fee_recipient(&mut self, account: AccountId) { + Self::require_owner(); + assert!(account != self.fee_recipient, "Already set to this address"); + + if self.performance_fee != 0 { + // Accrue any pending fees to current recipient before changing (so current recipient gets up to now) + self.internal_accrue_fee(); + } + env::log_str(&format!("Fee recipient set to {account}")); + self.fee_recipient = account; + } + + /// Sets the performance fee as a WAD fraction (1e18 = 100%). Accrues fees at the old rate first. + pub fn set_performance_fee(&mut self, fee: U128) { + Self::require_owner(); + + let fee: u128 = fee.into(); + + assert!(fee != self.performance_fee, "Fee already set to this value"); + // FIXME: dynamic based on underlying + assert!(fee <= (wad::WAD / 10), "fee too high"); + + // Accrue any pending fees with old rate before changing + self.internal_accrue_fee(); + self.performance_fee = fee; + env::log_str(&format!("Performance fee set to {fee}")); + } + + /* ----- Timelocks / Pending ----- */ + /// Proposes a new governance timelock in seconds. + /// If increasing, applies immediately; if decreasing, starts a timelock equal to the current duration. + pub fn submit_timelock(&mut self, new_timelock_secs: u32) { + Self::require_owner(); + let as_nanos = u64::from(new_timelock_secs) * 1_000_000_000; + + assert!(as_nanos != self.timelock_ns, "Already set to this value"); + assert!( + self.pending_timelock.is_none(), + "Timelock change already pending" + ); + assert!( + (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&as_nanos), + "Timelock out of bounds" + ); + if as_nanos > self.timelock_ns { + self.timelock_ns = as_nanos; + env::log_str(&format!("Timelock set to {new_timelock_secs} seconds")); + } else { + let valid_at = env::block_timestamp() + self.timelock_ns; + self.pending_timelock = Some(PendingValue { + value: as_nanos, + valid_at, + }); + env::log_str(&format!( + "Timelock change to {new_timelock_secs} seconds pending, will take effect at {valid_at}" + )); + } + } + + /// Accepts a pending timelock change after it becomes valid. + pub fn accept_timelock(&mut self) { + Self::require_owner(); + if let Some(p) = &self.pending_timelock { + assert!( + env::block_timestamp() >= p.valid_at, + "Timelock not elapsed yet" + ); + self.timelock_ns = p.value; + self.pending_timelock = None; + } else { + env::panic_str("No pending timelock change"); + } + } + + /// Revokes any pending timelock change. + pub fn revoke_pending_timelock(&mut self) { + Self::assert_guardian_or_owner(); + self.pending_timelock = None; + env::log_str("Pending timelock change revoked"); + } + + /* ----- Market config / queues ----- */ + /// Submits a change to a market's supply cap. + /// Decreases apply immediately; increases are subject to the governance timelock. + pub fn submit_cap(&mut self, market: AccountId, new_cap: U128) { + Self::assert_curator_or_owner(); + self.ensure_idle(); + let config = match self.config.get_mut(&market) { + None => { + self.config + .insert(market.clone(), MarketConfiguration::default()); + env::log_str(&format!("Market {market} created")); + #[allow(clippy::unwrap_used, reason = "No side effects")] + self.config.get_mut(&market).unwrap() + } + Some(config) => config, + }; + + assert!( + self.pending_cap.get(&market).is_none(), + "Invariant violation: A cap change is already pending for this market" + ); + assert!( + config.removable_at == 0, + "Market removal pending, cannot change cap" + ); + let new_cap = new_cap.0; + assert!(new_cap != config.cap, "New cap is same as current"); + + if new_cap < config.cap { + // If lowering the cap, we can apply the delta immediately + + config.cap = new_cap; + // Disable market if cap is zero + if new_cap == 0 { + config.enabled = false; + } + } else { + let valid_at = env::block_timestamp() + self.timelock_ns; + self.pending_cap.insert( + market.clone(), + PendingValue { + value: new_cap, + valid_at, + }, + ); + env::log_str(&format!( + "Supply cap raise for {market} to {new_cap} pending, valid at {valid_at}", + )); + } + } + /// Accepts a pending cap increase for `market` once the timelock has elapsed. + pub fn accept_cap(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + self.ensure_idle(); + if let Some(pending) = self.pending_cap.get(&market) { + assert!( + env::block_timestamp() >= pending.valid_at, + "Timelock not elapsed for cap change" + ); + + #[allow(clippy::expect_used, reason = "No side effects")] + let cfg = self.config.get_mut(&market).expect("Market not found"); + + cfg.cap = pending.value; + if pending.value > 0 { + // If enabling or raising cap above 0, mark enabled and add to withdraw_queue if not already present + if !cfg.enabled { + cfg.enabled = true; + let mut added = false; + if self.withdraw_queue.iter().any(|m| m == &market) { + env::log_str(&format!( + "Market {market} enabled (cap set > 0); already in withdraw_queue" + )); + } else { + self.withdraw_queue.push(market.clone()); + env::log_str(&format!( + "Market {market} enabled (cap set > 0); added to withdraw_queue" + )); + added = true; + } + + // Only adjust last_total_assets if we just re-added the market to the withdraw queue + if added { + let current = self.market_supply.get(&market).unwrap_or(&0); + self.last_total_assets = self.last_total_assets.saturating_add(*current); + } + } + cfg.removable_at = 0; + } else { + cfg.enabled = false; + } + env::log_str(&format!( + "Supply cap for {} set to {}", + market, pending.value + )); + self.pending_cap.remove(&market); + } else { + env::panic_str("No pending cap change for this market"); + } + } + + /// Revokes any pending cap change for `market`. + pub fn revoke_pending_cap(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + if self.pending_cap.get(&market).is_some() { + self.pending_cap.remove(&market); + } + } + + // To remove a market entirely, the curator: + //- first sets its cap to 0 (disabling new deposits) + //- then calls submit_market_removal. + // > This starts a timelock (using the vault’s timelock) + // - after which the market can be removed from the withdraw_queue (assuming any funds have been withdrawn) + /// Begins the process to remove `market` from the withdraw queue. + /// Requires cap == 0 and no pending cap changes; starts a timelock. + pub fn submit_market_removal(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + let cfg = self + .config + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("unknown market")); + assert!( + cfg.removable_at == 0, + "Removal already pending for this market" + ); + assert!( + cfg.cap == 0, + "Cannot remove market with non-zero cap (disable deposits first)" + ); + assert!(cfg.enabled, "Market not enabled or already removed"); + assert!( + self.pending_cap.get(&market).is_none(), + "Cap change pending for this market" + ); + cfg.removable_at = env::block_timestamp() + self.timelock_ns; + env::log_str(&format!( + "Market {} removal pending, will take effect at {}", + market, cfg.removable_at + )); + } + /// Revokes a pending market removal for `market`. + pub fn revoke_pending_market_removal(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + if let Some(cfg) = self.config.get_mut(&market) { + cfg.removable_at = 0; + } + env::log_str(&format!("Market {market} removal revoked")); + } + + /// Sets the ordered supply (allocation) queue. + /// Rejects duplicates and markets without a positive cap. Requires the vault to be idle. + pub fn set_supply_queue(&mut self, markets: Vec) { + Self::assert_allocator(); + self.ensure_idle(); + assert!(markets.len() <= MAX_QUEUE_LEN, "too long"); + + // Invariant: supply_queue has no duplicates; allocation order remains meaningful + let mut seen = std::collections::HashSet::new(); + for m in &markets { + if !seen.insert(m.clone()) { + env::panic_str(&format!("Duplicate market {m}")); + } + } + + self.supply_queue.clear(); + for m in &markets { + let cap = self.config.get(m).map_or(0, |c| c.cap); + assert!(cap > 0, "unauthorized market"); + self.supply_queue.push(m.clone()); + } + } + + /// For each removed market, we enforce the conditions: + /// Cap is 0 (no new deposits). + /// + /// No pending cap change. + /// + /// If the vault still has a supply in that market (vault_shares_in_market > 0), the market must have had submit_market_removal called (removable_at set) and the timelock must have passed. + /// Sets the ordered withdraw queue. + /// Enforces safety invariants and the policy that all enabled/holding markets must be present. + pub fn set_withdraw_queue(&mut self, queue: Vec) { + Self::assert_allocator(); + self.ensure_idle(); + assert!( + queue.len() <= MAX_QUEUE_LEN, + "Withdraw queue length exceeds max" + ); + + // Ensure no duplicates in the new queue + let mut seen = std::collections::HashSet::new(); + for id in &queue { + if !seen.insert(id.clone()) { + env::panic_str(&format!("Duplicate market {id}")); + } + } + + // Snapshot current withdraw queue into a set for membership checks + let current: std::collections::HashSet = + self.withdraw_queue.iter().cloned().collect(); + + // Each id in the new queue must correspond to a known market + for id in &queue { + assert!(self.config.get(id).is_some(), "Unknown market in new queue"); + } + + // Enforce invariant: withdraw_queue must include all enabled or holding markets + for (id, cfg) in self.config.iter() { + let has_supply = *self.market_supply.get(id).unwrap_or(&0) > 0; + if cfg.enabled || has_supply { + assert!( + seen.contains(id), + "Withdraw queue must include all enabled or holding markets" + ); + } + } + + // For every market being removed, enforce safety invariants before removal + for id in current.difference(&seen).cloned().collect::>() { + #[allow(clippy::expect_used, reason = "No side effects")] + let config = self.config.get_mut(&id).expect("Market not found"); + + assert!(config.cap == 0, "Cannot remove market with non-zero cap"); + assert!( + self.pending_cap.get(&id).is_none(), + "Cannot remove market with pending cap change" + ); + let position = *self.market_supply.get(&id).unwrap_or(&0); + if position > 0 { + assert!( + config.removable_at > 0, + "Market still has supply but no removal scheduled" + ); + assert!( + env::block_timestamp() >= config.removable_at, + "Removal timelock not elapsed for market" + ); + } + // Remove market configuration + self.config.remove(&id); + } + + // Replace withdraw_queue atomically + self.withdraw_queue.clear(); + for id in &queue { + self.withdraw_queue.push(id.clone()); + } + env::log_str(&format!( + "Withdraw queue updated. Current markets: {queue:?}", + )); + } + + /* ----- Views ----- */ + /// Returns total assets under management = idle balance + sum of market principals. + pub fn total_assets(&self) -> U128 { + // TODO: join + let mut sum = self.idle_balance; + self.withdraw_queue.iter().for_each(|m| { + sum += self.market_supply.get(m).unwrap_or(&0); + }); + U128(sum) + } + + /// Returns the maximum additional amount that can be deposited across all markets given current caps. + pub fn max_deposit(&self) -> U128 { + // TODO: join + let mut total = 0u128; + self.supply_queue.iter().for_each(|m| { + if let Some(cfg) = self.config.get(m) { + if cfg.cap > 0 { + let cur = self.market_supply.get(m).unwrap_or(&0); + if cfg.cap > *cur { + total += cfg.cap - cur; + } + } + } + }); + U128(total) + } + + /// Computes fee-aware effective totals for conversions, mimicking MetaMorpho: + /// - Include fee shares that would be minted if fees accrued now. + /// - Apply virtual offsets: +virtual_shares to supply and +virtual_assets to assets. + fn effective_totals_fee_aware(&self) -> (u128, u128) { + let cur = self.total_assets().0; + let ts = self.total_supply(); + let fee_shares = + crate::wad::compute_fee_shares(cur, self.last_total_assets, self.performance_fee, ts); + let new_total_supply = ts + .saturating_add(fee_shares) + .saturating_add(self.virtual_shares); + let new_total_assets = cur.saturating_add(self.virtual_assets); + (new_total_supply, new_total_assets) + } + + /// Converts an amount of underlying assets to shares, flooring the result. + /// Uses virtual offsets and fee-aware totals (pre-accrual simulation) like MetaMorpho. + pub fn convert_to_shares(&self, assets: U128) -> U128 { + let a: u128 = assets.0; + if a == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(crate::wad::mul_div_floor( + a, + new_total_supply, + new_total_assets, + )) + } + + /// Converts an amount of shares to underlying assets, flooring the result. + /// Uses virtual offsets and fee-aware totals (pre-accrual simulation) like MetaMorpho. + pub fn convert_to_assets(&self, shares: U128) -> U128 { + let s: u128 = shares.0; + if s == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(crate::wad::mul_div_floor( + s, + new_total_assets, + new_total_supply, + )) + } + + /// Preview the number of shares minted for a deposit of `assets` (floored). + /// Simulates fee accrual first (minting fee shares), then applies virtual offsets for conversion. + pub fn preview_deposit(&self, assets: U128) -> U128 { + self.convert_to_shares(assets) + } + + /// Preview the amount of assets required to mint `shares` (ceiled). + /// Simulates fee accrual first (minting fee shares), then applies virtual offsets for conversion. + pub fn preview_mint(&self, shares: U128) -> U128 { + let s = shares.0; + if s == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(crate::wad::mul_div_ceil( + s, + new_total_assets, + new_total_supply, + )) + } + + /// Preview the number of shares required to withdraw `assets` (ceiled). + /// Applies virtual offsets and fee-aware totals (pre-accrual simulation). + pub fn preview_withdraw(&self, assets: U128) -> U128 { + let a = assets.0; + if a == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(crate::wad::mul_div_ceil( + a, + new_total_supply, + new_total_assets, + )) + } + + /// Preview the amount of assets received by redeeming `shares` (floored). + /// Returns 0 if total supply is zero. + pub fn preview_redeem(&self, shares: U128) -> U128 { + self.convert_to_assets(shares) + } + + /* ----- Withdraw / Redeem ----- */ + /// Burns the necessary shares to withdraw `amount` of underlying to `receiver`. + /// Internally calls `redeem` after computing the share amount. + pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { + let shares_needed = self.preview_withdraw(amount).0; + self.redeem(U128(shares_needed), receiver) + } + + /// Redeems `shares` for underlying assets sent to `receiver`. + /// Shares are escrowed to the contract and only burned after successful payout. + pub fn redeem(&mut self, shares: U128, receiver: AccountId) -> PromiseOrValue<()> { + let shares = shares.0; + + let assets = self.convert_to_assets(U128(shares)).0; + + let owner = env::predecessor_account_id(); + + // Move shares into vault escrow; do not burn yet + #[allow(clippy::expect_used, reason = "No side effects")] + self.transfer_unchecked(&owner, &env::current_account_id(), shares) + .expect("Redeem failed to move shares into escrow"); + + self.internal_accrue_fee(); + + env::log_str(&format!( + "Redeem requested: {shares} shares for ~{assets} assets" + )); + self.start_withdraw(assets, receiver.clone(), owner, shares) + } + + /* ----- Skim (sends entire balance of `token` to `skim_recipient`) ----- */ + /// 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(); + ext_ft_core::ext(token.clone()) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .ft_balance_of(env::current_account_id()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .after_skim_balance(token, self.skim_recipient.clone()), + ) + } +} + +/* ----- Private Helpers ----- */ +impl Contract { + /* ----- Internal: fee, shares ----- */ + pub fn mint_shares(&mut self, to: &AccountId, amount: u128) { + if amount == 0 { + return; + } + #[allow(clippy::expect_used, reason = "No side effects")] + self.deposit_unchecked(to, amount) + .expect("Failed to mint shares"); + } + + 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.total_assets().0; + let fee_shares = crate::wad::compute_fee_shares( + cur, + self.last_total_assets, + self.performance_fee, + self.total_supply(), + ); + if fee_shares > 0 { + self.mint_shares(&self.fee_recipient.clone(), fee_shares); + } + self.last_total_assets = cur; + } + + /* ----- Auth ----- */ + fn assert_guardian_or_owner() { + let p = env::predecessor_account_id(); + + if !Self::has_role(&p, &Role::Guardian) { + Self::require_owner(); + } + } + + fn assert_curator_or_owner() { + let p = env::predecessor_account_id(); + if !Self::has_role(&p, &Role::Curator) { + Self::require_owner(); + } + } + + fn assert_allocator() { + let p = env::predecessor_account_id(); + if !Self::has_role(&p, &Role::Allocator) && !Self::has_role(&p, &Role::Curator) { + Self::require_owner(); + } + } + + /* ----- Internal: op orchestration ----- */ + fn ensure_idle(&self) { + // Invariant: Only one op in flight; ensure_idle() guards all mutating ops. + if !matches!(self.op_state, OpState::Idle) { + env::panic_str("busy"); + } + } + + fn start_allocation(&mut self, amount: u128) -> PromiseOrValue<()> { + if amount == 0 { + return PromiseOrValue::Value(()); + } + self.ensure_idle(); + self.idle_balance = 0; + let op_id = self.next_op_id; + self.next_op_id += 1; + self.op_state = OpState::Allocating { + op_id, + index: 0, + remaining: amount, + }; + self.step_allocation() + } + + fn step_allocation(&mut self) -> PromiseOrValue<()> { + let (op_id, index, remaining) = match &self.op_state { + OpState::Allocating { + op_id, + index, + remaining, + } => (*op_id, *index, *remaining), + _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), + }; + if remaining == 0 { + return self.stop_and_exit::(None); + } + if let Some(market) = self.supply_queue.get(index) { + let cap = self.config.get(market).map_or(0, |c| c.cap); + let cur = self.market_supply.get(market).unwrap_or(&0); + let room = cap.saturating_sub(*cur); + let to_supply = room.min(remaining); + if to_supply == 0 { + self.op_state = OpState::Allocating { + op_id, + index: index + 1, + remaining, + }; + return self.step_allocation(); + } + PromiseOrValue::Promise( + self.underlying_asset + .transfer_call( + market, + U128(to_supply).into(), + Some( + #[allow(clippy::expect_used, reason = "Infallible")] + serde_json::to_string(&templar_common::market::DepositMsg::Supply) + .expect("Infallible serialisation of supply enum") + .as_str(), + ), + ) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_supply_1_check(op_id, index, U128(to_supply)), + ), + ) + } else { + // Shouldn't happen if max_deposit used; stop and reconcile remaining in stop_and_exit + self.stop_and_exit(Some("Market not found")) + } + } + + fn start_withdraw( + &mut self, + amount: u128, + receiver: AccountId, + owner: AccountId, + escrow_shares: u128, + ) -> PromiseOrValue<()> { + if amount == 0 { + env::panic_str("no assets to withdraw"); + } + self.ensure_idle(); + let op_id = self.next_op_id; + self.next_op_id += 1; + + // Invariant: Idle-first reservation does not mutate idle_balance until payout succeeds. + let used_idle = self.idle_balance.min(amount); + let remaining = amount.saturating_sub(used_idle); + let collected = used_idle; + + self.op_state = OpState::Withdrawing { + op_id, + index: Default::default(), + remaining, + receiver, + collected, + owner, + escrow_shares, + }; + self.step_withdraw() + } + + fn step_withdraw(&mut self) -> PromiseOrValue<()> { + let (op_id, index, remaining, receiver, collected, owner, escrow_shares) = + match &self.op_state { + OpState::Withdrawing { + op_id, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + } => ( + *op_id, + *index, + *remaining, + receiver.clone(), + *collected, + owner.clone(), + *escrow_shares, + ), + _ => return self.stop_and_exit(Some("Not withdrawing")), + }; + if remaining == 0 { + if collected > 0 { + self.op_state = OpState::Payout { + op_id, + receiver: receiver.clone(), + amount: collected, + owner: owner.clone(), + escrow_shares, + }; + return PromiseOrValue::Promise( + self.underlying_asset + .transfer(receiver.clone(), U128(collected).into()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_send_to_user(op_id, receiver, U128(collected)), + ), + ); + } + // Nothing collected; refund escrowed shares + let self_id = env::current_account_id(); + self.withdraw_unchecked(&self_id, escrow_shares) + .expect("Failed to release escrowed shares"); + self.deposit_unchecked(&owner, escrow_shares); + return self.stop_and_exit::(None); + } + if let Some(market) = self.withdraw_queue.get(index) { + let have = self.market_supply.get(market).unwrap_or(&0); + let to_request = have.min(&remaining); + if to_request == &0 { + self.op_state = OpState::Withdrawing { + op_id, + index: index + 1, + remaining, + receiver, + collected, + owner, + escrow_shares, + }; + return self.step_withdraw(); + } + PromiseOrValue::Promise( + templar_common::market::ext_market::ext(market.clone()) + .with_attached_deposit(NearToken::from_yoctonear(1)) + // FIXME: incorrect + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(*to_request))) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_create_withdraw_req(op_id, index, U128(*to_request)), + ), + ) + } else { + // Insufficient liquidity across all markets: refund escrowed shares and stop + let self_id = env::current_account_id(); + self.transfer_unchecked(&self_id, &owner, escrow_shares) + .expect("Failed to release escrowed shares"); + self.stop_and_exit(Some(&Error::InsufficientLiquidity)) + } + } +} diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs new file mode 100644 index 00000000..5444be9f --- /dev/null +++ b/contract/vault/src/wad.rs @@ -0,0 +1,98 @@ +/// Fixed-point helpers and fee-accrual math using 18-decimal WAD precision. +use templar_common::primitive_types::U256; + +pub type WADFraction = u128; +pub const WAD: u128 = 1e18 as u128; + +/// Multiplies two WAD-scaled values and floors the result: floor(x * y / WAD). +#[inline] +pub fn mul_wad_floor(x: u128, y: u128) -> u128 { + mul_div_floor(x, y, WAD) +} + +/// Multiplies and divides with flooring: floor(x * y / denom). +/// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. +#[inline] +pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { + if denom == 0 { + return 0; + } + let num = U256::from(x) * U256::from(y); + let q = num / U256::from(denom); + q.as_u128() +} + +/// Multiplies and divides with ceiling: ceil(x * y / denom). +/// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. +#[inline] +pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { + if denom == 0 { + return 0; + } + let num = U256::from(x) * U256::from(y); + let d = U256::from(denom); + let q = (num + d - U256::from(1)) / d; + q.as_u128() +} + +/// Computes fee shares to mint given: +/// - `cur_total_assets`: current total assets under management +/// - `last_total_assets`: previous total assets snapshot +/// - `performance_fee`: WAD fraction (1e18 = 100%) +/// - `total_supply`: current total share supply +/// +/// Floors intermediate divisions; returns 0 when no profit, zero fee, or zero supply. +#[inline] +pub fn compute_fee_shares( + cur_total_assets: u128, + last_total_assets: u128, + performance_fee: u128, + total_supply: u128, +) -> u128 { + if performance_fee == 0 || total_supply == 0 || cur_total_assets <= last_total_assets { + return 0; + } + let profit = cur_total_assets - last_total_assets; + let fee_assets = mul_wad_floor(profit, performance_fee); + if fee_assets == 0 { + return 0; + } + // ERC-4626-like: mint shares so that fee_shares / (total_supply + fee_shares) = fee_assets / cur_total_assets + // Rearranged and floored: + let denom = cur_total_assets.saturating_sub(fee_assets).max(1); + mul_div_floor(fee_assets, total_supply, denom) +} + +#[cfg(test)] +mod tests { + use super::*; + + const W: u128 = WAD; + + #[test] + fn mul_wad_floor_rounds_down() { + // 0.3333... * 0.3333... ~= 0.1111... + let third = W / 3; + let res = mul_wad_floor(third, third); + // floor(1/9 * W) = floor(0.111... * 1e18) + assert!(res <= W / 9); + assert_eq!(res, (W / 9) - 1); // typical floor loss + } + #[test] + fn convert_roundtrip_bounds() { + // For any totals, redeem(convert_to_shares(a)) ≤ a and + // convert_to_shares(convert_to_assets(s)) ≥ s due to floor/ceil pairing. + let a = 1_234_567u128; + let s = 987_654u128; + // Fake a contract-like environment: + let ts = 10_000u128; + let ta = 12_000u128; + let to_sh = mul_div_floor(a, ts + 1, ta + 1); + let back_a = mul_div_floor(to_sh, ta + 1, ts + 1); + assert!(back_a <= a); + + let to_a = mul_div_floor(s, ta + 1, ts + 1); + let back_s = mul_div_ceil(to_a, ts + 1, ta + 1); + assert!(back_s >= s); + } +} diff --git a/contract/vault/tests/conversions.rs b/contract/vault/tests/conversions.rs new file mode 100644 index 00000000..d8090c65 --- /dev/null +++ b/contract/vault/tests/conversions.rs @@ -0,0 +1,126 @@ +use rstest::rstest; +use templar_vault_contract::*; + +const W: u128 = WAD; + +#[test] +fn no_fee_returns_zero() { + assert_eq!(compute_fee_shares(1_000, 900, 0, 1_000), 0); +} + +#[test] +fn no_profit_returns_zero() { + assert_eq!(compute_fee_shares(1_000, 1_000, W / 10, 1_000), 0); + assert_eq!(compute_fee_shares(900, 1_000, W / 10, 1_000), 0); +} + +#[test] +fn zero_supply_returns_zero() { + assert_eq!(compute_fee_shares(1_000, 900, W / 10, 0), 0); +} + +#[test] +fn simple_accrual_10_percent_fee() { + // cur=1200, last=1000, profit=200, fee_assets=20 + // fee_shares = floor(20 * 1000 / (1200-20)) = floor(20000/1180) = 16 + assert_eq!(compute_fee_shares(1200, 1000, W / 10, 1000), 16); +} + +#[test] +fn full_fee_100_percent() { + // cur=1200, last=1000, profit=200, fee_assets=200 + // denom = 1200 - 200 = 1000 + // fee_shares = 200*1000/1000 = 200 + assert_eq!(compute_fee_shares(1200, 1000, W, 1000), 200); +} + +// Property: Shares minting never panics, never mints more than `accept` when price ≥ 1 +// Model: minted = floor(accept * S / A); price ≥ 1 <=> A >= S => minted ≤ accept +#[rstest( + accept => [0u128, 1, 2, 10, 1u128<<32, 1u128<<64, u128::MAX/2, u128::MAX-1], + supply => [0u128, 1, 10, 1u128<<32, 1u128<<64, u128::MAX/2], + assets_base => [1u128, 2, 10, 1u128<<32, 1u128<<64, u128::MAX/2, u128::MAX-1] + )] +fn prop_minted_shares_le_accept_when_price_ge_one(accept: u128, supply: u128, assets_base: u128) { + let assets = assets_base.max(supply); // enforce price ≥ 1 + let minted = mul_div_floor(accept, supply, assets); + assert!( + minted <= accept, + "minted {minted} should be <= accept {accept} when price>=1 (S={supply}, A={assets})" + ); +} + +// Property: Fee shares are 0 when not profitable (cur_total_assets <= last_total_assets) +#[rstest( + perf => [0u128, W/100, W/10], + last => [0u128, 1u128, 1u128<<32], + ts => [0u128, 1u128, 1u128<<64] + )] +fn prop_fee_zero_when_not_profitable(perf: u128, last: u128, ts: u128) { + let cur_equal = last; + let cur_lower = last.saturating_sub(1); + assert_eq!(compute_fee_shares(cur_equal, last, perf, ts), 0); + assert_eq!(compute_fee_shares(cur_lower, last, perf, ts), 0); +} + +#[rstest( + s =>[0u128, 1, 13, 1<<32, 1<<64], + a =>[1u128, 7, 1<<32, 1<<64, (1u128<<64) + 123], + k =>[0u128, 1, 2, 10, 1<<16] + )] +fn deposit_is_monotone_in_assets(s: u128, a: u128, k: u128) { + // More assets never produce fewer shares (with fixed totals & offsets). + let shares1 = mul_div_floor(a, s + 1, a + k + 1); + let shares2 = mul_div_floor(a + 1, s + 1, a + k + 2); + assert!(shares2 >= shares1); +} + +// Property: Fee shares are monotone =>profit when fee>0 and total_supply>0 +#[rstest( + perf => [W/100, W/10], + last => [0u128, 1u128<<32], + ts => [1u128, 1u128<<64], + p1 => [0u128, 1u128, 1u128<<16], + p2 => [1u128, 1u128<<16, 1u128<<32] + )] +fn prop_fee_monotone_in_profit(perf: u128, last: u128, ts: u128, p1: u128, p2: u128) { + let p_low = p1.min(p2); + let p_high = p1.max(p2); + let s1 = compute_fee_shares(last.saturating_add(p_low), last, perf, ts); + let s2 = compute_fee_shares(last.saturating_add(p_high), last, perf, ts); + assert!( + s2 >= s1, + "fee shares should be monotone =>profit: s2 {s2} >= s1 {s1} (last={last}, perf={perf}, ts={ts})" + ); +} + +// Property: Withdrawal math never underflows: +// withdrawn = before - new (saturating) +// credited = min(withdrawn, need) +// remaining = rem - credited (saturating) +#[rstest( + before => [0u128, 1, 10, 1u128<<64, u128::MAX/2, u128::MAX-1], + newp => [0u128, 1, 10, 1u128<<64, u128::MAX/2], + need => [0u128, 1, 10, 1u128<<32, u128::MAX/4], + rem => [0u128, 1, 10, 1u128<<32, u128::MAX/4] + )] +fn prop_withdraw_math_never_underflows(before: u128, newp: u128, need: u128, rem: u128) { + let withdrawn = before.saturating_sub(newp); + let credited = core::cmp::min(withdrawn, need); + let remaining = rem.saturating_sub(credited); + assert!(withdrawn <= before, "withdrawn should not exceed before"); + assert!(credited <= need, "credited should be <= need"); + assert!(remaining <= rem, "remaining should not exceed rem"); +} + +#[rstest( + fee =>[0u128, W/100, W/10], + ts =>[0u128, 1, 1<<32, 1<<64], + last =>[0u128, 1, 1<<32], + profit =>[0u128, 1, 10, 1<<32] +)] +fn fee_shares_upper_bound_by_total_supply(fee: u128, ts: u128, last: u128, profit: u128) { + let cur = last.saturating_add(profit); + let minted = compute_fee_shares(cur, last, fee, ts); + assert!(minted <= ts || ts == 0); +} diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs new file mode 100644 index 00000000..7af1d68d --- /dev/null +++ b/contract/vault/tests/invariants.rs @@ -0,0 +1,34 @@ +// TODO: single-op state machine, all mutators must be idle +// TODO: every callback must be for the current op and market index + +// TODO: supply queue must never have duplicates + +// TODO: fee accruel must only happen when AUM grows + +// Allocations +// TODO: on allocation-failure, reconcile to idle +// TODO: allocation accounting: Accepted amount = new_principal - before &never more than attempted +// TODO: allocation attempts: any market that is enabled (new_principal > 0) must be in the withdraw queue + +// Withdraws +// TODO: try withdraw & idle first: idle balance can be utilised on a first-come-first-serve basis => it +// is **not** deducted until payout succeeds +// TODO: create withdraw: if create withdraw fails, skip to next market +// TODO: execute withdraw: if executing a withdrawal fails, assume nothing changed +// TODO: withdrawn(execute > read): withdrawn credits must increase idle balance +// TODO: withdraw queue must never have duplicates +// TODO: enabling a market (cap > 0) must add it to the withdraw queue + +// TODO: Skim: is no-op when balance is 0 + +// Payouts +// TODO: payout success: idle balance must decrease & burn escrowed shares +// TODO: payout failure: idle doesnt change & refund escrowed shares to original owner + +// TODO: stop and exit: must never mutiny funds or escrow + +// TODO: credit principal only after proper supply to marfket + +// TODO: Withdraw read onlky credits idle + +// TODO: on error, assume no risk From bf16fff777691753c186a47cf244b9844986afac Mon Sep 17 00:00:00 2001 From: Donovan Dall Date: Fri, 3 Oct 2025 13:32:07 +0100 Subject: [PATCH 002/121] feat: add vault configuration to test harness --- common/src/vault.rs | 20 +- .../market/tests/configuration_validation.rs | 96 ++++++--- contract/vault/src/lib.rs | 70 +++++-- contract/vault/tests/invariants.rs | 8 + test-utils/src/controller/market.rs | 2 +- test-utils/src/controller/mod.rs | 1 + test-utils/src/controller/vault.rs | 184 ++++++++++++++++++ test-utils/src/lib.rs | 90 ++++++++- 8 files changed, 414 insertions(+), 57 deletions(-) create mode 100644 test-utils/src/controller/vault.rs diff --git a/common/src/vault.rs b/common/src/vault.rs index c0d8fe8a..a1b8b4bc 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -1,6 +1,9 @@ use near_sdk::{json_types::U128, near, AccountId, Gas}; -use crate::supply::SupplyPosition; +use crate::{ + asset::{BorrowAsset, FungibleAsset}, + supply::SupplyPosition, +}; pub type TimestampNs = u64; @@ -32,6 +35,21 @@ pub struct MarketConfiguration { pub removable_at: TimestampNs, } +#[near(serializers = [json, borsh])] +pub struct VaultConfiguration { + pub owner_id: AccountId, + pub curator_id: AccountId, + pub guardian_id: AccountId, + pub underlying_token_id: FungibleAsset, + pub initial_timelock_sec: u32, + pub fee_recipient: AccountId, + pub skim_recipient: AccountId, + pub name: String, + pub symbol: String, + // TODO: decide if should assert decimals as underlying + pub decimals: u8, +} + #[near_sdk::ext_contract(ext_self)] pub trait Callbacks { fn after_supply_1_check(&mut self, op_id: u64, market_index: u32, attempted: U128) -> bool; diff --git a/contract/market/tests/configuration_validation.rs b/contract/market/tests/configuration_validation.rs index 937d0c82..7fb0f8e8 100644 --- a/contract/market/tests/configuration_validation.rs +++ b/contract/market/tests/configuration_validation.rs @@ -10,9 +10,13 @@ use templar_common::{ #[should_panic = "Smart contract panicked: Invalid configuration field `borrow_asset`: must not equal `collateral_asset`"] async fn borrow_asset_is_collateral_asset() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.borrow_asset = c.collateral_asset.clone().coerce(); - }) + setup_everything( + &worker, + |c| { + c.borrow_asset = c.collateral_asset.clone().coerce(); + }, + |_c| {}, + ) .await; } @@ -20,10 +24,14 @@ async fn borrow_asset_is_collateral_asset() { #[should_panic = "Smart contract panicked: Invalid configuration field `borrow_interest_rate_strategy`: out of bounds"] async fn borrow_interest_rate_strategy_exceed_apy_limit() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.borrow_interest_rate_strategy = - InterestRateStrategy::linear(dec!("0"), dec!("100001")).unwrap(); - }) + setup_everything( + &worker, + |c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(dec!("0"), dec!("100001")).unwrap(); + }, + |_c| {}, + ) .await; } @@ -31,9 +39,13 @@ async fn borrow_interest_rate_strategy_exceed_apy_limit() { #[should_panic = "Smart contract panicked: Invalid configuration field `borrow_mcr_maintenance`: out of bounds"] async fn borrow_mcr_maintenance_less_than_1() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.borrow_mcr_maintenance = dec!(".99"); - }) + setup_everything( + &worker, + |c| { + c.borrow_mcr_maintenance = dec!(".99"); + }, + |_c| {}, + ) .await; } @@ -41,10 +53,14 @@ async fn borrow_mcr_maintenance_less_than_1() { #[should_panic = "Smart contract panicked: Invalid configuration field `borrow_mcr_maintenance`: out of bounds"] async fn borrow_mcr_maintenance_less_than_borrow_mcr_liquidation() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.borrow_mcr_maintenance = dec!("1.2"); - c.borrow_mcr_liquidation = dec!("1.200000001"); - }) + setup_everything( + &worker, + |c| { + c.borrow_mcr_maintenance = dec!("1.2"); + c.borrow_mcr_liquidation = dec!("1.200000001"); + }, + |_c| {}, + ) .await; } @@ -52,9 +68,13 @@ async fn borrow_mcr_maintenance_less_than_borrow_mcr_liquidation() { #[should_panic = "Smart contract panicked: Invalid configuration field `borrow_mcr_liquidation`: out of bounds"] async fn borrow_mcr_liquidation_less_than_1() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.borrow_mcr_liquidation = dec!(".99"); - }) + setup_everything( + &worker, + |c| { + c.borrow_mcr_liquidation = dec!(".99"); + }, + |_c| {}, + ) .await; } @@ -62,9 +82,13 @@ async fn borrow_mcr_liquidation_less_than_1() { #[should_panic = "Smart contract panicked: Invalid configuration field `borrow_asset_maximum_usage_ratio`: out of bounds"] async fn borrow_asset_maximum_usage_ratio_is_zero() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.borrow_asset_maximum_usage_ratio = dec!("0"); - }) + setup_everything( + &worker, + |c| { + c.borrow_asset_maximum_usage_ratio = dec!("0"); + }, + |_c| {}, + ) .await; } @@ -72,9 +96,13 @@ async fn borrow_asset_maximum_usage_ratio_is_zero() { #[should_panic = "Smart contract panicked: Invalid configuration field `borrow_asset_maximum_usage_ratio`: out of bounds"] async fn borrow_asset_maximum_usage_ratio_greater_than_1() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.borrow_asset_maximum_usage_ratio = dec!("1.0001"); - }) + setup_everything( + &worker, + |c| { + c.borrow_asset_maximum_usage_ratio = dec!("1.0001"); + }, + |_c| {}, + ) .await; } @@ -82,10 +110,14 @@ async fn borrow_asset_maximum_usage_ratio_greater_than_1() { #[should_panic = "Smart contract panicked: Invalid configuration field `supply_withdrawal_range.minimum`: out of bounds"] async fn withdrawal_minimum_greater_than_supply_minimum() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.supply_range = (1, None).try_into().unwrap(); - c.supply_withdrawal_range = (2, None).try_into().unwrap(); - }) + setup_everything( + &worker, + |c| { + c.supply_range = (1, None).try_into().unwrap(); + c.supply_withdrawal_range = (2, None).try_into().unwrap(); + }, + |_c| {}, + ) .await; } @@ -109,8 +141,12 @@ async fn withdrawal_fee_greater_than_withdrawal_minimum() { #[should_panic = "Smart contract panicked: Invalid configuration field `liquidation_maximum_spread`: out of bounds"] async fn liquidation_maximum_spread_greater_than_1() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.liquidation_maximum_spread = dec!("2"); - }) + setup_everything( + &worker, + |c| { + c.liquidation_maximum_spread = dec!("2"); + }, + |_c| {}, + ) .await; } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index ab3c2eda..09e05010 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -8,7 +8,6 @@ use near_sdk::{ store::{IterableMap, LookupMap, Vector}, AccountId, BorshStorageKey, IntoStorageKey, NearToken, PanicOnDefault, Promise, PromiseOrValue, }; -use near_sdk_contract_tools::rbac::Rbac; use near_sdk_contract_tools::{ ft::{ nep141::GAS_FOR_FT_TRANSFER_CALL, ContractMetadata, FungibleToken, Nep141Controller, @@ -21,8 +20,8 @@ use near_sdk_contract_tools::{owner::Owner, rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ - ext_self, MarketConfiguration, PendingValue, TimestampNs, GAS_CB, GAS_XFER, MAX_QUEUE_LEN, - MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + ext_self, MarketConfiguration, PendingValue, TimestampNs, VaultConfiguration, GAS_CB, + GAS_XFER, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, }, }; pub use wad::*; @@ -175,19 +174,20 @@ impl Contract { /// - `fee_recipient`: account to receive performance fees. /// - `skim_recipient`: account to receive skimmed tokens. /// - `name`/`symbol`/`decimals`: metadata for the share token. - pub fn new( - owner_id: AccountId, - curator_id: AccountId, - guardian_id: AccountId, - underlying_token_id: FungibleAsset, - initial_timelock_sec: u32, - fee_recipient: AccountId, - skim_recipient: AccountId, - name: String, - symbol: String, - // TODO: decide if should assert decimals as underlying - decimals: u8, - ) -> Self { + pub fn new(configuration: VaultConfiguration) -> Self { + let VaultConfiguration { + owner, + curator, + guardian, + underlying_token, + initial_timelock_sec, + fee_recipient, + skim_recipient, + name, + symbol, + decimals, + } = configuration; + let timelock_ns = u64::from(initial_timelock_sec) * 1_000_000_000; assert!( (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&timelock_ns), @@ -216,7 +216,7 @@ impl Contract { let storage_usage_role = env::storage_usage(); let mut contract = Self { - underlying_asset: underlying_token_id, + underlying_asset: underlying_token, timelock_ns, performance_fee: Default::default(), fee_recipient, @@ -238,14 +238,42 @@ impl Contract { storage_usage_role, }; contract.set_metadata(&ContractMetadata::new(name, symbol, decimals)); - Owner::init(&mut contract, &owner_id); - Rbac::add_role(&mut contract, &curator_id, &Role::Curator); - Rbac::add_role(&mut contract, &curator_id, &Role::Allocator); - Rbac::add_role(&mut contract, &guardian_id, &Role::Guardian); + Owner::init(&mut contract, &owner); + Rbac::add_role(&mut contract, &curator, &Role::Curator); + Rbac::add_role(&mut contract, &curator, &Role::Allocator); + Rbac::add_role(&mut contract, &guardian, &Role::Guardian); contract } + pub fn get_configuration(&self) -> VaultConfiguration { + let timelock_sec = self.timelock_ns / 1_000_000_000; + VaultConfiguration { + owner: self.own_get_owner().expect("Owner not set"), + curator: Self::with_members_of(&Role::Curator, |members| { + assert!( + members.len() == 1, + "Invariant violation: Cannot Have more than 1 Curator" + ); + members.iter().next().expect("Curator not set").clone() + }), + guardian: Self::with_members_of(&Role::Guardian, |members| { + assert!( + members.len() == 1, + "Invariant violation: Cannot Have more than 1 Guardian" + ); + members.iter().next().expect("Guardian not set").clone() + }), + underlying_token: self.underlying_asset.clone(), + initial_timelock_sec: timelock_sec as u32, + fee_recipient: self.fee_recipient.clone(), + skim_recipient: self.skim_recipient.clone(), + name: self.get_metadata().name, + symbol: self.get_metadata().symbol, + decimals: self.get_metadata().decimals, + } + } + /// Sets the Curator account. Also grants/removes the Allocator role accordingly. pub fn set_curator(&mut self, account: AccountId) { Self::require_owner(); diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 7af1d68d..9cfaa37c 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -32,3 +32,11 @@ // TODO: Withdraw read onlky credits idle // TODO: on error, assume no risk +// +// +// + +// TODO: test harness +// We need: +// - market setup (using market::setup_test) +// - vault version of setup_test, utilising the market principal diff --git a/test-utils/src/controller/market.rs b/test-utils/src/controller/market.rs index eb6022a7..c27e4ed9 100644 --- a/test-utils/src/controller/market.rs +++ b/test-utils/src/controller/market.rs @@ -32,7 +32,7 @@ use super::{oracle::OracleController, token::TokenController, ContractController #[derive(Clone)] pub struct MarketController { - contract: Contract, + pub(crate) contract: Contract, } impl ContractController for MarketController { diff --git a/test-utils/src/controller/mod.rs b/test-utils/src/controller/mod.rs index 8d7e1cfa..d260af10 100644 --- a/test-utils/src/controller/mod.rs +++ b/test-utils/src/controller/mod.rs @@ -14,6 +14,7 @@ pub mod oracle; pub mod registry; pub mod storage_management; pub mod token; +pub mod vault; pub mod universal_account; pub trait ContractController { diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs new file mode 100644 index 00000000..fb09c5cb --- /dev/null +++ b/test-utils/src/controller/vault.rs @@ -0,0 +1,184 @@ +use super::ContractController; +use crate::{ + controller::storage_management::StorageManagementController, define, get_contract, + UnifiedMarketController, +}; +use near_sdk::{ + json_types::U128, + serde_json::{self, json}, + AccountId, NearToken, +}; +use near_workspaces::{ + network::Sandbox, result::ExecutionSuccess, types::SecretKey, Account, Contract, Worker, +}; +use std::ops::Deref; +use templar_common::vault::*; +use tokio::sync::OnceCell; + +#[derive(Clone)] +pub struct VaultController { + contract: Contract, +} + +impl ContractController for VaultController { + fn contract(&self) -> &Contract { + &self.contract + } +} + +impl StorageManagementController for VaultController {} + +impl VaultController { + pub async fn deploy(account: Account, configuration: &VaultConfiguration) -> Self { + let wasm = load_wasm().await; + let contract = account.deploy(wasm).await.unwrap().unwrap(); + + let init_call = contract + .call("new") + .args_json(json!({ + "configuration": configuration, + })) + .transact() + .await + .unwrap() + .unwrap(); + + eprintln!("Init call logs"); + eprintln!("--------------"); + for log in init_call.logs() { + eprintln!("\t{log}"); + } + eprintln!("--------------"); + + Self { contract } + } + + define! { + /* -------- Views -------- */ + #[view] pub fn get_configuration() -> VaultConfiguration; + #[view] pub fn get_fee_recipient() -> AccountId; + #[view] pub fn get_last_total_assets() -> U128; + #[view] pub fn get_total_assets() -> U128; + #[view] pub fn get_total_supply() -> U128; + #[view] pub fn get_idle_balance() -> U128; + #[view] pub fn get_op_state() -> OpState; + #[view] pub fn list_supply_queue(offset: Option, count: Option) -> Vec; + #[view] pub fn list_withdraw_queue(offset: Option, count: Option) -> Vec; + #[view] pub fn get_market_supply(market: &AccountId) -> U128; + #[view] pub fn get_next_op_id() -> u64; + + /* -------- Calls (externals) -------- */ + // Owner/guardian-gated: mints fee shares when performance is positive. + #[call(exec, tgas(20))] + pub fn accrue_fee["internal_accrue_fee"](); + + // Allocator/curator/owner-gated: begins allocation across markets. + #[call(exec, tgas(300))] + pub fn allocate["start_allocation"](amount: U128); + + // User withdrawal path; expects escrowed shares already held by the contract. + #[call(exec, tgas(300))] + pub fn withdraw["start_withdraw"](amount: U128, receiver: AccountId, owner: AccountId, escrow_shares: U128); + + /* -------- Promise callbacks (must be #[private] on-chain) -------- */ + // After attempting to supply into a market during allocation. + #[call(exec, tgas(50))] + pub fn after_supply_1_check(op_id: u64, index: u32, amount: U128); + + // After creating a withdrawal request on a market during withdrawal orchestration. + #[call(exec, tgas(50))] + pub fn after_create_withdraw_req(op_id: u64, index: u32, amount: U128); + + // After payout to the user completes. + #[call(exec, tgas(50))] + pub fn after_send_to_user(op_id: u64, receiver: AccountId, amount: U128); + } +} + +static WASM: OnceCell> = OnceCell::const_new(); + +pub async fn load_wasm() -> &'static [u8] { + WASM.get_or_init(|| get_contract("templar_vault_contract", "contract/vault")) + .await +} + +#[derive(Clone)] +pub struct UnifiedVaultController { + pub vault: VaultController, + pub configuration: VaultConfiguration, + pub market: UnifiedMarketController, +} + +impl Deref for UnifiedVaultController { + type Target = VaultController; + + fn deref(&self) -> &Self::Target { + &self.vault + } +} + +fn contract_with_dummy_sk(worker: &Worker, account_id: AccountId) -> Contract { + let dummy_key = SecretKey::from_seed(near_workspaces::types::KeyType::ED25519, ""); + + Contract::from_secret_key(account_id, dummy_key.clone(), worker) +} + +impl UnifiedVaultController { + pub async fn attach(worker: &Worker, market_id: AccountId) -> Self { + let vault = VaultController { + contract: contract_with_dummy_sk(worker, market_id.clone()), + }; + let market = UnifiedMarketController::attach(worker, market_id).await; + + let configuration = vault.get_configuration().await; + + Self { + vault, + configuration, + market, + } + } + + pub fn new( + vault: VaultController, + configuration: VaultConfiguration, + market: UnifiedMarketController, + ) -> Self { + Self { + vault, + configuration, + market, + } + } + + pub async fn init_account(&self, account: &Account) { + self.storage_deposits(account).await; + self.market.init_account(account).await; + } + + pub async fn storage_deposits(&self, account: &Account) { + eprintln!("Performing storage deposits for {}...", account.id()); + let bounds = self.vault.storage_balance_bounds().await; + + self.vault.storage_deposit(account, bounds.min).await; + self.market + .storage_deposit(account, NearToken::from_near(1)) + .await; + } + + pub async fn supply(&self, supply_user: &Account, amount: u128) -> ExecutionSuccess { + eprintln!( + "{} transferring {amount} tokens for supply...", + supply_user.id() + ); + self.market + .borrow_asset + .transfer_call( + supply_user, + self.vault.contract().id(), + amount, + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ) + .await + } +} diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index f9e183ab..4a96369d 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -28,7 +28,9 @@ use templar_common::{ number::Decimal, oracle::pyth::{self, PriceIdentifier}, registry::DeployMode, + vault::VaultConfiguration, }; +use crate::controller::vault::{UnifiedVaultController, VaultController}; pub const DEFAULT_COLLATERAL_PRICE_ID: PriceIdentifier = PriceIdentifier(hex_literal::hex!( "cccccccc232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588" @@ -83,14 +85,47 @@ macro_rules! accounts { macro_rules! setup_test { ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { $crate::accounts!($w, $($n),*); - let s = $crate::setup_everything(&$w, $f).await; + let s = $crate::setup_everything(&$w, $f, |_| {}).await; + ::tokio::join!( + $(s.c.init_account(&$n)),* + ); + let $crate::SetupEverything { $($e,)* .. } = s; + }; + ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr) vconfig($v:expr)) => { + $crate::accounts!($w, $($n),*); + let s = $crate::setup_everything(&$w, $f, $v).await; ::tokio::join!( $(s.c.init_account(&$n)),* ); let $crate::SetupEverything { $($e,)* .. } = s; }; ($w:ident extract($($e:ident),*) accounts($($n:ident),*)) => { - $crate::setup_test!($w extract($($e),*) accounts($($n),*) config(|_| {})) + $crate::setup_test_w!($w extract($($e),*) accounts($($n),*) config(|_| {}), vconfig(|_| {})) + }; +} + +#[macro_export] +macro_rules! setup_test { + (extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { + let worker = near_workspaces::sandbox().await.unwrap(); + $crate::accounts!(worker, $($n),*); + let s = $crate::setup_everything(&worker, $f, |_| {}).await; + ::tokio::join!( + $(s.c.init_account(&$n)),* + ); + let $crate::SetupEverything { $($e,)* .. } = s; + }; + (extract($($e:ident),*) accounts($($n:ident),*) config($f:expr) vconfig($v:expr)) => { + let worker = near_workspaces::sandbox().await.unwrap(); + $crate::accounts!(worker, $($n),*); + let s = $crate::setup_everything(&worker, $f, $v).await; + ::tokio::join!( + $(s.c.init_account(&$n)),* + ); + let $crate::SetupEverything { $($e,)* .. } = s; + }; + (extract($($e:ident),*) accounts($($n:ident),*)) => { + $crate::setup_test!(extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})) }; } @@ -135,6 +170,28 @@ pub fn market_configuration( } } +pub fn vault_configuration( + owner_id: AccountId, + curator_id: AccountId, + guardian_id: AccountId, + borrow_asset_id: AccountId, + skim_recipient_id: AccountId, + fee_recipient_id: AccountId, +) -> VaultConfiguration { + VaultConfiguration { + owner: owner_id, + curator: curator_id, + guardian: guardian_id, + underlying_token: FungibleAsset::nep141(borrow_asset_id), + initial_timelock_sec: 0, + fee_recipient: fee_recipient_id, + skim_recipient: skim_recipient_id, + name: "Vault".to_string(), + symbol: "VAULT".to_string(), + decimals: 24, + } +} + async fn compile_contract(p: &str) -> Vec { let path = Path::new(env!("CARGO_WORKSPACE_DIR")).join(p); near_workspaces::compile_project(path.to_str().unwrap()) @@ -163,11 +220,13 @@ pub struct SetupEverything { pub c: UnifiedMarketController, pub protocol_yield_user: Account, pub insurance_yield_user: Account, + pub vault: UnifiedVaultController, } pub async fn setup_everything( worker: &Worker, customize_market_configuration: impl FnOnce(&mut MarketConfiguration), + customize_vault_configuration: impl FnOnce(&mut VaultConfiguration), ) -> SetupEverything { accounts!( worker, @@ -176,7 +235,13 @@ pub async fn setup_everything( insurance_yield_user, collateral_asset, borrow_asset, - price_oracle + price_oracle, + vault, + vault_owner, + vault_curator, + vault_guardian, + skim_recipient, + fee_recipient ); let mut config = market_configuration( price_oracle.id().clone(), @@ -189,7 +254,17 @@ pub async fn setup_everything( ); customize_market_configuration(&mut config); - let (market, price_oracle, borrow_asset, collateral_asset) = tokio::join!( + let mut vault_config = vault_configuration( + vault_owner.id().clone(), + vault_curator.id().clone(), + vault_guardian.id().clone(), + borrow_asset.id().clone(), + skim_recipient.id().clone(), + fee_recipient.id().clone(), + ); + customize_vault_configuration(&mut vault_config); + + let (market, price_oracle, borrow_asset, collateral_asset, vault) = tokio::join!( MarketController::deploy(market, &config), OracleController::deploy(price_oracle), async { @@ -221,6 +296,7 @@ pub async fn setup_everything( } } }, + VaultController::deploy(vault, &vault_config) ); let c = @@ -229,17 +305,23 @@ pub async fn setup_everything( c.set_borrow_asset_price(1.0).await; c.set_collateral_asset_price(1.0).await; + let v = UnifiedVaultController::new(vault, vault_config, c.clone()); + // Asset opt-ins. tokio::join!( c.storage_deposits(c.market.contract().as_account()), c.init_account(&protocol_yield_user), c.init_account(&insurance_yield_user), + v.storage_deposits(v.vault.contract().as_account()), + v.init_account(&skim_recipient), + v.init_account(&fee_recipient) ); SetupEverything { c, protocol_yield_user, insurance_yield_user, + vault: v, } } From 2caf5fc2d14d8af3dd71989322ca9660c41f1959 Mon Sep 17 00:00:00 2001 From: Donovan Dall Date: Mon, 6 Oct 2025 10:52:19 +0100 Subject: [PATCH 003/121] chore: move opstate --- common/src/vault.rs | 62 +++++++++++++++++++++-- contract/vault/src/impl_token_receiver.rs | 2 + contract/vault/src/lib.rs | 57 ++------------------- 3 files changed, 63 insertions(+), 58 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index a1b8b4bc..37329fc0 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -16,6 +16,9 @@ pub const MIN_TIMELOCK_NS: u64 = 86_400_000_000_000; // 1 day pub const MAX_TIMELOCK_NS: u64 = 30 * 86_400_000_000_000; // 30 days pub const MAX_QUEUE_LEN: usize = 64; +pub type ExpectedIdx = u32; +pub type ActualIdx = u32; + /// Parsed from the string parameter `msg` passed by `*_transfer_call` to /// `*_on_transfer` calls. #[near(serializers = [json])] @@ -35,12 +38,13 @@ pub struct MarketConfiguration { pub removable_at: TimestampNs, } +#[derive(Clone)] #[near(serializers = [json, borsh])] pub struct VaultConfiguration { - pub owner_id: AccountId, - pub curator_id: AccountId, - pub guardian_id: AccountId, - pub underlying_token_id: FungibleAsset, + pub owner: AccountId, + pub curator: AccountId, + pub guardian: AccountId, + pub underlying_token: FungibleAsset, pub initial_timelock_sec: u32, pub fee_recipient: AccountId, pub skim_recipient: AccountId, @@ -77,3 +81,53 @@ pub struct PendingValue { // Timestamp when this pending value can be finalized pub valid_at: TimestampNs, } + +#[derive(Debug, Clone)] +#[near(serializers = [json, borsh])] +/// Operation state machine for asynchronous allocation, withdrawal, and payout flows. +pub enum OpState { + Idle, + Allocating { + op_id: u64, + index: u32, + remaining: u128, + }, + Withdrawing { + op_id: u64, + index: u32, + remaining: u128, + collected: u128, + receiver: AccountId, + owner: AccountId, + escrow_shares: u128, + }, + Payout { + op_id: u64, + receiver: AccountId, + amount: u128, + owner: AccountId, + escrow_shares: u128, + }, +} + +#[derive(Debug)] +#[near(serializers = [json])] +pub enum Error { + // Invariant: Index drift or stale op_id results in a graceful stop + IndexDrifted(ExpectedIdx, ActualIdx), + // Invariant: Attempting to work on a market that is missing from the withdraw queue + MissingMarket(u32), + NotWithdrawing(OpState), + NotAllocating(OpState), + MarketTransferFailed, + MissingSupplyPosition, + PositionReadFailed, + // Invariant: Insufficient liquidity across all markets to satisfy withdrawal + InsufficientLiquidity, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 378fc83a..2584e8ab 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -72,6 +72,8 @@ impl Nep245Receiver for Contract { DepositMsg::Supply => { let refund = self.execute_supply( sender_id.clone(), + // FIXME: this is incorrect, we should abstract this into the underlying to + // determine the kind. token_id .parse() .unwrap_or_else(|_| env::panic_str("Invalid token ID")), diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 09e05010..4a5213d1 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -17,11 +17,12 @@ use near_sdk_contract_tools::{ Owner, Rbac, }; use near_sdk_contract_tools::{owner::Owner, rbac}; +use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ - ext_self, MarketConfiguration, PendingValue, TimestampNs, VaultConfiguration, GAS_CB, - GAS_XFER, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + ext_self, Error, MarketConfiguration, OpState, PendingValue, TimestampNs, + VaultConfiguration, GAS_CB, GAS_XFER, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, }, }; pub use wad::*; @@ -33,33 +34,6 @@ pub mod wad; #[derive(Debug, Clone)] #[near(serializers = [json, borsh])] -/// Operation state machine for asynchronous allocation, withdrawal, and payout flows. -pub enum OpState { - Idle, - Allocating { - op_id: u64, - index: u32, - remaining: u128, - }, - Withdrawing { - op_id: u64, - index: u32, - remaining: u128, - collected: u128, - receiver: AccountId, - owner: AccountId, - escrow_shares: u128, - }, - Payout { - op_id: u64, - receiver: AccountId, - amount: u128, - owner: AccountId, - escrow_shares: u128, - }, -} - -#[near] #[derive(BorshStorageKey)] /// Internal storage keys used by persistent collections. pub enum StorageKey { @@ -85,31 +59,6 @@ pub enum Role { Allocator, } -type ExpectedIdx = u32; -type ActualIdx = u32; - -#[derive(Debug)] -#[near(serializers = [json])] -pub enum Error { - // Invariant: Index drift or stale op_id results in a graceful stop - IndexDrifted(ExpectedIdx, ActualIdx), - // Invariant: Attempting to work on a market that is missing from the withdraw queue - MissingMarket(u32), - NotWithdrawing(OpState), - NotAllocating(OpState), - MarketTransferFailed, - MissingSupplyPosition, - PositionReadFailed, - // Invariant: Insufficient liquidity across all markets to satisfy withdrawal - InsufficientLiquidity, -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:?}") - } -} - #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] // FIXME: #[nep145(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] From b6a5d3ca46fd28e8640fb8b0aae55c95d44979cf Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 004/121] test: init storage for the accounts properly in the test harness --- test-utils/src/controller/vault.rs | 21 ++++++++++++++++++--- test-utils/src/lib.rs | 14 +++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index fb09c5cb..2ccc906d 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -59,6 +59,7 @@ impl VaultController { #[view] pub fn get_fee_recipient() -> AccountId; #[view] pub fn get_last_total_assets() -> U128; #[view] pub fn get_total_assets() -> U128; + #[view] pub fn get_max_deposit() -> U128; #[view] pub fn get_total_supply() -> U128; #[view] pub fn get_idle_balance() -> U128; #[view] pub fn get_op_state() -> OpState; @@ -66,6 +67,12 @@ impl VaultController { #[view] pub fn list_withdraw_queue(offset: Option, count: Option) -> Vec; #[view] pub fn get_market_supply(market: &AccountId) -> U128; #[view] pub fn get_next_op_id() -> u64; + #[view] pub fn convert_to_shares(assets: U128) -> U128; + #[view] pub fn convert_to_assets(shares: U128) -> U128; + #[view] pub fn preview_mint(shares: U128) -> U128; + #[view] pub fn preview_deposit(assets: U128) -> U128; + #[view] pub fn preview_withdraw(assets: U128) -> U128; + #[view] pub fn preview_redeem(shares: U128) -> U128; /* -------- Calls (externals) -------- */ // Owner/guardian-gated: mints fee shares when performance is positive. @@ -80,6 +87,15 @@ impl VaultController { #[call(exec, tgas(300))] pub fn withdraw["start_withdraw"](amount: U128, receiver: AccountId, owner: AccountId, escrow_shares: U128); + // User redemption path; expects escrowed shares already held by the contract. + #[call(exec, tgas(300))] + pub fn redeem["start_redeem"](shares: U128, receiver: AccountId, owner: AccountId, escrow_shares: U128); + + #[call(exec, tgas(50))] + pub fn skim["skim"](token: AccountId); + + // TODO: caps? + /* -------- Promise callbacks (must be #[private] on-chain) -------- */ // After attempting to supply into a market during allocation. #[call(exec, tgas(50))] @@ -161,9 +177,8 @@ impl UnifiedVaultController { let bounds = self.vault.storage_balance_bounds().await; self.vault.storage_deposit(account, bounds.min).await; - self.market - .storage_deposit(account, NearToken::from_near(1)) - .await; + self.market.storage_deposits(account).await; + // FIXME: we should set the queue for this too! } pub async fn supply(&self, supply_user: &Account, amount: u128) -> ExecutionSuccess { diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 4a96369d..3a5fbef0 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -87,7 +87,7 @@ macro_rules! setup_test { $crate::accounts!($w, $($n),*); let s = $crate::setup_everything(&$w, $f, |_| {}).await; ::tokio::join!( - $(s.c.init_account(&$n)),* + $(s.vault.init_account(&$n)),* ); let $crate::SetupEverything { $($e,)* .. } = s; }; @@ -95,7 +95,7 @@ macro_rules! setup_test { $crate::accounts!($w, $($n),*); let s = $crate::setup_everything(&$w, $f, $v).await; ::tokio::join!( - $(s.c.init_account(&$n)),* + $(s.vault.init_account(&$n)),* ); let $crate::SetupEverything { $($e,)* .. } = s; }; @@ -111,7 +111,7 @@ macro_rules! setup_test { $crate::accounts!(worker, $($n),*); let s = $crate::setup_everything(&worker, $f, |_| {}).await; ::tokio::join!( - $(s.c.init_account(&$n)),* + $(s.vault.init_account(&$n)),* ); let $crate::SetupEverything { $($e,)* .. } = s; }; @@ -120,7 +120,7 @@ macro_rules! setup_test { $crate::accounts!(worker, $($n),*); let s = $crate::setup_everything(&worker, $f, $v).await; ::tokio::join!( - $(s.c.init_account(&$n)),* + $(s.vault.init_account(&$n)),* ); let $crate::SetupEverything { $($e,)* .. } = s; }; @@ -183,7 +183,7 @@ pub fn vault_configuration( curator: curator_id, guardian: guardian_id, underlying_token: FungibleAsset::nep141(borrow_asset_id), - initial_timelock_sec: 0, + initial_timelock_sec: (templar_common::vault::MAX_TIMELOCK_NS / 1_000_000_000) as u32, fee_recipient: fee_recipient_id, skim_recipient: skim_recipient_id, name: "Vault".to_string(), @@ -313,8 +313,8 @@ pub async fn setup_everything( c.init_account(&protocol_yield_user), c.init_account(&insurance_yield_user), v.storage_deposits(v.vault.contract().as_account()), - v.init_account(&skim_recipient), - v.init_account(&fee_recipient) + v.storage_deposits(&skim_recipient), + v.storage_deposits(&fee_recipient), ); SetupEverything { From 12d579225aa30f8517b4b50d4d0c37ae9acfe11f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 005/121] chore: expose some getters --- contract/vault/src/impl_token_receiver.rs | 2 +- contract/vault/src/lib.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 2584e8ab..8ec12fea 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -106,7 +106,7 @@ impl Contract { self.internal_accrue_fee(); - let max = self.max_deposit().0; + let max = self.get_max_deposit().0; let accept = deposit.min(max); let refund = deposit - accept; diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 4a5213d1..0ec09240 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -656,7 +656,7 @@ impl Contract { /* ----- Views ----- */ /// Returns total assets under management = idle balance + sum of market principals. - pub fn total_assets(&self) -> U128 { + pub fn get_total_assets(&self) -> U128 { // TODO: join let mut sum = self.idle_balance; self.withdraw_queue.iter().for_each(|m| { @@ -666,7 +666,7 @@ impl Contract { } /// Returns the maximum additional amount that can be deposited across all markets given current caps. - pub fn max_deposit(&self) -> U128 { + pub fn get_max_deposit(&self) -> U128 { // TODO: join let mut total = 0u128; self.supply_queue.iter().for_each(|m| { @@ -686,7 +686,7 @@ impl Contract { /// - Include fee shares that would be minted if fees accrued now. /// - Apply virtual offsets: +virtual_shares to supply and +virtual_assets to assets. fn effective_totals_fee_aware(&self) -> (u128, u128) { - let cur = self.total_assets().0; + let cur = self.get_total_assets().0; let ts = self.total_supply(); let fee_shares = crate::wad::compute_fee_shares(cur, self.last_total_assets, self.performance_fee, ts); @@ -828,7 +828,7 @@ impl Contract { 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.total_assets().0; + let cur = self.get_total_assets().0; let fee_shares = crate::wad::compute_fee_shares( cur, self.last_total_assets, From 7f13178ad6828142e4c0974cc2fc95217e2d8249 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 006/121] feat!: introduce lazy/eager allocations BREAKING CHANGE: we previously only eagerly allocated, the new approach is to allocate in a lazy-fashion, unless the vault is a simple vault. --- common/src/vault.rs | 28 +++++ contract/vault/src/impl_callbacks.rs | 27 +++-- contract/vault/src/impl_token_receiver.rs | 6 +- contract/vault/src/lib.rs | 124 +++++++++++++++++++++- test-utils/src/controller/vault.rs | 2 +- test-utils/src/lib.rs | 1 + 6 files changed, 172 insertions(+), 16 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 37329fc0..bc933975 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -18,7 +18,34 @@ pub const MAX_QUEUE_LEN: usize = 64; pub type ExpectedIdx = u32; pub type ActualIdx = u32; +pub type AllocationWeights = Vec<(AccountId, u128)>; +pub type AllocationPlan = Vec<(AccountId, u128)>; +#[derive(Clone, Debug)] +#[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 }, + Lazy, +} /// Parsed from the string parameter `msg` passed by `*_transfer_call` to /// `*_on_transfer` calls. #[near(serializers = [json])] @@ -41,6 +68,7 @@ pub struct MarketConfiguration { #[derive(Clone)] #[near(serializers = [json, borsh])] pub struct VaultConfiguration { + pub mode: AllocationMode, pub owner: AccountId, pub curator: AccountId, pub guardian: AccountId, diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 51e9b90d..8e9fce53 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -1,6 +1,8 @@ use std::fmt::Display; -use crate::{Contract, ContractExt, Error, GAS_CB, GAS_XFER, Nep141Controller, OpState, ext_self, near}; +use crate::{ + ext_self, near, Contract, ContractExt, Error, Nep141Controller, OpState, GAS_CB, GAS_XFER, +}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, json_types::U128, serde_json, AccountId, Gas, NearToken, Promise, PromiseError, @@ -166,16 +168,18 @@ impl Contract { return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); }; - if let Ok(()) = did_create { PromiseOrValue::Promise( - ext_market::ext(market.clone()) - .with_static_gas(GAS_XFER) - .execute_next_supply_withdrawal_request() - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) - .after_exec_withdraw_req(op_id, market_index, need), - ), - ) } else { + if let Ok(()) = did_create { + PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(GAS_XFER) + .execute_next_supply_withdrawal_request() + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_exec_withdraw_req(op_id, market_index, need), + ), + ) + } else { env::log_str("create_supply_withdrawal_request failed; moving to next market"); self.op_state = OpState::Withdrawing { op_id, @@ -413,6 +417,7 @@ impl Contract { self.idle_balance = self.idle_balance.saturating_add(*remaining); } } + self.plan = None; self.op_state = OpState::Idle; } diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 8ec12fea..475a37d6 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -1,7 +1,7 @@ use crate::{aux::ReturnStyle, Contract, ContractExt, OpState}; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; -use templar_common::vault::DepositMsg; +use templar_common::vault::{AllocationMode, DepositMsg}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; @@ -116,7 +116,9 @@ impl Contract { self.idle_balance = self.idle_balance.saturating_add(accept); self.last_total_assets = self.last_total_assets.saturating_add(accept); - if matches!(self.op_state, OpState::Idle) { + if matches!(self.op_state, OpState::Idle) + && matches!(self.mode, AllocationMode::Eager { min_batch } if self.idle_balance >= min_batch) + { // Invariant: no overlapping operations env::log_str("Starting allocation"); self.start_allocation(self.idle_balance); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 0ec09240..8683f1bb 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -21,8 +21,9 @@ use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ - ext_self, Error, MarketConfiguration, OpState, PendingValue, TimestampNs, - VaultConfiguration, GAS_CB, GAS_XFER, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + ext_self, AllocationMode, AllocationPlan, AllocationWeights, Error, MarketConfiguration, + OpState, PendingValue, TimestampNs, VaultConfiguration, GAS_CB, GAS_XFER, MAX_QUEUE_LEN, + MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, }, }; pub use wad::*; @@ -66,6 +67,9 @@ pub enum Role { /// Vault contract that issues shares over an underlying fungible asset and allocates liquidity /// across configured markets. Implements 4626-like deposit/withdraw semantics. pub struct Contract { + mode: AllocationMode, + plan: Option, + underlying_asset: FungibleAsset, /// configuration per market (market ID -> MarketConfig) config: IterableMap, @@ -135,6 +139,7 @@ impl Contract { name, symbol, decimals, + mode, } = configuration; let timelock_ns = u64::from(initial_timelock_sec) * 1_000_000_000; @@ -185,6 +190,8 @@ impl Contract { next_op_id: 1, storage_usage_supply, storage_usage_role, + mode, + plan: None, }; contract.set_metadata(&ContractMetadata::new(name, symbol, decimals)); Owner::init(&mut contract, &owner); @@ -872,6 +879,75 @@ impl Contract { } } + pub fn reallocate( + &mut self, + weights: AllocationWeights, + amount: Option, + ) -> PromiseOrValue<()> { + Self::assert_allocator(); + self.ensure_idle(); + + if weights.is_empty() { + return self.start_allocation(amount.map(|x| x.0).unwrap_or(self.idle_balance)); + } + + // Validate unique markets + let mut seen = std::collections::HashSet::new(); + let mut sum_w: u128 = 0; + + for (m, w) in &weights { + if !seen.insert(m.clone()) { + env::panic_str(&format!("Duplicate market in weights: {m}")); + } + sum_w = sum_w.saturating_add(u128::from(*w)); + } + if sum_w == 0 { + env::panic_str("Sum of weights is zero"); + } + + // Compute total amount to allocate: clamp to idle and available room + let requested: u128 = amount.map(|x| x.0).unwrap_or(self.idle_balance); + let max_room = self.get_max_deposit().0; + let total = requested.min(self.idle_balance).min(max_room); + if total == 0 { + env::panic_str("No funds to allocate"); + } + + // Build planned allocations, applying cap room + let mut plan: AllocationPlan = Vec::with_capacity(weights.len()); + let mut total_planned: u128 = 0; + + for (market, w) in weights { + let weight_u128 = u128::from(w); + + // Room = cap - current + let cap = self.config.get(&market).map_or(0u128, |c| c.cap); + if cap == 0 { + plan.push((market, 0)); + continue; + } + let cur = *self.market_supply.get(&market).unwrap_or(&0u128); + let room = cap.saturating_sub(cur); + if room == 0 { + plan.push((market, 0)); + continue; + } + + let alloc = crate::wad::mul_div_floor(total, weight_u128, sum_w); + let planned = alloc.min(room); + + total_planned = total_planned.saturating_add(planned); + plan.push((market, planned)); + } + + if total_planned == 0 { + env::panic_str("No funds to allocate after cap"); + } + + self.plan = Some(plan); + self.start_allocation(total_planned) + } + fn start_allocation(&mut self, amount: u128) -> PromiseOrValue<()> { if amount == 0 { return PromiseOrValue::Value(()); @@ -900,6 +976,50 @@ impl Contract { if remaining == 0 { return self.stop_and_exit::(None); } + + // If a per-op allocation plan exists, use it; otherwise, fall back to supply_queue order. + if let Some(plan) = &self.plan { + let idx = index as usize; + if let Some((market, planned_amount)) = plan.get(idx) { + let market_id = market.clone(); + + let cap = self.config.get(&market_id).map_or(0, |c| c.cap); + let cur = *self.market_supply.get(&market_id).unwrap_or(&0); + let room = cap.saturating_sub(cur); + let to_supply = room.min(remaining).min(*planned_amount); + + if to_supply == 0 { + self.op_state = OpState::Allocating { + op_id, + index: index + 1, + remaining, + }; + return self.step_allocation(); + } + + return PromiseOrValue::Promise( + self.underlying_asset + .transfer_call( + &market_id, + U128(to_supply).into(), + Some( + #[allow(clippy::expect_used, reason = "Infallible")] + serde_json::to_string(&templar_common::market::DepositMsg::Supply) + .expect("Infallible serialisation of supply enum") + .as_str(), + ), + ) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_CB) + .after_supply_1_check(op_id, index, U128(to_supply)), + ), + ); + } + // Plan exhausted; stop and reconcile remaining in stop_and_exit + return self.stop_and_exit::(None); + } + if let Some(market) = self.supply_queue.get(index) { let cap = self.config.get(market).map_or(0, |c| c.cap); let cur = self.market_supply.get(market).unwrap_or(&0); diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 2ccc906d..04eb25a4 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -81,7 +81,7 @@ impl VaultController { // Allocator/curator/owner-gated: begins allocation across markets. #[call(exec, tgas(300))] - pub fn allocate["start_allocation"](amount: U128); + pub fn reallocate["start_allocation"](weights: AllocationPlan, amount: Option); // User withdrawal path; expects escrowed shares already held by the contract. #[call(exec, tgas(300))] diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 3a5fbef0..a20af4ac 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -189,6 +189,7 @@ pub fn vault_configuration( name: "Vault".to_string(), symbol: "VAULT".to_string(), decimals: 24, + mode: templar_common::vault::AllocationMode::Lazy, } } From f92028763ece9dbec54ff70ebf16fcc5a3629037 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 007/121] feat: implement plan-based weighted allocation for per-op reallocate --- contract/vault/src/impl_callbacks.rs | 34 ++++++++++++--- contract/vault/src/lib.rs | 62 ++++++++++++---------------- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 8e9fce53..ee96a8b7 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -30,8 +30,19 @@ impl Contract { _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), } - let Some(market) = self.supply_queue.get(market_index) else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + // Resolve market by plan (if present) or supply_queue + let market: AccountId = if let Some(plan) = &self.plan { + if let Some((m, _)) = plan.get(market_index as usize) { + m.clone() + } else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + } + } else { + if let Some(m) = self.supply_queue.get(market_index) { + m.clone() + } else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + } }; // If the transfer failed, do not attempt to reconcile; stop and leave remaining untouched @@ -43,7 +54,7 @@ impl Contract { return self.stop_and_exit(Some(&Error::MarketTransferFailed)); } - let before = self.market_supply.get(market).unwrap_or(&0); + let before = self.market_supply.get(&market).unwrap_or(&0); let fetch_pos = ext_market::ext(market.clone()) .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) @@ -89,8 +100,19 @@ impl Contract { return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); } - let Some(market) = self.supply_queue.get(market_index) else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + // Resolve market by plan (if present) or supply_queue + let market: AccountId = if let Some(plan) = &self.plan { + if let Some((m, _)) = plan.get(market_index as usize) { + m.clone() + } else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + } + } else { + if let Some(m) = self.supply_queue.get(market_index) { + m.clone() + } else { + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + } }; let (new_principal, remaining_next) = match position { @@ -118,7 +140,7 @@ impl Contract { self.market_supply.insert(market.clone(), new_principal); // Invariant: withdraw_queue gains any market with new_principal > 0 - if new_principal > 0 && !self.withdraw_queue.iter().any(|m| m == market) { + if new_principal > 0 && !self.withdraw_queue.iter().any(|m| m == &market) { self.withdraw_queue.push(market.clone()); } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 8683f1bb..11cead7a 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -227,6 +227,7 @@ impl Contract { name: self.get_metadata().name, symbol: self.get_metadata().symbol, decimals: self.get_metadata().decimals, + mode: self.mode.clone(), } } @@ -887,11 +888,12 @@ impl Contract { Self::assert_allocator(); self.ensure_idle(); + // If no weights provided, just push the requested or all idle via queue order. if weights.is_empty() { return self.start_allocation(amount.map(|x| x.0).unwrap_or(self.idle_balance)); } - // Validate unique markets + // Validate unique markets and accumulate weight sum let mut seen = std::collections::HashSet::new(); let mut sum_w: u128 = 0; @@ -905,7 +907,7 @@ impl Contract { env::panic_str("Sum of weights is zero"); } - // Compute total amount to allocate: clamp to idle and available room + // Clamp total allocation by idle balance and aggregate room let requested: u128 = amount.map(|x| x.0).unwrap_or(self.idle_balance); let max_room = self.get_max_deposit().0; let total = requested.min(self.idle_balance).min(max_room); @@ -913,39 +915,14 @@ impl Contract { env::panic_str("No funds to allocate"); } - // Build planned allocations, applying cap room - let mut plan: AllocationPlan = Vec::with_capacity(weights.len()); - let mut total_planned: u128 = 0; - - for (market, w) in weights { - let weight_u128 = u128::from(w); - - // Room = cap - current - let cap = self.config.get(&market).map_or(0u128, |c| c.cap); - if cap == 0 { - plan.push((market, 0)); - continue; - } - let cur = *self.market_supply.get(&market).unwrap_or(&0u128); - let room = cap.saturating_sub(cur); - if room == 0 { - plan.push((market, 0)); - continue; - } - - let alloc = crate::wad::mul_div_floor(total, weight_u128, sum_w); - let planned = alloc.min(room); - - total_planned = total_planned.saturating_add(planned); - plan.push((market, planned)); - } - - if total_planned == 0 { - env::panic_str("No funds to allocate after cap"); - } + // Store an ephemeral plan of (market, weight) to drive weighted allocation. + let plan: AllocationPlan = weights + .into_iter() + .map(|(m, w)| (m, u128::from(w))) + .collect(); self.plan = Some(plan); - self.start_allocation(total_planned) + self.start_allocation(total) } fn start_allocation(&mut self, amount: u128) -> PromiseOrValue<()> { @@ -977,16 +954,29 @@ impl Contract { return self.stop_and_exit::(None); } - // If a per-op allocation plan exists, use it; otherwise, fall back to supply_queue order. + // If a per-op allocation plan exists, use it as weighted priority; otherwise, fall back to supply_queue order. if let Some(plan) = &self.plan { let idx = index as usize; - if let Some((market, planned_amount)) = plan.get(idx) { + 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); + } + + // 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 { + crate::wad::mul_div_floor(remaining, *weight, sum_w) + }; + let cap = self.config.get(&market_id).map_or(0, |c| c.cap); let cur = *self.market_supply.get(&market_id).unwrap_or(&0); let room = cap.saturating_sub(cur); - let to_supply = room.min(remaining).min(*planned_amount); + let to_supply = room.min(target); if to_supply == 0 { self.op_state = OpState::Allocating { From f7185075dc117e98c0c8bfd9167e8c4053f62c52 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 008/121] feat: add vault events, allocate flow, and withdraw/skim support --- common/src/vault.rs | 209 ++++++++++ contract/vault/src/impl_callbacks.rs | 195 ++++++--- contract/vault/src/impl_token_receiver.rs | 30 +- contract/vault/src/lib.rs | 463 ++++++++++++++-------- test-utils/src/controller/vault.rs | 64 ++- 5 files changed, 722 insertions(+), 239 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index bc933975..f0c12779 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -152,6 +152,7 @@ pub enum Error { PositionReadFailed, // Invariant: Insufficient liquidity across all markets to satisfy withdrawal InsufficientLiquidity, + ZeroAmount, } impl std::fmt::Display for Error { @@ -159,3 +160,211 @@ impl std::fmt::Display for Error { write!(f, "{self:?}") } } + +#[near(event_json(standard = "templar-vault"))] +pub enum Event { + #[event_version("1.0.0")] + MintedShares { amount: U128, receiver: AccountId }, + #[event_version("1.0.0")] + AllocationStarted { op_id: u64, remaining: U128 }, + + // Allocation lifecycle (plan/request) + #[event_version("1.0.0")] + AllocationRequestedQueue { op_id: u64, total: U128 }, + #[event_version("1.0.0")] + AllocationRequestedWeighted { + op_id: u64, + total: U128, + weights: Vec<(AccountId, U128)>, + }, + #[event_version("1.0.0")] + AllocationPlanSet { + op_id: u64, + plan: Vec<(AccountId, U128)>, + }, + + // Per-step planning and outcomes + #[event_version("1.0.0")] + AllocationStepPlanned { + op_id: u64, + index: u32, + market: AccountId, + target: U128, + room: U128, + 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, + }, + #[event_version("1.0.0")] + AllocationTransferFailed { + op_id: u64, + index: u32, + market: AccountId, + attempted: U128, + }, + #[event_version("1.0.0")] + AllocationStepSettled { + op_id: u64, + index: u32, + market: AccountId, + before: U128, + new_principal: U128, + accepted: U128, + attempted: U128, + refunded: U128, + remaining_after: U128, + }, + + // Completion and stop + #[event_version("1.0.0")] + AllocationCompleted { op_id: u64 }, + #[event_version("1.0.0")] + AllocationStopped { + op_id: u64, + index: u32, + remaining: U128, + reason: Option, + }, + + // Eager + #[event_version("1.0.0")] + AllocationEagerTriggered { + op_id: u64, + idle_balance: U128, + min_batch: U128, + deposit_accepted: U128, + }, + + // Admin and configuration events + #[event_version("1.0.0")] + CuratorSet { account: AccountId }, + #[event_version("1.0.0")] + AllocatorRoleSet { account: AccountId, allowed: bool }, + #[event_version("1.0.0")] + SkimRecipientSet { account: AccountId }, + #[event_version("1.0.0")] + FeeRecipientSet { account: AccountId }, + #[event_version("1.0.0")] + PerformanceFeeSet { fee: U128 }, + + #[event_version("1.0.0")] + TimelockSet { seconds: u32 }, + #[event_version("1.0.0")] + TimelockChangeSubmitted { new_seconds: u32, valid_at: u64 }, + #[event_version("1.0.0")] + PendingTimelockRevoked {}, + + // Market and queue management + #[event_version("1.0.0")] + MarketCreated { market: AccountId }, + #[event_version("1.0.0")] + SupplyCapRaiseSubmitted { + market: AccountId, + new_cap: U128, + valid_at: u64, + }, + #[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")] + MarketRemovalSubmitted { + market: AccountId, + removable_at: u64, + }, + #[event_version("1.0.0")] + MarketRemovalRevoked { market: AccountId }, + #[event_version("1.0.0")] + WithdrawQueueUpdated { markets: Vec }, + + // User flows + #[event_version("1.0.0")] + RedeemRequested { + shares: U128, + estimated_assets: U128, + }, + + // Allocation read/settlement diagnostics + #[event_version("1.0.0")] + AllocationPositionMissing { + op_id: u64, + index: u32, + market: AccountId, + attempted: U128, + refunded: U128, + }, + #[event_version("1.0.0")] + AllocationPositionReadFailed { + op_id: u64, + index: u32, + market: AccountId, + attempted: U128, + refunded: U128, + }, + + // Withdrawal read diagnostics + #[event_version("1.0.0")] + WithdrawalPositionMissing { + op_id: u64, + market: AccountId, + index: u32, + before: U128, + need: U128, + }, + #[event_version("1.0.0")] + WithdrawalPositionReadFailed { + op_id: u64, + market: AccountId, + index: u32, + before: U128, + need: U128, + }, + + // Payout and stop diagnostics + #[event_version("1.0.0")] + PayoutUnexpectedState { + op_id: u64, + receiver: AccountId, + amount: U128, + }, + #[event_version("1.0.0")] + WithdrawalStopped { + op_id: u64, + index: u32, + remaining: U128, + collected: U128, + reason: Option, + }, + #[event_version("1.0.0")] + PayoutStopped { + op_id: u64, + receiver: AccountId, + amount: U128, + reason: Option, + }, + #[event_version("1.0.0")] + OperationStoppedWhileIdle { reason: Option }, + + // Skim and deposits + #[event_version("1.0.0")] + SkimNoop { + token: AccountId, + recipient: AccountId, + }, + #[event_version("1.0.0")] + DepositRejectedWrongAsset { token: AccountId }, + #[event_version("1.0.0")] + DepositRejectedZeroAmount { sender: AccountId }, +} diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index ee96a8b7..c820a8cf 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -30,43 +30,44 @@ impl Contract { _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), } - // Resolve market by plan (if present) or supply_queue + // Resolve market by plan or supply_queue let market: AccountId = if let Some(plan) = &self.plan { if let Some((m, _)) = plan.get(market_index as usize) { m.clone() } else { return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); } + } else if let Some(m) = self.supply_queue.get(market_index) { + m.clone() } else { - if let Some(m) = self.supply_queue.get(market_index) { - m.clone() - } else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); - } + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); }; // If the transfer failed, do not attempt to reconcile; stop and leave remaining untouched if supply_refund.is_err() { - env::log_str(&format!( - "after_supply_1_check: transfer failed; stopping (op_id={}, market={}, index={}, attempted={})", - op_id, market, market_index, attempted.0 - )); + Event::AllocationTransferFailed { + op_id, + index: market_index, + market: market.clone(), + attempted, + } + .emit(); return self.stop_and_exit(Some(&Error::MarketTransferFailed)); } let before = self.market_supply.get(&market).unwrap_or(&0); - let fetch_pos = ext_market::ext(market.clone()) - .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) - .get_supply_position(env::current_account_id()); - PromiseOrValue::Promise( - fetch_pos.then( - ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) - .after_supply_2_read( - op_id, - market_index, + ext_market::ext(market.clone()) + .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) + .with_unused_gas_weight(0) + .get_supply_position(env::current_account_id()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(Self::AFTER_SUPPLY_POSITION_CHECK_GAS) + .after_supply_2_read( + op_id, + market_index, U128(*before), attempted, supply_refund.unwrap_or(U128(0)), @@ -123,21 +124,44 @@ impl Contract { (new_principal, remaining) } Ok(None) => { - env::log_str(&format!( - "after_supply_2_read: position None; stopping (op_id={}, market={}, index={}, attempted={}, refunded={})", - op_id, market, market_index, attempted.0, refunded.0 - )); + Event::AllocationPositionMissing { + op_id, + index: market_index, + market: market.clone(), + attempted, + refunded, + } + .emit(); return self.stop_and_exit(Some(&Error::MissingSupplyPosition)); } Err(_) => { - env::log_str(&format!( - "after_supply_2_read: position read failed; stopping (op_id={}, market={}, index={}, attempted={}, refunded={})", - op_id, market, market_index, attempted.0, refunded.0 - )); + Event::AllocationPositionReadFailed { + op_id, + index: market_index, + market: market.clone(), + attempted, + refunded, + } + .emit(); return self.stop_and_exit(Some(&Error::PositionReadFailed)); } }; + // Emit step settled event + let accepted_event = new_principal.saturating_sub(before.0); + Event::AllocationStepSettled { + op_id, + index: market_index, + market: market.clone(), + before, + new_principal: U128(new_principal), + accepted: U128(accepted_event), + attempted, + refunded, + remaining_after: U128(remaining_next), + } + .emit(); + self.market_supply.insert(market.clone(), new_principal); // Invariant: withdraw_queue gains any market with new_principal > 0 if new_principal > 0 && !self.withdraw_queue.iter().any(|m| m == &market) { @@ -149,10 +173,11 @@ impl Contract { index: market_index + 1, remaining: remaining_next, }; - self.step_allocation(); - PromiseOrValue::Value(()) + self.step_allocation() } + pub const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(20); + #[private] pub fn after_create_withdraw_req( &mut self, @@ -320,17 +345,25 @@ impl Contract { } Ok(None) => { // No position => treat as principal = 0 - env::log_str(&format!( - "after_exec_withdraw_read: no position; treating principal as 0 (op_id={}, market={}, index={}, before={}, need={})", - op_id, market, market_index, before_principal, need.0 - )); + Event::WithdrawalPositionMissing { + op_id, + market: market.clone(), + index: market_index, + before: U128(before_principal), + need, + } + .emit(); 0 } Err(_) => { - env::log_str(&format!( - "after_exec_withdraw_read: get_supply_position failed; op_id={}, market={}, index={}, assuming no change (before={}, need={})", - op_id, market, market_index, before_principal, need.0 - )); + Event::WithdrawalPositionReadFailed { + op_id, + market: market.clone(), + index: market_index, + before: U128(before_principal), + need, + } + .emit(); before_principal } }; @@ -405,7 +438,12 @@ impl Contract { escrow_shares, } if *cur == op_id && *r == receiver => (owner.clone(), *escrow_shares, *a), _ => { - env::log_str("after_send_to_user: unexpected op_state; ignoring"); + Event::PayoutUnexpectedState { + op_id, + receiver: receiver.clone(), + amount, + } + .emit(); return false; } }; @@ -431,10 +469,29 @@ impl Contract { &mut self, msg: Option<&T>, ) { - if let Some(msg) = msg { - env::log_str(format!("Allocation stopped: {msg}").as_str()); - } - if let OpState::Allocating { remaining, .. } = &self.op_state { + // replaced log with events elsewhere; no-op here + if let OpState::Allocating { + op_id, + index, + remaining, + } = &self.op_state + { + // Emit completion vs stop event before reconciling remaining + match msg { + None => { + Event::AllocationCompleted { op_id: *op_id }.emit(); + } + Some(m) => { + Event::AllocationStopped { + op_id: *op_id, + index: *index, + remaining: U128(*remaining), + reason: Some(m.to_string()), + } + .emit(); + } + } + if *remaining > 0 { self.idle_balance = self.idle_balance.saturating_add(*remaining); } @@ -448,8 +505,25 @@ impl Contract { &mut self, msg: Option<&T>, ) { - if let Some(msg) = msg { - env::log_str(format!("Withdrawal stopped: {msg}").as_str()); + { + let (op_id, index, remaining, collected) = match &self.op_state { + OpState::Withdrawing { + op_id, + index, + remaining, + collected, + .. + } => (*op_id, *index, *remaining, *collected), + _ => (0, 0, 0, 0), + }; + Event::WithdrawalStopped { + op_id, + index, + remaining: U128(remaining), + collected: U128(collected), + reason: msg.map(|m| m.to_string()), + } + .emit(); } // Take copies to avoid holding immutable borrows across mutable self calls. let (owner_acc, escrow) = match &self.op_state { @@ -473,8 +547,22 @@ impl Contract { /// Payout: refund escrowed shares to owner and go Idle. fn stop_and_exit_payout(&mut self, msg: Option<&T>) { - if let Some(msg) = msg { - env::log_str(format!("Payout stopped: {msg}").as_str()); + { + if let OpState::Payout { + op_id, + receiver, + amount, + .. + } = &self.op_state + { + Event::PayoutStopped { + op_id: *op_id, + receiver: receiver.clone(), + amount: U128(*amount), + reason: msg.map(|m| m.to_string()), + } + .emit(); + } } // Take copies to avoid holding immutable borrows across mutable self calls. let (owner_acc, escrow) = match &self.op_state { @@ -505,9 +593,10 @@ impl Contract { OpState::Withdrawing { .. } => self.stop_and_exit_withdrawing(msg), OpState::Payout { .. } => self.stop_and_exit_payout(msg), OpState::Idle => { - if let Some(msg) = msg { - env::log_str(format!("Operation stopped: {msg:?}").as_str()); + Event::OperationStoppedWhileIdle { + reason: msg.map(|m| format!("{m:?}")), } + .emit(); self.op_state = OpState::Idle; } } @@ -525,9 +614,11 @@ impl Contract { Ok(U128(v)) if v > 0 => v, _ => { // Invariant: Skim does nothing for zero balance (no-op cross-call avoided). - env::log_str(&format!( - "Tried to skim; token={token}, recipient={recipient}" - )); + Event::SkimNoop { + token: token.clone(), + recipient: recipient.clone(), + } + .emit(); return PromiseOrValue::Value(()); } }; diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 475a37d6..52e7b963 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -1,7 +1,7 @@ use crate::{aux::ReturnStyle, Contract, ContractExt, OpState}; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; -use templar_common::vault::{AllocationMode, DepositMsg}; +use templar_common::vault::{AllocationMode, DepositMsg, Event}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; @@ -95,12 +95,12 @@ impl Contract { ) -> u128 { // Invariant: Only the underlying token is accepted; others are fully refunded if token_id != self.underlying_asset.contract_id() { - env::log_str("Only underlying asset is supported"); + Event::DepositRejectedWrongAsset { token: token_id.clone() }.emit(); return deposit; }; if deposit == 0 { - env::log_str("Deposit is zero"); + Event::DepositRejectedZeroAmount { sender: sender_id.clone() }.emit(); return 0; } @@ -112,16 +112,28 @@ impl Contract { let shares = self.preview_deposit(U128(accept)).0; self.mint_shares(&sender_id, shares); + Event::MintedShares { + amount: shares.into(), + receiver: sender_id.clone(), + } + .emit(); self.idle_balance = self.idle_balance.saturating_add(accept); self.last_total_assets = self.last_total_assets.saturating_add(accept); - if matches!(self.op_state, OpState::Idle) - && matches!(self.mode, AllocationMode::Eager { min_batch } if self.idle_balance >= min_batch) - { - // Invariant: no overlapping operations - env::log_str("Starting allocation"); - self.start_allocation(self.idle_balance); + if let AllocationMode::Eager { min_batch } = self.mode { + if matches!(self.op_state, OpState::Idle) && self.idle_balance >= min_batch { + // Invariant: no overlapping operations + let op_id = self.next_op_id; + Event::AllocationEagerTriggered { + op_id, + idle_balance: U128(self.idle_balance), + min_batch: U128(min_batch), + deposit_accepted: U128(accept), + } + .emit(); + self.start_allocation(self.idle_balance); + } } refund diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 11cead7a..547cf78c 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -21,9 +21,9 @@ use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ - ext_self, AllocationMode, AllocationPlan, AllocationWeights, Error, MarketConfiguration, - OpState, PendingValue, TimestampNs, VaultConfiguration, GAS_CB, GAS_XFER, MAX_QUEUE_LEN, - MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + ext_self, AllocationMode, AllocationPlan, AllocationWeights, Error, Event, + MarketConfiguration, OpState, PendingValue, TimestampNs, VaultConfiguration, GAS_CB, + GAS_XFER, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, }, }; pub use wad::*; @@ -202,35 +202,6 @@ impl Contract { contract } - pub fn get_configuration(&self) -> VaultConfiguration { - let timelock_sec = self.timelock_ns / 1_000_000_000; - VaultConfiguration { - owner: self.own_get_owner().expect("Owner not set"), - curator: Self::with_members_of(&Role::Curator, |members| { - assert!( - members.len() == 1, - "Invariant violation: Cannot Have more than 1 Curator" - ); - members.iter().next().expect("Curator not set").clone() - }), - guardian: Self::with_members_of(&Role::Guardian, |members| { - assert!( - members.len() == 1, - "Invariant violation: Cannot Have more than 1 Guardian" - ); - members.iter().next().expect("Guardian not set").clone() - }), - underlying_token: self.underlying_asset.clone(), - initial_timelock_sec: timelock_sec as u32, - fee_recipient: self.fee_recipient.clone(), - skim_recipient: self.skim_recipient.clone(), - name: self.get_metadata().name, - symbol: self.get_metadata().symbol, - decimals: self.get_metadata().decimals, - mode: self.mode.clone(), - } - } - /// Sets the Curator account. Also grants/removes the Allocator role accordingly. pub fn set_curator(&mut self, account: AccountId) { Self::require_owner(); @@ -250,8 +221,15 @@ impl Contract { }); Self::add_role(self, &account, &Role::Curator); Self::add_role(self, &account, &Role::Allocator); - env::log_str(&format!("Curator set to {account}")); - env::log_str(&format!("Allocator role for {account}")); + Event::CuratorSet { + account: account.clone(), + } + .emit(); + Event::AllocatorRoleSet { + account, + allowed: true, + } + .emit(); } /// Grants or revokes the Allocator role for `account`. @@ -262,7 +240,7 @@ impl Contract { } else { self.remove_role(&account, &Role::Allocator); } - env::log_str(&format!("Allocator role for {account} set to {allowed}")); + Event::AllocatorRoleSet { account, allowed }.emit(); } /// Proposes a new Guardian. If a Guardian already exists, starts a timelock; otherwise sets immediately. @@ -325,7 +303,10 @@ impl Contract { "Already set to this address" ); self.skim_recipient = account.clone(); - env::log_str(&format!("Skim recipient set to {account}")); + Event::SkimRecipientSet { + account: account.clone(), + } + .emit(); } /// Sets the performance fee recipient. Accrues pending fees with the current recipient first. @@ -337,7 +318,10 @@ impl Contract { // Accrue any pending fees to current recipient before changing (so current recipient gets up to now) self.internal_accrue_fee(); } - env::log_str(&format!("Fee recipient set to {account}")); + Event::FeeRecipientSet { + account: account.clone(), + } + .emit(); self.fee_recipient = account; } @@ -354,7 +338,7 @@ impl Contract { // Accrue any pending fees with old rate before changing self.internal_accrue_fee(); self.performance_fee = fee; - env::log_str(&format!("Performance fee set to {fee}")); + Event::PerformanceFeeSet { fee: U128(fee) }.emit(); } /* ----- Timelocks / Pending ----- */ @@ -375,16 +359,21 @@ impl Contract { ); if as_nanos > self.timelock_ns { self.timelock_ns = as_nanos; - env::log_str(&format!("Timelock set to {new_timelock_secs} seconds")); + Event::TimelockSet { + seconds: new_timelock_secs, + } + .emit(); } else { let valid_at = env::block_timestamp() + self.timelock_ns; self.pending_timelock = Some(PendingValue { value: as_nanos, valid_at, }); - env::log_str(&format!( - "Timelock change to {new_timelock_secs} seconds pending, will take effect at {valid_at}" - )); + Event::TimelockChangeSubmitted { + new_seconds: new_timelock_secs, + valid_at, + } + .emit(); } } @@ -407,7 +396,7 @@ impl Contract { pub fn revoke_pending_timelock(&mut self) { Self::assert_guardian_or_owner(); self.pending_timelock = None; - env::log_str("Pending timelock change revoked"); + Event::PendingTimelockRevoked {}.emit(); } /* ----- Market config / queues ----- */ @@ -420,7 +409,10 @@ impl Contract { None => { self.config .insert(market.clone(), MarketConfiguration::default()); - env::log_str(&format!("Market {market} created")); + Event::MarketCreated { + market: market.clone(), + } + .emit(); #[allow(clippy::unwrap_used, reason = "No side effects")] self.config.get_mut(&market).unwrap() } @@ -455,11 +447,15 @@ impl Contract { valid_at, }, ); - env::log_str(&format!( - "Supply cap raise for {market} to {new_cap} pending, valid at {valid_at}", - )); + Event::SupplyCapRaiseSubmitted { + market: market.clone(), + new_cap: U128(new_cap), + valid_at, + } + .emit(); } } + /// Accepts a pending cap increase for `market` once the timelock has elapsed. pub fn accept_cap(&mut self, market: AccountId) { Self::assert_curator_or_owner(); @@ -480,14 +476,24 @@ impl Contract { cfg.enabled = true; let mut added = false; if self.withdraw_queue.iter().any(|m| m == &market) { - env::log_str(&format!( - "Market {market} enabled (cap set > 0); already in withdraw_queue" - )); + Event::MarketEnabled { + market: market.clone(), + } + .emit(); + Event::MarketAlreadyInWithdrawQueue { + market: market.clone(), + } + .emit(); } else { self.withdraw_queue.push(market.clone()); - env::log_str(&format!( - "Market {market} enabled (cap set > 0); added to withdraw_queue" - )); + Event::MarketEnabled { + market: market.clone(), + } + .emit(); + Event::WithdrawQueueMarketAdded { + market: market.clone(), + } + .emit(); added = true; } @@ -501,10 +507,11 @@ impl Contract { } else { cfg.enabled = false; } - env::log_str(&format!( - "Supply cap for {} set to {}", - market, pending.value - )); + Event::SupplyCapSet { + market: market.clone(), + new_cap: U128(pending.value), + } + .emit(); self.pending_cap.remove(&market); } else { env::panic_str("No pending cap change for this market"); @@ -546,10 +553,11 @@ impl Contract { "Cap change pending for this market" ); cfg.removable_at = env::block_timestamp() + self.timelock_ns; - env::log_str(&format!( - "Market {} removal pending, will take effect at {}", - market, cfg.removable_at - )); + Event::MarketRemovalSubmitted { + market: market.clone(), + removable_at: cfg.removable_at, + } + .emit(); } /// Revokes a pending market removal for `market`. pub fn revoke_pending_market_removal(&mut self, market: AccountId) { @@ -557,7 +565,7 @@ impl Contract { if let Some(cfg) = self.config.get_mut(&market) { cfg.removable_at = 0; } - env::log_str(&format!("Market {market} removal revoked")); + Event::MarketRemovalRevoked { market }.emit(); } /// Sets the ordered supply (allocation) queue. @@ -657,12 +665,154 @@ impl Contract { for id in &queue { self.withdraw_queue.push(id.clone()); } - env::log_str(&format!( - "Withdraw queue updated. Current markets: {queue:?}", - )); + Event::WithdrawQueueUpdated { + markets: queue.clone(), + } + .emit(); + } + + /* ----- Withdraw / Redeem ----- */ + /// Burns the necessary shares to withdraw `amount` of underlying to `receiver`. + /// Internally calls `redeem` after computing the share amount. + pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { + let shares_needed = self.preview_withdraw(amount).0; + self.redeem(U128(shares_needed), receiver) + } + + /// Redeems `shares` for underlying assets sent to `receiver`. + /// Shares are escrowed to the contract and only burned after successful payout. + pub fn redeem(&mut self, shares: U128, receiver: AccountId) -> PromiseOrValue<()> { + let shares = shares.0; + + let assets = self.convert_to_assets(U128(shares)).0; + + let owner = env::predecessor_account_id(); + + // Move shares into vault escrow; do not burn yet + #[allow(clippy::expect_used, reason = "No side effects")] + self.transfer_unchecked(&owner, &env::current_account_id(), shares) + .expect("Redeem failed to move shares into escrow"); + + self.internal_accrue_fee(); + + Event::RedeemRequested { + shares: U128(shares), + estimated_assets: U128(assets), + } + .emit(); + self.start_withdraw(assets, receiver.clone(), owner, shares) + } + + /* ----- Skim (sends entire balance of `token` to `skim_recipient`) ----- */ + /// 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(); + ext_ft_core::ext(token.clone()) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .ft_balance_of(env::current_account_id()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .after_skim_balance(token, self.skim_recipient.clone()), + ) + } + + pub fn allocate( + &mut self, + weights: AllocationWeights, + amount: Option, + ) -> PromiseOrValue<()> { + Self::assert_allocator(); + self.ensure_idle(); + + // If no weights provided, just push the requested or all idle via queue order. + if weights.is_empty() { + return self.start_allocation(amount.map(|x| x.0).unwrap_or(self.idle_balance)); + } + + // Validate unique markets and accumulate weight sum + let mut seen = std::collections::HashSet::new(); + let mut sum_w: u128 = 0; + + for (m, w) in &weights { + if !seen.insert(m.clone()) { + env::panic_str(&format!("Duplicate market in weights: {m}")); + } + sum_w = sum_w.saturating_add(u128::from(*w)); + } + if sum_w == 0 { + env::panic_str("Sum of weights is zero"); + } + + // Clamp total allocation by idle balance and aggregate room + let requested: u128 = amount.map(|x| x.0).unwrap_or(self.idle_balance); + let max_room = self.get_max_deposit().0; + let total = requested.min(self.idle_balance).min(max_room); + if total == 0 { + env::panic_str("No funds to allocate"); + } + + // Emit request and plan events + let op_id = self.next_op_id; + let weights_for_event: Vec<(AccountId, U128)> = weights + .iter() + .map(|(m, w)| (m.clone(), U128((*w).into()))) + .collect(); + Event::AllocationRequestedWeighted { + op_id, + total: U128(total), + weights: weights_for_event.clone(), + } + .emit(); + Event::AllocationPlanSet { + op_id, + plan: weights_for_event, + } + .emit(); + + // Store an ephemeral plan of (market, weight) to drive weighted allocation. + let plan: AllocationPlan = weights + .into_iter() + .map(|(m, w)| (m, u128::from(w))) + .collect(); + + self.plan = Some(plan); + self.start_allocation(total) + } +} + +/* ----- Views ----- */ +#[near] +impl Contract { + pub fn get_configuration(&self) -> VaultConfiguration { + let timelock_sec = self.timelock_ns / 1_000_000_000; + VaultConfiguration { + owner: self.own_get_owner().expect("Owner not set"), + curator: Self::with_members_of(&Role::Curator, |members| { + assert!( + members.len() == 1, + "Invariant violation: Cannot Have more than 1 Curator" + ); + members.iter().next().expect("Curator not set").clone() + }), + guardian: Self::with_members_of(&Role::Guardian, |members| { + assert!( + members.len() == 1, + "Invariant violation: Cannot Have more than 1 Guardian" + ); + members.iter().next().expect("Guardian not set").clone() + }), + underlying_token: self.underlying_asset.clone(), + initial_timelock_sec: timelock_sec as u32, + fee_recipient: self.fee_recipient.clone(), + skim_recipient: self.skim_recipient.clone(), + name: self.get_metadata().name, + symbol: self.get_metadata().symbol, + decimals: self.get_metadata().decimals, + mode: self.mode.clone(), + } } - /* ----- Views ----- */ /// Returns total assets under management = idle balance + sum of market principals. pub fn get_total_assets(&self) -> U128 { // TODO: join @@ -673,6 +823,10 @@ impl Contract { U128(sum) } + pub fn get_total_supply(&self) -> U128 { + U128(self.total_supply()) + } + /// Returns the maximum additional amount that can be deposited across all markets given current caps. pub fn get_max_deposit(&self) -> U128 { // TODO: join @@ -690,21 +844,6 @@ impl Contract { U128(total) } - /// Computes fee-aware effective totals for conversions, mimicking MetaMorpho: - /// - Include fee shares that would be minted if fees accrued now. - /// - Apply virtual offsets: +virtual_shares to supply and +virtual_assets to assets. - fn effective_totals_fee_aware(&self) -> (u128, u128) { - let cur = self.get_total_assets().0; - let ts = self.total_supply(); - let fee_shares = - crate::wad::compute_fee_shares(cur, self.last_total_assets, self.performance_fee, ts); - let new_total_supply = ts - .saturating_add(fee_shares) - .saturating_add(self.virtual_shares); - let new_total_assets = cur.saturating_add(self.virtual_assets); - (new_total_supply, new_total_assets) - } - /// Converts an amount of underlying assets to shares, flooring the result. /// Uses virtual offsets and fee-aware totals (pre-accrual simulation) like MetaMorpho. pub fn convert_to_shares(&self, assets: U128) -> U128 { @@ -776,54 +915,25 @@ impl Contract { pub fn preview_redeem(&self, shares: U128) -> U128 { self.convert_to_assets(shares) } - - /* ----- Withdraw / Redeem ----- */ - /// Burns the necessary shares to withdraw `amount` of underlying to `receiver`. - /// Internally calls `redeem` after computing the share amount. - pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { - let shares_needed = self.preview_withdraw(amount).0; - self.redeem(U128(shares_needed), receiver) - } - - /// Redeems `shares` for underlying assets sent to `receiver`. - /// Shares are escrowed to the contract and only burned after successful payout. - pub fn redeem(&mut self, shares: U128, receiver: AccountId) -> PromiseOrValue<()> { - let shares = shares.0; - - let assets = self.convert_to_assets(U128(shares)).0; - - let owner = env::predecessor_account_id(); - - // Move shares into vault escrow; do not burn yet - #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&owner, &env::current_account_id(), shares) - .expect("Redeem failed to move shares into escrow"); - - self.internal_accrue_fee(); - - env::log_str(&format!( - "Redeem requested: {shares} shares for ~{assets} assets" - )); - self.start_withdraw(assets, receiver.clone(), owner, shares) - } - - /* ----- Skim (sends entire balance of `token` to `skim_recipient`) ----- */ - /// 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(); - ext_ft_core::ext(token.clone()) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) - .ft_balance_of(env::current_account_id()) - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) - .after_skim_balance(token, self.skim_recipient.clone()), - ) - } } /* ----- Private Helpers ----- */ impl Contract { + /// Computes fee-aware effective totals for conversions, mimicking MetaMorpho: + /// - Include fee shares that would be minted if fees accrued now. + /// - Apply virtual offsets: +virtual_shares to supply and +virtual_assets to assets. + fn effective_totals_fee_aware(&self) -> (u128, u128) { + let cur = self.get_total_assets().0; + let ts = self.total_supply(); + let fee_shares = + crate::wad::compute_fee_shares(cur, self.last_total_assets, self.performance_fee, ts); + let new_total_supply = ts + .saturating_add(fee_shares) + .saturating_add(self.virtual_shares); + let new_total_assets = cur.saturating_add(self.virtual_assets); + (new_total_supply, new_total_assets) + } + /* ----- Internal: fee, shares ----- */ pub fn mint_shares(&mut self, to: &AccountId, amount: u128) { if amount == 0 { @@ -880,54 +990,9 @@ impl Contract { } } - pub fn reallocate( - &mut self, - weights: AllocationWeights, - amount: Option, - ) -> PromiseOrValue<()> { - Self::assert_allocator(); - self.ensure_idle(); - - // If no weights provided, just push the requested or all idle via queue order. - if weights.is_empty() { - return self.start_allocation(amount.map(|x| x.0).unwrap_or(self.idle_balance)); - } - - // Validate unique markets and accumulate weight sum - let mut seen = std::collections::HashSet::new(); - let mut sum_w: u128 = 0; - - for (m, w) in &weights { - if !seen.insert(m.clone()) { - env::panic_str(&format!("Duplicate market in weights: {m}")); - } - sum_w = sum_w.saturating_add(u128::from(*w)); - } - if sum_w == 0 { - env::panic_str("Sum of weights is zero"); - } - - // Clamp total allocation by idle balance and aggregate room - let requested: u128 = amount.map(|x| x.0).unwrap_or(self.idle_balance); - let max_room = self.get_max_deposit().0; - let total = requested.min(self.idle_balance).min(max_room); - if total == 0 { - env::panic_str("No funds to allocate"); - } - - // Store an ephemeral plan of (market, weight) to drive weighted allocation. - let plan: AllocationPlan = weights - .into_iter() - .map(|(m, w)| (m, u128::from(w))) - .collect(); - - self.plan = Some(plan); - self.start_allocation(total) - } - fn start_allocation(&mut self, amount: u128) -> PromiseOrValue<()> { if amount == 0 { - return PromiseOrValue::Value(()); + return self.stop_and_exit(Some(&Error::ZeroAmount)); } self.ensure_idle(); self.idle_balance = 0; @@ -938,6 +1003,11 @@ impl Contract { index: 0, remaining: amount, }; + Event::AllocationStarted { + op_id, + remaining: U128(amount), + } + .emit(); self.step_allocation() } @@ -978,7 +1048,33 @@ impl Contract { let room = cap.saturating_sub(cur); let to_supply = room.min(target); + // Emit planned step event + Event::AllocationStepPlanned { + op_id, + index, + market: market_id.clone(), + target: U128(target), + room: U128(room), + to_supply: U128(to_supply), + remaining_before: U128(remaining), + planned: true, + } + .emit(); + if to_supply == 0 { + Event::AllocationStepSkipped { + op_id, + 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 { op_id, index: index + 1, @@ -1015,7 +1111,30 @@ impl Contract { let cur = self.market_supply.get(market).unwrap_or(&0); let room = cap.saturating_sub(*cur); let to_supply = room.min(remaining); + + // Emit planned step event (queue-based) + Event::AllocationStepPlanned { + op_id, + 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, + index, + market: market.clone(), + reason: "no-room".to_string(), + remaining: U128(remaining), + } + .emit(); + self.op_state = OpState::Allocating { op_id, index: index + 1, @@ -1055,7 +1174,7 @@ impl Contract { escrow_shares: u128, ) -> PromiseOrValue<()> { if amount == 0 { - env::panic_str("no assets to withdraw"); + return self.stop_and_exit(Some(&Error::ZeroAmount)); } self.ensure_idle(); let op_id = self.next_op_id; diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 04eb25a4..2131a138 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -59,8 +59,8 @@ impl VaultController { #[view] pub fn get_fee_recipient() -> AccountId; #[view] pub fn get_last_total_assets() -> U128; #[view] pub fn get_total_assets() -> U128; - #[view] pub fn get_max_deposit() -> U128; #[view] pub fn get_total_supply() -> U128; + #[view] pub fn get_max_deposit() -> U128; #[view] pub fn get_idle_balance() -> U128; #[view] pub fn get_op_state() -> OpState; #[view] pub fn list_supply_queue(offset: Option, count: Option) -> Vec; @@ -94,19 +94,71 @@ impl VaultController { #[call(exec, tgas(50))] pub fn skim["skim"](token: AccountId); - // TODO: caps? + #[call(exec, tgas(5))] + pub fn submit_cap(market: AccountId, new_cap: U128); + + #[call(exec, tgas(5))] + pub fn accept_cap(market: AccountId); + + #[call(exec, tgas(5))] + pub fn revoke_pending_cap(market: AccountId); + + #[call(exec, tgas(50))] + pub fn submit_market_removal(market: AccountId); + + #[call(exec, tgas(50))] + pub fn revoke_pending_market_removal(market: AccountId); + + #[call(exec, tgas(50))] + pub fn set_curator(account: AccountId); + + #[call(exec, tgas(50))] + pub fn set_is_allocator(account: AccountId, allowed: bool); + + #[call(exec, tgas(50))] + pub fn submit_guardian(new_g: AccountId); - /* -------- Promise callbacks (must be #[private] on-chain) -------- */ - // After attempting to supply into a market during allocation. #[call(exec, tgas(50))] + pub fn accept_guardian(); + + #[call(exec, tgas(50))] + pub fn revoke_pending_guardian(); + + #[call(exec, tgas(50))] + pub fn set_skim_recipient(account: AccountId); + + #[call(exec, tgas(50))] + pub fn set_fee_recipient(account: AccountId); + + #[call(exec, tgas(50))] + pub fn set_performance_fee(fee: U128); + + #[call(exec, tgas(50))] + pub fn submit_timelock(new_timelock_secs: u32); + + #[call(exec, tgas(50))] + pub fn accept_timelock(); + + #[call(exec, tgas(50))] + pub fn revoke_pending_timelock(); + + #[call(exec, tgas(50))] + pub fn set_supply_queue(markets: Vec); + + #[call(exec, tgas(50))] + pub fn set_withdraw_queue(queue: Vec); + + + // After attempting to supply into a market during allocation. + #[call(exec, tgas(30))] pub fn after_supply_1_check(op_id: u64, index: u32, amount: U128); // After creating a withdrawal request on a market during withdrawal orchestration. - #[call(exec, tgas(50))] + #[call(exec, tgas(20))] pub fn after_create_withdraw_req(op_id: u64, index: u32, amount: U128); // After payout to the user completes. - #[call(exec, tgas(50))] + #[call(exec, tgas(5))] pub fn after_send_to_user(op_id: u64, receiver: AccountId, amount: U128); } } From b344eb60480e651d411be59588f1adbf2169b046 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 009/121] chore: slightly better gas accounting --- common/src/vault.rs | 14 +++------- contract/vault/src/impl_callbacks.rs | 39 ++++++++++++++-------------- contract/vault/src/lib.rs | 9 ++++--- test-utils/src/controller/vault.rs | 2 +- 4 files changed, 29 insertions(+), 35 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index f0c12779..3458102c 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -1,24 +1,18 @@ -use near_sdk::{json_types::U128, near, AccountId, Gas}; +use near_sdk::{json_types::U128, near, AccountId}; -use crate::{ - asset::{BorrowAsset, FungibleAsset}, - supply::SupplyPosition, -}; +use crate::asset::{BorrowAsset, FungibleAsset}; pub type TimestampNs = u64; -// FIXME: -pub const GAS_XFER: Gas = Gas::from_tgas(4); -pub const GAS_CB: Gas = Gas::from_tgas(30); pub const ONE_YOCTO: u128 = 1; -pub const MIN_TIMELOCK_NS: u64 = 86_400_000_000_000; // 1 day +pub const MIN_TIMELOCK_NS: u64 = 0; pub const MAX_TIMELOCK_NS: u64 = 30 * 86_400_000_000_000; // 30 days pub const MAX_QUEUE_LEN: usize = 64; pub type ExpectedIdx = u32; pub type ActualIdx = u32; -pub type AllocationWeights = Vec<(AccountId, u128)>; +pub type AllocationWeights = Vec<(AccountId, U128)>; pub type AllocationPlan = Vec<(AccountId, u128)>; #[derive(Clone, Debug)] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index c820a8cf..77e6ae49 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -1,20 +1,17 @@ use std::fmt::Display; -use crate::{ - ext_self, near, Contract, ContractExt, Error, Nep141Controller, OpState, GAS_CB, GAS_XFER, -}; +use crate::{ext_self, near, Contract, ContractExt, Error, Nep141Controller, OpState}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, json_types::U128, serde_json, AccountId, Gas, NearToken, Promise, PromiseError, PromiseOrValue, }; use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; -use templar_common::{market::ext_market, supply::SupplyPosition}; +use templar_common::{market::ext_market, supply::SupplyPosition, vault::Event}; #[near] impl Contract { - const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(20); - const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(20); + pub const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(30); #[private] pub fn after_supply_1_check( @@ -68,14 +65,16 @@ impl Contract { .after_supply_2_read( op_id, market_index, - U128(*before), - attempted, - supply_refund.unwrap_or(U128(0)), - ), - ), + U128(*before), + attempted, + supply_refund.unwrap_or(U128(0)), + ), + ), ) } + pub const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(4); + pub const AFTER_SUPPLY_POSITION_CHECK_GAS: Gas = Gas::from_tgas(10); // FIXME: no panics in this function! This will cause to spin if the op changes #[private] pub fn after_supply_2_read( @@ -108,12 +107,10 @@ impl Contract { } else { return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); } + } else if let Some(m) = self.supply_queue.get(market_index) { + m.clone() } else { - if let Some(m) = self.supply_queue.get(market_index) { - m.clone() - } else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); - } + return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); }; let (new_principal, remaining_next) = match position { @@ -218,11 +215,11 @@ impl Contract { if let Ok(()) = did_create { PromiseOrValue::Promise( ext_market::ext(market.clone()) - .with_static_gas(GAS_XFER) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) .execute_next_supply_withdrawal_request() .then( ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) + .with_static_gas(Self::AFTER_CREATE_WITHDRAW_REQ_GAS) .after_exec_withdraw_req(op_id, market_index, need), ), ) @@ -294,7 +291,7 @@ impl Contract { })) .expect("json"), NearToken::from_yoctonear(0), - GAS_CB, + Self::AFTER_CREATE_WITHDRAW_REQ_GAS, // FIXME: ), ), ) @@ -394,7 +391,7 @@ impl Contract { .transfer(recv.clone(), U128(collected).into()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) + .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) .after_send_to_user(op_id, recv, U128(collected)), ), ) @@ -421,6 +418,8 @@ impl Contract { } } + pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(5); + #[private] pub fn after_send_to_user( &mut self, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 547cf78c..f1e1527a 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1097,7 +1097,8 @@ impl Contract { ) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) + .with_static_gas(Self::AFTER_SUPPLY_ENSURE_GAS) + .with_unused_gas_weight(0) .after_supply_1_check(op_id, index, U128(to_supply)), ), ); @@ -1156,7 +1157,7 @@ impl Contract { ) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) + .with_static_gas(Self::AFTER_SUPPLY_ENSURE_GAS) .after_supply_1_check(op_id, index, U128(to_supply)), ), ) @@ -1233,7 +1234,7 @@ impl Contract { .transfer(receiver.clone(), U128(collected).into()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) + .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) .after_send_to_user(op_id, receiver, U128(collected)), ), ); @@ -1268,7 +1269,7 @@ impl Contract { .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(*to_request))) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(GAS_CB) + .with_static_gas(Self::AFTER_CREATE_WITHDRAW_REQ_GAS) .after_create_withdraw_req(op_id, index, U128(*to_request)), ), ) diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 2131a138..b144ebd2 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -81,7 +81,7 @@ impl VaultController { // Allocator/curator/owner-gated: begins allocation across markets. #[call(exec, tgas(300))] - pub fn reallocate["start_allocation"](weights: AllocationPlan, amount: Option); + pub fn allocate(weights: AllocationWeights, amount: Option); // User withdrawal path; expects escrowed shares already held by the contract. #[call(exec, tgas(300))] From 2a8618a43bddd53de5d76216c534c6517badc225 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 010/121] test: happy path test --- contract/vault/src/lib.rs | 20 +++++++++--- contract/vault/tests/happy_path.rs | 34 ++++++++++++++++++++ test-utils/src/controller/vault.rs | 51 ++++++++++++++++++++++++++++-- test-utils/src/lib.rs | 17 ++++++++-- 4 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 contract/vault/tests/happy_path.rs diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index f1e1527a..de49dd62 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -22,8 +22,8 @@ use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ ext_self, AllocationMode, AllocationPlan, AllocationWeights, Error, Event, - MarketConfiguration, OpState, PendingValue, TimestampNs, VaultConfiguration, GAS_CB, - GAS_XFER, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + MarketConfiguration, OpState, PendingValue, TimestampNs, VaultConfiguration, MAX_QUEUE_LEN, + MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, }, }; pub use wad::*; @@ -725,9 +725,21 @@ impl Contract { Self::assert_allocator(); self.ensure_idle(); - // If no weights provided, just push the requested or all idle via queue order. + // If no weights provided, use queue order; clamp total and emit request event. if weights.is_empty() { - return self.start_allocation(amount.map(|x| x.0).unwrap_or(self.idle_balance)); + let requested: u128 = amount.map(|x| x.0).unwrap_or(self.idle_balance); + let max_room = self.get_max_deposit().0; + let total = requested.min(self.idle_balance).min(max_room); + if total == 0 { + return self.stop_and_exit(Some(&Error::ZeroAmount)); + } + let op_id = self.next_op_id; + Event::AllocationRequestedQueue { + op_id, + total: U128(total), + } + .emit(); + return self.start_allocation(total); } // Validate unique markets and accumulate weight sum diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs new file mode 100644 index 00000000..0d132fb8 --- /dev/null +++ b/contract/vault/tests/happy_path.rs @@ -0,0 +1,34 @@ +use near_sdk::json_types::U128; +use test_utils::{setup_test, setup_test_w, ContractController}; + +#[tokio::test] +async fn happy() { + setup_test!(extract(vault, c, vault_curator) accounts(supply_user )); + + c.init_account(&supply_user).await; + vault.init_account(&supply_user).await; + + let amount: U128 = 1000.into(); + + vault.supply(&supply_user, amount.0).await; + + let weights = vec![(c.market.contract().as_account().id().clone(), U128(1))]; + vault.allocate(&vault_curator, weights, Some(amount)).await; + + assert_eq!( + c.borrow_asset + .balance_of(vault.contract().as_account().id()) + .await, + 0 + ); + assert_eq!(vault.get_total_supply().await, amount); + assert_eq!(vault.get_total_assets().await, amount); + assert_eq!( + c.get_supply_position(vault.contract().as_account().id()) + .await + .unwrap() + .get_deposit() + .total(), + amount.into() + ); +} diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index b144ebd2..ddb4b204 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -1,7 +1,7 @@ use super::ContractController; use crate::{ controller::storage_management::StorageManagementController, define, get_contract, - UnifiedMarketController, + print_execution, UnifiedMarketController, }; use near_sdk::{ json_types::U128, @@ -175,6 +175,7 @@ pub struct UnifiedVaultController { pub vault: VaultController, pub configuration: VaultConfiguration, pub market: UnifiedMarketController, + pub debug: bool, } impl Deref for UnifiedVaultController { @@ -204,6 +205,7 @@ impl UnifiedVaultController { vault, configuration, market, + debug: true, } } @@ -216,6 +218,7 @@ impl UnifiedVaultController { vault, configuration, market, + debug: true, } } @@ -238,7 +241,8 @@ impl UnifiedVaultController { "{} transferring {amount} tokens for supply...", supply_user.id() ); - self.market + let e = self + .market .borrow_asset .transfer_call( supply_user, @@ -246,6 +250,47 @@ impl UnifiedVaultController { amount, serde_json::to_string(&DepositMsg::Supply).unwrap(), ) - .await + .await; + if self.debug { + print_execution(&e); + } + e + } + + pub async fn setup_caps(&self, owner: &Account, markets: &[AccountId], amount: u128) { + let mut submits = vec![]; + let mut accepts = vec![]; + + for mkt in markets { + submits.push(self.submit_cap(owner, mkt.clone(), amount).await); + accepts.push(self.accept_cap(owner, mkt.clone()).await); + } + + let set_queue = self.set_supply_queue(&owner, markets).await; + + if self.debug { + for s in submits { + print_execution(&s); + } + for a in accepts { + print_execution(&a); + } + print_execution(&set_queue); + } + } + + pub async fn allocate( + &self, + allocator: &Account, + weights: AllocationWeights, + amount: Option, + ) { + let e = self + .vault + .allocate(allocator, weights, amount.unwrap_or(1000.into())) + .await; + if self.debug { + print_execution(&e); + } } } diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index a20af4ac..e1e86b8d 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -183,7 +183,7 @@ pub fn vault_configuration( curator: curator_id, guardian: guardian_id, underlying_token: FungibleAsset::nep141(borrow_asset_id), - initial_timelock_sec: (templar_common::vault::MAX_TIMELOCK_NS / 1_000_000_000) as u32, + initial_timelock_sec: templar_common::vault::MIN_TIMELOCK_NS as u32, fee_recipient: fee_recipient_id, skim_recipient: skim_recipient_id, name: "Vault".to_string(), @@ -222,6 +222,11 @@ pub struct SetupEverything { pub protocol_yield_user: Account, pub insurance_yield_user: Account, pub vault: UnifiedVaultController, + pub vault_owner: Account, + pub vault_curator: Account, + pub vault_guardian: Account, + pub skim_recipient: Account, + pub fee_recipient: Account, } pub async fn setup_everything( @@ -308,9 +313,10 @@ pub async fn setup_everything( let v = UnifiedVaultController::new(vault, vault_config, c.clone()); + let mkt = c.market.contract().as_account(); // Asset opt-ins. tokio::join!( - c.storage_deposits(c.market.contract().as_account()), + c.storage_deposits(mkt), c.init_account(&protocol_yield_user), c.init_account(&insurance_yield_user), v.storage_deposits(v.vault.contract().as_account()), @@ -318,11 +324,18 @@ pub async fn setup_everything( v.storage_deposits(&fee_recipient), ); + v.setup_caps(&vault_owner, &[mkt.id().clone()], 1000).await; + SetupEverything { c, protocol_yield_user, insurance_yield_user, vault: v, + vault_owner, + vault_curator, + vault_guardian, + skim_recipient, + fee_recipient, } } From 7c620dd5739eb8f738ce6cc71166d2d3953d6d32 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 011/121] fix: use calc_refund in AllocationStepSettled event Co-authored-by: aider (openai/gpt-5) --- common/src/vault.rs | 7 +++--- contract/vault/src/impl_callbacks.rs | 33 ++++++++++++---------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 3458102c..1696bfc3 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -85,11 +85,12 @@ pub trait Callbacks { market_index: u32, before: U128, attempted: U128, - refunded: U128, + accepted: U128, ) -> bool; fn after_create_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; fn after_exec_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; + fn after_exec_withdraw_read(&mut self, op_id: u64, market_index: u32, before: U128, need: U128); fn after_send_to_user(&mut self, op_id: u64, receiver: AccountId, amount: U128) -> bool; @@ -297,7 +298,7 @@ pub enum Event { index: u32, market: AccountId, attempted: U128, - refunded: U128, + accepted: U128, }, #[event_version("1.0.0")] AllocationPositionReadFailed { @@ -305,7 +306,7 @@ pub enum Event { index: u32, market: AccountId, attempted: U128, - refunded: U128, + accepted: U128, }, // Withdrawal read diagnostics diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 77e6ae49..6307fce5 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -16,7 +16,9 @@ impl Contract { #[private] pub fn after_supply_1_check( &mut self, - #[callback_result] supply_refund: Result, + #[callback_result] accepted: Result, // NOTE: we probably can't rely on + // this as a `true` value of accepted, so we are taking a belt-and-braces approach of + // querying the supply position op_id: u64, market_index: u32, attempted: U128, @@ -41,7 +43,7 @@ impl Contract { }; // If the transfer failed, do not attempt to reconcile; stop and leave remaining untouched - if supply_refund.is_err() { + if accepted.is_err() { Event::AllocationTransferFailed { op_id, index: market_index, @@ -67,7 +69,7 @@ impl Contract { market_index, U128(*before), attempted, - supply_refund.unwrap_or(U128(0)), + accepted.unwrap_or(U128(0)), ), ), ) @@ -84,7 +86,7 @@ impl Contract { market_index: u32, before: U128, attempted: U128, - refunded: U128, + accepted: U128, ) -> PromiseOrValue<()> { let (idx, rem) = match &self.op_state { OpState::Allocating { @@ -126,7 +128,7 @@ impl Contract { index: market_index, market: market.clone(), attempted, - refunded, + accepted, } .emit(); return self.stop_and_exit(Some(&Error::MissingSupplyPosition)); @@ -137,7 +139,7 @@ impl Contract { index: market_index, market: market.clone(), attempted, - refunded, + accepted, } .emit(); return self.stop_and_exit(Some(&Error::PositionReadFailed)); @@ -146,6 +148,8 @@ impl Contract { // Emit step settled event let accepted_event = new_principal.saturating_sub(before.0); + // Compute refund from ground truth (attempted - accepted), ignoring token-reported value + let refunded = attempted.0.saturating_sub(accepted_event); Event::AllocationStepSettled { op_id, index: market_index, @@ -154,7 +158,7 @@ impl Contract { new_principal: U128(new_principal), accepted: U128(accepted_event), attempted, - refunded, + refunded: U128(refunded), remaining_after: U128(remaining_next), } .emit(); @@ -281,18 +285,9 @@ impl Contract { .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) .get_supply_position(env::current_account_id()) .then( - Promise::new(env::current_account_id()).function_call( - "after_exec_withdraw_read".to_string(), - serde_json::to_vec(&serde_json::json!({ - "op_id": op_id, - "market_index": market_index, - "before": U128(before), - "need": need, - })) - .expect("json"), - NearToken::from_yoctonear(0), - Self::AFTER_CREATE_WITHDRAW_REQ_GAS, // FIXME: - ), + ext_self::ext(env::current_account_id()) + .with_static_gas(Self::AFTER_CREATE_WITHDRAW_REQ_GAS) + .after_exec_withdraw_read(op_id, market_index, U128(before), need), ), ) } From 4c08fe251dc2d463402e8957789f9a358f7f52fd Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 012/121] test: further assumptions --- contract/vault/src/impl_callbacks.rs | 62 ++++++++++++++-------------- contract/vault/tests/happy_path.rs | 51 ++++++++++++++++++----- 2 files changed, 72 insertions(+), 41 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 6307fce5..0ecc4f17 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -459,6 +459,37 @@ impl Contract { } } + #[private] + pub fn after_skim_balance( + &mut self, + #[callback_result] balance: Result, + token: AccountId, + recipient: AccountId, + ) -> PromiseOrValue<()> { + let amount = match balance { + Ok(U128(v)) if v > 0 => v, + _ => { + // Invariant: Skim does nothing for zero balance + Event::SkimNoop { + token: token.clone(), + recipient: recipient.clone(), + } + .emit(); + return PromiseOrValue::Value(()); + } + }; + if amount == 0 { + PromiseOrValue::Value(()) + } else { + PromiseOrValue::Promise( + ext_ft_core::ext(token) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .ft_transfer(recipient, U128(amount), None), + ) + } + } + fn stop_and_exit_allocating( &mut self, msg: Option<&T>, @@ -596,35 +627,4 @@ impl Contract { } PromiseOrValue::Value(()) } - - #[private] - pub fn after_skim_balance( - &mut self, - #[callback_result] balance: Result, - token: AccountId, - recipient: AccountId, - ) -> PromiseOrValue<()> { - let amount = match balance { - Ok(U128(v)) if v > 0 => v, - _ => { - // Invariant: Skim does nothing for zero balance (no-op cross-call avoided). - Event::SkimNoop { - token: token.clone(), - recipient: recipient.clone(), - } - .emit(); - return PromiseOrValue::Value(()); - } - }; - if amount == 0 { - PromiseOrValue::Value(()) - } else { - PromiseOrValue::Promise( - ext_ft_core::ext(token) - .with_attached_deposit(NearToken::from_yoctonear(1)) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) - .ft_transfer(recipient, U128(amount), None), - ) - } - } } diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 0d132fb8..09b9fa1a 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -3,32 +3,63 @@ use test_utils::{setup_test, setup_test_w, ContractController}; #[tokio::test] async fn happy() { - setup_test!(extract(vault, c, vault_curator) accounts(supply_user )); + setup_test!(extract(vault, c, vault_curator) accounts(supply_user)); c.init_account(&supply_user).await; vault.init_account(&supply_user).await; + let v = vault.contract().id(); let amount: U128 = 1000.into(); vault.supply(&supply_user, amount.0).await; - let weights = vec![(c.market.contract().as_account().id().clone(), U128(1))]; + let weights = vec![(c.market.contract().id().clone(), U128(1))]; vault.allocate(&vault_curator, weights, Some(amount)).await; assert_eq!( - c.borrow_asset - .balance_of(vault.contract().as_account().id()) - .await, - 0 + c.borrow_asset.balance_of(vault.contract().id()).await, + 0, + "Vault should not have any assets leftover after rebalancing 100%" ); - assert_eq!(vault.get_total_supply().await, amount); - assert_eq!(vault.get_total_assets().await, amount); assert_eq!( - c.get_supply_position(vault.contract().as_account().id()) + vault.get_total_supply().await, + amount, + "Vault should have issued shares to the supplier" + ); + assert_eq!( + vault.get_total_assets().await, + amount, + "Vault should appropriately track assets" + ); + assert_eq!( + c.get_supply_position(v) .await .unwrap() .get_deposit() .total(), - amount.into() + amount.into(), + "Supply position should match amount of tokens supplied to contract", + ); + + // Wait for activation. + while !c + .get_supply_position(v) + .await + .unwrap() + .get_deposit() + .incoming + .is_empty() + { + // TODO: should also do this in allocate + c.harvest_yield(vault.contract().as_account(), None, None) + .await; + } + + let supply_position = c.get_supply_position(v).await.unwrap(); + + assert_eq!( + u128::from(supply_position.get_deposit().active), + amount.0, + "Supply position should match amount of tokens supplied to contract", ); } From 17e3c6cae33329ce787771120cf67b0824a0b9b4 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 013/121] test: user can withdraw position if unborrowed --- contract/vault/src/lib.rs | 9 ++++----- contract/vault/tests/happy_path.rs | 14 ++++++++++++++ test-utils/src/controller/vault.rs | 19 ++++++++++++++++--- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index de49dd62..9d38238d 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -6,7 +6,8 @@ use near_sdk::{ json_types::U128, near, serde_json, store::{IterableMap, LookupMap, Vector}, - AccountId, BorshStorageKey, IntoStorageKey, NearToken, PanicOnDefault, Promise, PromiseOrValue, + AccountId, BorshStorageKey, Gas, IntoStorageKey, NearToken, PanicOnDefault, Promise, + PromiseOrValue, }; use near_sdk_contract_tools::{ ft::{ @@ -1253,9 +1254,8 @@ impl Contract { } // Nothing collected; refund escrowed shares let self_id = env::current_account_id(); - self.withdraw_unchecked(&self_id, escrow_shares) + self.transfer_unchecked(&self_id, &owner, escrow_shares) .expect("Failed to release escrowed shares"); - self.deposit_unchecked(&owner, escrow_shares); return self.stop_and_exit::(None); } if let Some(market) = self.withdraw_queue.get(index) { @@ -1275,9 +1275,8 @@ impl Contract { } PromiseOrValue::Promise( templar_common::market::ext_market::ext(market.clone()) - .with_attached_deposit(NearToken::from_yoctonear(1)) // FIXME: incorrect - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .with_static_gas(Gas::from_tgas(10)) .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(*to_request))) .then( ext_self::ext(env::current_account_id()) diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 09b9fa1a..3d88449b 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -62,4 +62,18 @@ async fn happy() { amount.0, "Supply position should match amount of tokens supplied to contract", ); + + let user_balance = c.borrow_asset.balance_of(supply_user.id()).await; + vault.withdraw(&supply_user, amount, None).await; + assert_eq!( + c.borrow_asset.balance_of(supply_user.id()).await, + amount.0 + user_balance, + "Supply user should have received their tokens back" + ); + + let supply_position = c.get_supply_position(v).await; + assert!( + supply_position.is_none(), + "Supply position should be closed" + ); } diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index ddb4b204..f2bcb7a0 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -83,13 +83,12 @@ impl VaultController { #[call(exec, tgas(300))] pub fn allocate(weights: AllocationWeights, amount: Option); - // User withdrawal path; expects escrowed shares already held by the contract. #[call(exec, tgas(300))] - pub fn withdraw["start_withdraw"](amount: U128, receiver: AccountId, owner: AccountId, escrow_shares: U128); + pub fn withdraw(amount: U128, receiver: AccountId); // User redemption path; expects escrowed shares already held by the contract. #[call(exec, tgas(300))] - pub fn redeem["start_redeem"](shares: U128, receiver: AccountId, owner: AccountId, escrow_shares: U128); + pub fn redeem(shares: U128, receiver: AccountId); #[call(exec, tgas(50))] pub fn skim["skim"](token: AccountId); @@ -293,4 +292,18 @@ impl UnifiedVaultController { print_execution(&e); } } + + pub async fn withdraw(&self, withdrawer: &Account, amount: U128, receiver: Option) { + let e = self + .vault + .withdraw( + withdrawer, + amount, + receiver.unwrap_or(withdrawer.id().clone()), + ) + .await; + if self.debug { + print_execution(&e); + } + } } From 40c1dbed095877dd25dc8927c8ac1f93e682b29b Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 09:36:04 +0100 Subject: [PATCH 014/121] test: happy path with withdraw --- contract/vault/src/impl_callbacks.rs | 2 ++ contract/vault/tests/happy_path.rs | 47 ++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 0ecc4f17..261ada3d 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -220,6 +220,8 @@ impl Contract { PromiseOrValue::Promise( ext_market::ext(market.clone()) .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + // TODO: we can only do this if there is sufficient liquidity in the market, we + // should check that there is first, but even so, we can be rugged .execute_next_supply_withdrawal_request() .then( ext_self::ext(env::current_account_id()) diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 3d88449b..0f779bfc 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -1,9 +1,17 @@ use near_sdk::json_types::U128; +use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; use test_utils::{setup_test, setup_test_w, ContractController}; #[tokio::test] async fn happy() { - setup_test!(extract(vault, c, vault_curator) accounts(supply_user)); + setup_test!( + extract(vault, c, vault_curator) + accounts(supply_user, borrow_user) + config(|c| { + c.borrow_interest_rate_strategy = + InterestRateStrategy::linear(Decimal::ZERO, Decimal::ZERO).unwrap(); + }) + ); c.init_account(&supply_user).await; vault.init_account(&supply_user).await; @@ -12,9 +20,12 @@ async fn happy() { let amount: U128 = 1000.into(); vault.supply(&supply_user, amount.0).await; + c.collateralize(&borrow_user, 2000).await; let weights = vec![(c.market.contract().id().clone(), U128(1))]; - vault.allocate(&vault_curator, weights, Some(amount)).await; + vault + .allocate(&vault_curator, weights.clone(), Some(amount)) + .await; assert_eq!( c.borrow_asset.balance_of(vault.contract().id()).await, @@ -64,7 +75,9 @@ async fn happy() { ); let user_balance = c.borrow_asset.balance_of(supply_user.id()).await; + vault.withdraw(&supply_user, amount, None).await; + assert_eq!( c.borrow_asset.balance_of(supply_user.id()).await, amount.0 + user_balance, @@ -76,4 +89,34 @@ async fn happy() { supply_position.is_none(), "Supply position should be closed" ); + + c.storage_deposits(vault.contract().as_account()).await; + + // 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; + while !c + .get_supply_position(v) + .await + .unwrap() + .get_deposit() + .incoming + .is_empty() + { + // TODO: should also do this in allocate + c.harvest_yield(vault.contract().as_account(), None, None) + .await; + } + + println!( + "Balance of the market for the collateral asset: {}", + c.borrow_asset.balance_of(c.market.contract().id()).await + ); + + c.borrow(&borrow_user, 500).await; + + // TODO: what happens if we try to withdraw now? + // + vault.withdraw(&supply_user, amount, None).await; } From 85d99a9b026d2a6724fb8fd92f3ef579b183ea5f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 8 Oct 2025 12:31:07 +0100 Subject: [PATCH 015/121] feat: add vault-level deferred withdrawals queue and executor feat: add burn_shares to OpState, assert allocator during withdrawals feat: emit WithdrawalQueued event on enqueue_pending_withdrawal refactor: make step_withdraw remaining==0 path unconditional payout --- .gitignore | 1 + common/src/vault.rs | 10 ++ contract/vault/README.md | 61 ++++++++ contract/vault/src/impl_callbacks.rs | 24 +++- contract/vault/src/lib.rs | 207 ++++++++++++++++++++------- contract/vault/tests/happy_path.rs | 57 ++++---- contract/vault/tests/invariants.rs | 125 ++++++++++++++-- test-utils/src/controller/vault.rs | 12 +- 8 files changed, 396 insertions(+), 101 deletions(-) create mode 100644 contract/vault/README.md diff --git a/.gitignore b/.gitignore index c6b0702d..bcb5de7f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ # documentation site _site/ +.aider* diff --git a/common/src/vault.rs b/common/src/vault.rs index 1696bfc3..4b273d2f 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -130,6 +130,7 @@ pub enum OpState { amount: u128, owner: AccountId, escrow_shares: u128, + burn_shares: u128, }, } @@ -290,6 +291,15 @@ pub enum Event { shares: U128, estimated_assets: U128, }, + #[event_version("1.0.0")] + WithdrawalQueued { + id: u64, + owner: AccountId, + receiver: AccountId, + escrow_shares: U128, + expected_assets: U128, + requested_at: u64, + }, // Allocation read/settlement diagnostics #[event_version("1.0.0")] diff --git a/contract/vault/README.md b/contract/vault/README.md new file mode 100644 index 00000000..399dc834 --- /dev/null +++ b/contract/vault/README.md @@ -0,0 +1,61 @@ +# Templar Vault – Withdrawals + +This document explains how withdrawals work in the vault as currently implemented. + +Summary +- The vault performs “best-effort now” withdrawals. There is no persistent vault-level withdrawal queue. +- If there is insufficient liquidity across all markets, the vault refunds escrowed shares and stops. No payout is made. +- Partial progress may occur while attempting the withdrawal (some markets may return funds), but payout to the user only happens when the full requested amount is collected. + +Entry points +- withdraw(amount, receiver) + - Convenience wrapper that computes shares = preview_withdraw(amount) and calls redeem(shares, receiver). +- redeem(shares, receiver) + - Escrows the caller’s shares in the vault and starts a withdrawal operation. + +Operational flow +1) Escrow shares + - redeem(shares) transfers the shares from the caller into the vault (escrow). Shares are not burned yet. + +2) Accrue fees and compute targets + - The vault accrues any pending performance fee, then computes the underlying amount to return for the given shares. + +3) Consume idle liquidity first + - The vault immediately uses idle_balance (underlying already held in the vault) to cover part of the request, if available. + +4) Iterate markets in withdraw_queue + - For the remaining amount, the vault iterates withdraw_queue in order. + - For each market, it requests and executes a supply withdrawal on that market (create_supply_withdrawal_request + execute_next_supply_withdrawal_request). + - After execution, the vault reads the market position to reconcile the new principal and determine how much underlying actually came back. + +5) Payout on full fulfillment + - When collected == requested (remaining == 0), the vault transfers underlying to receiver. + - On transfer success: idle_balance decreases by the payout and escrowed shares are burned. + - On transfer failure: escrowed shares are returned to the owner; idle_balance remains unchanged. + +6) Insufficient liquidity (end of queue) + - If the vault reaches the end of withdraw_queue with remaining > 0: + - Escrowed shares are returned to the owner. + - Any partial funds that did come back from markets remain in the vault’s idle_balance (they are not paid out). + - The withdrawal operation stops. Callers can retry later. + +Events to watch +- RedeemRequested { shares, estimated_assets } – emitted when a withdrawal begins. +- WithdrawalPositionMissing / WithdrawalPositionReadFailed – diagnostics when reading a market position after a withdrawal step fails. +- WithdrawalStopped { remaining, collected, reason } – emitted when the withdrawal stops without completing (e.g., InsufficientLiquidity). +- PayoutStopped / PayoutUnexpectedState – diagnostics for payout errors. +- Note: There is currently no explicit “payout succeeded” event; payout success is the normal completion path. + +Design rationale (simplicity) +- No persistent queue in the vault: fewer invariants, fewer public methods, and no long-lived state. +- Users/integrators can pre-check with preview_withdraw(amount) to estimate shares and can retry later if markets are illiquid. + +Integrator tips +- Prefer preview_withdraw(amount) to understand the share cost beforehand. +- To reduce the chance of stopping due to illiquidity, withdraw smaller amounts that can be satisfied by idle_balance or by likely-available market liquidity. +- Monitor events: + - Successful payout: the final callback after_send_to_user returns success (no explicit event). + - Stopped without payout: WithdrawalStopped will include remaining and collected amounts and a reason. + +Future enhancements considered +- Vault-level queued withdrawals (keep shares escrowed and resume later) can improve UX during illiquid periods, but add complexity (queue state, execution/cancel flows, griefing protections). The current implementation intentionally opts for the simpler “best-effort now” model. diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 261ada3d..c0a25dc6 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -381,6 +381,7 @@ impl Contract { amount: collected, owner: owner.clone(), escrow_shares, + burn_shares: escrow_shares, }; PromiseOrValue::Promise( self.underlying_asset @@ -425,14 +426,15 @@ impl Contract { receiver: AccountId, amount: U128, ) -> bool { - let (owner, escrow_shares, payout_amount) = match &self.op_state { + let (owner, escrow_shares, payout_amount, burn_shares) = match &self.op_state { OpState::Payout { op_id: cur, receiver: r, amount: a, owner, escrow_shares, - } if *cur == op_id && *r == receiver => (owner.clone(), *escrow_shares, *a), + burn_shares, + } if *cur == op_id && *r == receiver => (owner.clone(), *escrow_shares, *a, *burn_shares), _ => { Event::PayoutUnexpectedState { op_id, @@ -445,14 +447,24 @@ impl Contract { }; if let Ok(()) = result { - // Invariant: On payout success, idle_balance -= payout_amount and escrowed shares are burned + // Invariant: On payout success, idle_balance -= payout_amount. + // Burn only the proportional shares and refund the remainder to the owner. self.idle_balance = self.idle_balance.saturating_sub(payout_amount); - self.withdraw_unchecked(&env::current_account_id(), escrow_shares) - .expect("Failed to burn escrowed shares"); + let to_burn = burn_shares.min(escrow_shares); + if to_burn > 0 { + self.withdraw_unchecked(&env::current_account_id(), to_burn) + .expect("Failed to burn escrowed shares"); + } + let refund_shares = escrow_shares.saturating_sub(to_burn); + if refund_shares > 0 { + #[allow(clippy::expect_used, reason = "No side effects")] + self.transfer_unchecked(&env::current_account_id(), &owner, refund_shares) + .expect("Failed to refund remaining escrowed shares"); + } self.op_state = OpState::Idle; true } else { - // Invariant: On payout failure, refund escrow to owner and leave idle_balance unchanged + // Invariant: On payout failure, refund full escrow to owner and leave idle_balance unchanged #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&env::current_account_id(), &owner, escrow_shares) .expect("Failed to release escrowed shares"); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 9d38238d..6986009e 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -44,6 +44,7 @@ pub enum StorageKey { SupplyQueue, WithdrawQueue, MarketSupply, + PendingWithdrawals, } #[derive(BorshStorageKey)] @@ -61,6 +62,16 @@ pub enum Role { Allocator, } +#[derive(Clone, Debug)] +#[near(serializers = [json, borsh])] +pub struct PendingWithdrawal { + pub owner: AccountId, + pub receiver: AccountId, + pub escrow_shares: u128, + pub expected_assets: u128, + pub requested_at: u64, +} + #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] // FIXME: #[nep145(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] @@ -112,6 +123,11 @@ pub struct Contract { // Storage usage storage_usage_supply: u64, storage_usage_role: u64, + + // Pending withdrawals queue (vault-level, FIFO by id) + pending_withdrawals: IterableMap, + next_withdraw_id: u64, + next_withdraw_to_execute: u64, } #[near] @@ -193,6 +209,11 @@ impl Contract { storage_usage_role, mode, plan: None, + + // Pending withdrawals init + pending_withdrawals: IterableMap::new(key!(PendingWithdrawals)), + next_withdraw_id: 0, + next_withdraw_to_execute: 0, }; contract.set_metadata(&ContractMetadata::new(name, symbol, decimals)); Owner::init(&mut contract, &owner); @@ -701,10 +722,37 @@ impl Contract { estimated_assets: U128(assets), } .emit(); - self.start_withdraw(assets, receiver.clone(), owner, shares) + + self.enqueue_pending_withdrawal(&owner, &receiver, shares, assets); + PromiseOrValue::Value(()) + } + + /// Executes the next pending withdrawal request, if any, using the existing withdraw pipeline. + /// This defers creating market-side withdrawal requests until explicitly invoked. + pub fn execute_next_withdrawal_request(&mut self) -> PromiseOrValue<()> { + self.ensure_idle(); + Self::assert_allocator(); + + // Find the next present pending withdrawal by id + let mut id = self.next_withdraw_to_execute; + while id < self.next_withdraw_id { + if let Some(pending) = self.pending_withdrawals.remove(&id) { + // Advance the head pointer and start processing + self.next_withdraw_to_execute = id.saturating_add(1); + return self.start_withdraw( + pending.expected_assets, + pending.receiver, + pending.owner, + pending.escrow_shares, + ); + } + id = id.saturating_add(1); + self.next_withdraw_to_execute = id; + } + + PromiseOrValue::Value(()) } - /* ----- Skim (sends entire balance of `token` to `skim_recipient`) ----- */ /// 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(); @@ -728,7 +776,7 @@ impl Contract { // If no weights provided, use queue order; clamp total and emit request event. if weights.is_empty() { - let requested: u128 = amount.map(|x| x.0).unwrap_or(self.idle_balance); + let requested: u128 = amount.map_or(self.idle_balance, |x| x.0); let max_room = self.get_max_deposit().0; let total = requested.min(self.idle_balance).min(max_room); if total == 0 { @@ -758,7 +806,7 @@ impl Contract { } // Clamp total allocation by idle balance and aggregate room - let requested: u128 = amount.map(|x| x.0).unwrap_or(self.idle_balance); + let requested: u128 = amount.map_or(self.idle_balance, |x| x.0); let max_room = self.get_max_deposit().0; let total = requested.min(self.idle_balance).min(max_room); if total == 0 { @@ -932,6 +980,40 @@ impl Contract { /* ----- Private Helpers ----- */ impl Contract { + /// Enqueue a vault-level pending withdrawal request (escrow already taken). + fn enqueue_pending_withdrawal( + &mut self, + owner: &AccountId, + receiver: &AccountId, + escrow_shares: u128, + expected_assets: u128, + ) { + let id = self.next_withdraw_id; + self.next_withdraw_id = self.next_withdraw_id.saturating_add(1); + let requested_at = env::block_timestamp(); + + self.pending_withdrawals.insert( + id, + PendingWithdrawal { + owner: owner.clone(), + receiver: receiver.clone(), + escrow_shares, + expected_assets, + requested_at, + }, + ); + + Event::WithdrawalQueued { + id, + owner: owner.clone(), + receiver: receiver.clone(), + escrow_shares: U128(escrow_shares), + expected_assets: U128(expected_assets), + requested_at, + } + .emit(); + } + /// Computes fee-aware effective totals for conversions, mimicking MetaMorpho: /// - Include fee shares that would be minted if fees accrued now. /// - Apply virtual offsets: +virtual_shares to supply and +virtual_assets to assets. @@ -1212,51 +1294,46 @@ impl Contract { } fn step_withdraw(&mut self) -> PromiseOrValue<()> { - let (op_id, index, remaining, receiver, collected, owner, escrow_shares) = - match &self.op_state { - OpState::Withdrawing { - op_id, - index, - remaining, - receiver, - collected, - owner, - escrow_shares, - } => ( - *op_id, - *index, - *remaining, - receiver.clone(), - *collected, - owner.clone(), - *escrow_shares, - ), - _ => return self.stop_and_exit(Some("Not withdrawing")), - }; + let (op_id, index, remaining, receiver, collected, owner, escrow_shares) = match &self + .op_state + { + OpState::Withdrawing { + op_id, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + } => ( + *op_id, + *index, + *remaining, + receiver.clone(), + *collected, + owner.clone(), + *escrow_shares, + ), + _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), + }; if remaining == 0 { - if collected > 0 { - self.op_state = OpState::Payout { - op_id, - receiver: receiver.clone(), - amount: collected, - owner: owner.clone(), - escrow_shares, - }; - return PromiseOrValue::Promise( - self.underlying_asset - .transfer(receiver.clone(), U128(collected).into()) - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, receiver, U128(collected)), - ), - ); - } - // Nothing collected; refund escrowed shares - let self_id = env::current_account_id(); - self.transfer_unchecked(&self_id, &owner, escrow_shares) - .expect("Failed to release escrowed shares"); - return self.stop_and_exit::(None); + self.op_state = OpState::Payout { + op_id, + receiver: receiver.clone(), + amount: collected, + owner: owner.clone(), + escrow_shares, + burn_shares: escrow_shares, + }; + return PromiseOrValue::Promise( + self.underlying_asset + .transfer(receiver.clone(), U128(collected).into()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) + .after_send_to_user(op_id, receiver, U128(collected)), + ), + ); } if let Some(market) = self.withdraw_queue.get(index) { let have = self.market_supply.get(market).unwrap_or(&0); @@ -1285,11 +1362,35 @@ impl Contract { ), ) } else { - // Insufficient liquidity across all markets: refund escrowed shares and stop - let self_id = env::current_account_id(); - self.transfer_unchecked(&self_id, &owner, escrow_shares) - .expect("Failed to release escrowed shares"); - self.stop_and_exit(Some(&Error::InsufficientLiquidity)) + // End of withdraw queue. If we collected something, pay it out now and burn proportional shares. + if collected > 0 { + let requested = collected.saturating_add(remaining); + let burn_shares = + crate::wad::mul_div_floor(escrow_shares, collected, requested.max(1)); + self.op_state = OpState::Payout { + op_id, + receiver: receiver.clone(), + amount: collected, + owner: owner.clone(), + escrow_shares, + burn_shares, + }; + return PromiseOrValue::Promise( + self.underlying_asset + .transfer(receiver.clone(), U128(collected).into()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) + .after_send_to_user(op_id, receiver, U128(collected)), + ), + ); + } else { + // Nothing collected at all; refund escrow and stop with InsufficientLiquidity + let self_id = env::current_account_id(); + self.transfer_unchecked(&self_id, &owner, escrow_shares) + .expect("Failed to release escrowed shares"); + return self.stop_and_exit(Some(&Error::InsufficientLiquidity)); + } } } } diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 0f779bfc..7216bf13 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -1,6 +1,9 @@ -use near_sdk::json_types::U128; +use near_sdk::{json_types::U128, AccountId}; use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; -use test_utils::{setup_test, setup_test_w, ContractController}; +use test_utils::{ + controller::vault::UnifiedVaultController, setup_test, setup_test_w, ContractController, + MarketController, UnifiedMarketController, +}; #[tokio::test] async fn happy() { @@ -12,8 +15,6 @@ async fn happy() { InterestRateStrategy::linear(Decimal::ZERO, Decimal::ZERO).unwrap(); }) ); - - c.init_account(&supply_user).await; vault.init_account(&supply_user).await; let v = vault.contract().id(); @@ -52,19 +53,7 @@ async fn happy() { "Supply position should match amount of tokens supplied to contract", ); - // Wait for activation. - while !c - .get_supply_position(v) - .await - .unwrap() - .get_deposit() - .incoming - .is_empty() - { - // TODO: should also do this in allocate - c.harvest_yield(vault.contract().as_account(), None, None) - .await; - } + harvest(&c, &vault).await; let supply_position = c.get_supply_position(v).await.unwrap(); @@ -77,6 +66,8 @@ async fn happy() { let user_balance = c.borrow_asset.balance_of(supply_user.id()).await; vault.withdraw(&supply_user, amount, None).await; + // TODO: assert the user now escrowed their shares + vault.execute_next_withdrawal(&vault_curator).await; assert_eq!( c.borrow_asset.balance_of(supply_user.id()).await, @@ -96,8 +87,27 @@ async fn happy() { 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; + harvest(&c, &vault).await; + + println!( + "Balance of the market for the collateral asset: {}", + c.borrow_asset.balance_of(c.market.contract().id()).await + ); + + let borrowed = amount.0 / 2; + + c.borrow(&borrow_user, borrowed).await; + + vault + .withdraw(&supply_user, (amount.0 - borrowed).into(), None) + .await; +} + +// FIXME: should also do this in allocate on behalf of the vault? +pub async fn harvest(c: &UnifiedMarketController, vault: &UnifiedVaultController) { + // Wait for activation. while !c - .get_supply_position(v) + .get_supply_position(vault.contract().id()) .await .unwrap() .get_deposit() @@ -108,15 +118,4 @@ async fn happy() { c.harvest_yield(vault.contract().as_account(), None, None) .await; } - - println!( - "Balance of the market for the collateral asset: {}", - c.borrow_asset.balance_of(c.market.contract().id()).await - ); - - c.borrow(&borrow_user, 500).await; - - // TODO: what happens if we try to withdraw now? - // - vault.withdraw(&supply_user, amount, None).await; } diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 9cfaa37c..bbd136ac 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -1,12 +1,8 @@ -// TODO: single-op state machine, all mutators must be idle -// TODO: every callback must be for the current op and market index - -// TODO: supply queue must never have duplicates - -// TODO: fee accruel must only happen when AUM grows +// TODO(unit): single-op state machine, all mutators must be idle +// TODO(prop): every callback must be for the current op and market index // Allocations -// TODO: on allocation-failure, reconcile to idle +// TODO(unit?): on allocation-failure, reconcile to idle // TODO: allocation accounting: Accepted amount = new_principal - before &never more than attempted // TODO: allocation attempts: any market that is enabled (new_principal > 0) must be in the withdraw queue @@ -33,10 +29,115 @@ // TODO: on error, assume no risk // + +use near_sdk::{json_types::U128, AccountId}; +use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; +use test_utils::{ + controller::vault::UnifiedVaultController, setup_test, setup_test_w, ContractController, + MarketController, UnifiedMarketController, +}; + +#[tokio::test] +async fn supply_queue_mustnt_have_duplicates() {} + +#[tokio::test] +async fn withdraw_queue_mustnt_have_duplicates() {} + +#[tokio::test] +async fn fee_accrual_only_when_aum_grows() {} + +// #[tokio::test] +// async fn happy() { +// setup_test!( +// extract(vault, c, vault_curator) +// accounts(supply_user, borrow_user) +// config(|c| { +// c.borrow_interest_rate_strategy = +// InterestRateStrategy::linear(Decimal::ZERO, Decimal::ZERO).unwrap(); +// }) +// ); +// vault.init_account(&supply_user).await; +// +// let v = vault.contract().id(); +// let amount: U128 = 1000.into(); // +// vault.supply(&supply_user, amount.0).await; +// c.collateralize(&borrow_user, 2000).await; // - -// TODO: test harness -// We need: -// - market setup (using market::setup_test) -// - vault version of setup_test, utilising the market principal +// let weights = vec![(c.market.contract().id().clone(), U128(1))]; +// vault +// .allocate(&vault_curator, weights.clone(), Some(amount)) +// .await; +// +// assert_eq!( +// c.borrow_asset.balance_of(vault.contract().id()).await, +// 0, +// "Vault should not have any assets leftover after rebalancing 100%" +// ); +// assert_eq!( +// vault.get_total_supply().await, +// amount, +// "Vault should have issued shares to the supplier" +// ); +// assert_eq!( +// vault.get_total_assets().await, +// amount, +// "Vault should appropriately track assets" +// ); +// assert_eq!( +// c.get_supply_position(v) +// .await +// .unwrap() +// .get_deposit() +// .total(), +// amount.into(), +// "Supply position should match amount of tokens supplied to contract", +// ); +// +// harvest(&c, &vault).await; +// +// let supply_position = c.get_supply_position(v).await.unwrap(); +// +// assert_eq!( +// u128::from(supply_position.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; +// +// vault.withdraw(&supply_user, amount, None).await; +// +// assert_eq!( +// c.borrow_asset.balance_of(supply_user.id()).await, +// amount.0 + user_balance, +// "Supply user should have received their tokens back" +// ); +// +// let supply_position = c.get_supply_position(v).await; +// assert!( +// supply_position.is_none(), +// "Supply position should be closed" +// ); +// +// c.storage_deposits(vault.contract().as_account()).await; +// +// // 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; +// harvest(&c, &vault).await; +// +// println!( +// "Balance of the market for the collateral asset: {}", +// c.borrow_asset.balance_of(c.market.contract().id()).await +// ); +// +// let borrowed = amount.0 / 2; +// +// c.borrow(&borrow_user, borrowed).await; +// +// vault +// .withdraw(&supply_user, (amount.0 - borrowed).into(), None) +// .await; +// } diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index f2bcb7a0..5ae5ea53 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -83,9 +83,12 @@ impl VaultController { #[call(exec, tgas(300))] pub fn allocate(weights: AllocationWeights, amount: Option); - #[call(exec, tgas(300))] + #[call(exec, tgas(30))] pub fn withdraw(amount: U128, receiver: AccountId); + #[call(exec, tgas(300))] + pub fn execute_next_withdrawal_request(); + // User redemption path; expects escrowed shares already held by the contract. #[call(exec, tgas(300))] pub fn redeem(shares: U128, receiver: AccountId); @@ -306,4 +309,11 @@ impl UnifiedVaultController { print_execution(&e); } } + + pub async fn execute_next_withdrawal(&self, allocator: &Account) { + let e = self.vault.execute_next_withdrawal_request(allocator).await; + if self.debug { + print_execution(&e); + } + } } From 6f397d00a9b74b934c4c3a396608dd3b45373994 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 9 Oct 2025 11:05:12 +0100 Subject: [PATCH 016/121] test: locked state machine --- contract/vault/tests/happy_path.rs | 2 + contract/vault/tests/invariants.rs | 69 +++++++++++++++++++----------- test-utils/src/controller/vault.rs | 47 +++++++++++++------- 3 files changed, 76 insertions(+), 42 deletions(-) diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 7216bf13..e282b614 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -101,6 +101,8 @@ async fn happy() { vault .withdraw(&supply_user, (amount.0 - borrowed).into(), None) .await; + + vault.execute_next_withdrawal(&vault_curator).await; } // FIXME: should also do this in allocate on behalf of the vault? diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index bbd136ac..f8bd8543 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -1,41 +1,37 @@ -// TODO(unit): single-op state machine, all mutators must be idle -// TODO(prop): every callback must be for the current op and market index +use near_sdk::{json_types::U128, AccountId}; +use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; +use test_utils::{ + controller::vault::UnifiedVaultController, setup_test, setup_test_w, ContractController, + MarketController, UnifiedMarketController, +}; -// Allocations // TODO(unit?): on allocation-failure, reconcile to idle -// TODO: allocation accounting: Accepted amount = new_principal - before &never more than attempted -// TODO: allocation attempts: any market that is enabled (new_principal > 0) must be in the withdraw queue + +// TODO(prop): every callback must be for the current op and market index +// TODO(prop): allocation accounting: Accepted amount = new_principal - before &never more than attempted +// TODO(prop): allocation attempts: any market that is enabled (new_principal > 0) must be in the withdraw queue +// TODO(prop): withdraw queue must never have duplicates +// TODO(prop): enabling a market (cap > 0) must add it to the withdraw queue // Withdraws -// TODO: try withdraw & idle first: idle balance can be utilised on a first-come-first-serve basis => it +// TODO(integration): try withdraw & idle first: idle balance can be utilised on a first-come-first-serve basis => it // is **not** deducted until payout succeeds -// TODO: create withdraw: if create withdraw fails, skip to next market -// TODO: execute withdraw: if executing a withdrawal fails, assume nothing changed -// TODO: withdrawn(execute > read): withdrawn credits must increase idle balance -// TODO: withdraw queue must never have duplicates -// TODO: enabling a market (cap > 0) must add it to the withdraw queue +// TODO(integration): create withdraw: if create withdraw fails, skip to next market +// TODO(integration): execute withdraw: if executing a withdrawal fails, assume nothing changed +// TODO(integration): withdrawn(execute > read): withdrawn credits must increase idle balance // TODO: Skim: is no-op when balance is 0 // Payouts -// TODO: payout success: idle balance must decrease & burn escrowed shares -// TODO: payout failure: idle doesnt change & refund escrowed shares to original owner +// TODO(integration): payout success: idle balance must decrease & burn escrowed shares +// TODO(integration): payout failure: idle doesnt change & refund escrowed shares to original owner -// TODO: stop and exit: must never mutiny funds or escrow +// TODO(integration): single-op state machine, all mutators must be idle +// TODO(integration): stop and exit: must never mutiny funds or escrow -// TODO: credit principal only after proper supply to marfket +// Note: happy path?: credit principal only after proper supply to marfket -// TODO: Withdraw read onlky credits idle - -// TODO: on error, assume no risk -// - -use near_sdk::{json_types::U128, AccountId}; -use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; -use test_utils::{ - controller::vault::UnifiedVaultController, setup_test, setup_test_w, ContractController, - MarketController, UnifiedMarketController, -}; +// TODO: Withdraw read only credits idle #[tokio::test] async fn supply_queue_mustnt_have_duplicates() {} @@ -46,6 +42,27 @@ async fn withdraw_queue_mustnt_have_duplicates() {} #[tokio::test] async fn fee_accrual_only_when_aum_grows() {} +#[tokio::test] +#[should_panic = "busy"] +async fn state_machine_is_locked_when_another_op_is_running() { + setup_test!( + extract(vault, c, vault_curator) + accounts(supply_user, borrow_user) + ); + let amount = 1000; + let m = c.market.contract().id().clone(); + vault.supply(&supply_user, amount).await; + + let queue = vec![m.clone()]; + tokio::join!( + vault.allocate(&vault_curator, vec![], Some(amount.into())), + vault.submit_cap(&vault_curator, m.clone(), (amount * 2).into()), + vault.set_supply_queue(&vault_curator, &queue), + vault.set_withdraw_queue(&vault_curator, &queue), + vault.allocate(&vault_curator, vec![], Some(amount.into())), + ); +} + // #[tokio::test] // async fn happy() { // setup_test!( diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 5ae5ea53..76e8a776 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -260,25 +260,12 @@ impl UnifiedVaultController { } pub async fn setup_caps(&self, owner: &Account, markets: &[AccountId], amount: u128) { - let mut submits = vec![]; - let mut accepts = vec![]; - for mkt in markets { - submits.push(self.submit_cap(owner, mkt.clone(), amount).await); - accepts.push(self.accept_cap(owner, mkt.clone()).await); + self.submit_cap(owner, mkt.clone(), amount.into()).await; + self.accept_cap(owner, mkt.clone()).await; } - let set_queue = self.set_supply_queue(&owner, markets).await; - - if self.debug { - for s in submits { - print_execution(&s); - } - for a in accepts { - print_execution(&a); - } - print_execution(&set_queue); - } + self.set_supply_queue(owner, markets).await; } pub async fn allocate( @@ -316,4 +303,32 @@ impl UnifiedVaultController { print_execution(&e); } } + + pub async fn submit_cap(&self, submitter: &Account, market: AccountId, amount: U128) { + let e = self.vault.submit_cap(submitter, market, amount).await; + if self.debug { + print_execution(&e); + } + } + + pub async fn accept_cap(&self, acceptor: &Account, market: AccountId) { + let e = self.vault.accept_cap(acceptor, market).await; + if self.debug { + print_execution(&e); + } + } + + pub async fn set_supply_queue(&self, allocator: &Account, markets: &[AccountId]) { + let e = self.vault.set_supply_queue(allocator, markets).await; + if self.debug { + print_execution(&e); + } + } + + pub async fn set_withdraw_queue(&self, allocator: &Account, markets: &[AccountId]) { + let e = self.vault.set_withdraw_queue(allocator, markets).await; + if self.debug { + print_execution(&e); + } + } } From 8f04378d4f45001f12bb00d9db63f84a1b7addd0 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 9 Oct 2025 11:05:25 +0100 Subject: [PATCH 017/121] test: duplicate markets --- contract/vault/tests/invariants.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index f8bd8543..a8eb56ed 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -12,6 +12,7 @@ use test_utils::{ // TODO(prop): allocation attempts: any market that is enabled (new_principal > 0) must be in the withdraw queue // TODO(prop): withdraw queue must never have duplicates // TODO(prop): enabling a market (cap > 0) must add it to the withdraw queue +// TODO(prop): stop and exit: must never mutiny funds or escrow // Withdraws // TODO(integration): try withdraw & idle first: idle balance can be utilised on a first-come-first-serve basis => it @@ -27,17 +28,36 @@ use test_utils::{ // TODO(integration): payout failure: idle doesnt change & refund escrowed shares to original owner // TODO(integration): single-op state machine, all mutators must be idle -// TODO(integration): stop and exit: must never mutiny funds or escrow // Note: happy path?: credit principal only after proper supply to marfket // TODO: Withdraw read only credits idle #[tokio::test] -async fn supply_queue_mustnt_have_duplicates() {} +#[should_panic = "Duplicate market"] +async fn supply_queue_mustnt_have_duplicates() { + setup_test!( + extract(vault, c, vault_curator) + accounts(supply_user, borrow_user) + ); + let m = c.market.contract().id().clone(); + + let queue = vec![m.clone(), m.clone()]; + vault.set_supply_queue(&vault_curator, &queue).await; +} #[tokio::test] -async fn withdraw_queue_mustnt_have_duplicates() {} +#[should_panic = "Duplicate market"] +async fn withdraw_queue_mustnt_have_duplicates() { + setup_test!( + extract(vault, c, vault_curator) + accounts(supply_user, borrow_user) + ); + let m = c.market.contract().id().clone(); + + let queue = vec![m.clone(), m.clone()]; + vault.set_withdraw_queue(&vault_curator, &queue).await; +} #[tokio::test] async fn fee_accrual_only_when_aum_grows() {} From 48005dd0152cfa72d68dce3c8c2c7bde3db07f80 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 9 Oct 2025 15:18:44 +0100 Subject: [PATCH 018/121] chore: default alloc mode --- common/src/vault.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 4b273d2f..4011ee43 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -15,7 +15,7 @@ pub type ActualIdx = u32; pub type AllocationWeights = Vec<(AccountId, U128)>; pub type AllocationPlan = Vec<(AccountId, u128)>; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] #[near(serializers = [json, borsh])] pub enum AllocationMode { // When eager makes sense @@ -37,7 +37,10 @@ pub enum AllocationMode { // 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 }, + Eager { + min_batch: u128, + }, + #[default] Lazy, } /// Parsed from the string parameter `msg` passed by `*_transfer_call` to From 18bc866b8c0d93d83884bb7fef8dffbfaf4b1b4e Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 9 Oct 2025 17:44:32 +0100 Subject: [PATCH 019/121] test: cb tests --- contract/vault/src/impl_callbacks.rs | 175 ++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index c0a25dc6..9e634582 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -434,7 +434,9 @@ impl Contract { owner, escrow_shares, burn_shares, - } if *cur == op_id && *r == receiver => (owner.clone(), *escrow_shares, *a, *burn_shares), + } if *cur == op_id && *r == receiver => { + (owner.clone(), *escrow_shares, *a, *burn_shares) + } _ => { Event::PayoutUnexpectedState { op_id, @@ -642,3 +644,174 @@ impl Contract { PromiseOrValue::Value(()) } } + +#[cfg(test)] +mod tests { + use crate::Contract; + use near_sdk::json_types::U128; + use near_sdk::test_utils::{accounts, VMContextBuilder}; + use near_sdk::{test_utils::testing_env_with_promise_results, AccountId, PromiseOrValue}; + use near_sdk::{test_vm_config, testing_env, PromiseResult, RuntimeFeesConfig}; + use templar_common::asset::{BorrowAsset, FungibleAsset}; + use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; + use test_utils::vault_configuration; + + fn mk(n: u32) -> AccountId { + format!("acc{n}.testnet").parse().expect("valid account id") + } + + fn setup_env( + current: &AccountId, + predecessor: &AccountId, + promise_results: Vec, + ) { + let mut builder = VMContextBuilder::new(); + builder.current_account_id(current.clone()); + builder.predecessor_account_id(predecessor.clone()); + builder.signer_account_id(predecessor.clone()); + testing_env!( + builder.build(), + test_vm_config(), + RuntimeFeesConfig::test(), + Default::default(), + promise_results + ); + } + + fn new_test_contract(vault_id: &AccountId) -> Contract { + // Ensure env is available before constructing the contract (uses env::storage_usage etc). + setup_env(vault_id, vault_id, vec![]); + + // Basic accounts + let owner = accounts(1); + let curator = accounts(2); + let guardian = accounts(3); + let fee_recipient = accounts(4); + let skim_recipient = accounts(5); + let underlying_token_id = mk(6); + + let cfg = vault_configuration( + owner, + curator, + guardian, + underlying_token_id, + skim_recipient, + fee_recipient, + ); + + Contract::new(cfg) + } + + #[test] + fn after_send_to_user_success_no_escrow() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + let receiver = mk(7); + + // Seed idle balance and set Payout state; use zero escrow/burn to avoid FT side-effects in unit test. + c.idle_balance = 1_000; + c.op_state = OpState::Payout { + op_id: 1, + receiver: receiver.clone(), + amount: 200, + owner: accounts(1), + escrow_shares: 0, + burn_shares: 0, + }; + + // Provide a successful callback result + let ok = c.after_send_to_user(Ok(()), 1, receiver.clone(), U128(200)); + assert!(ok, "Payout should report success"); + assert_eq!(c.idle_balance, 800, "Idle balance must decrease by payout"); + assert!( + matches!(c.op_state, OpState::Idle), + "Vault must go Idle after successful payout" + ); + } + + #[test] + fn after_exec_withdraw_read_none_to_payout() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + // Prepare a single-market withdraw queue with non-zero principal + let market = mk(8); + c.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 100); + + // Withdrawing: need 60, already collected 10; expect position None => new_principal = 0, withdrawn = 100, credited = min(100, 60) = 60 + c.op_state = OpState::Withdrawing { + op_id: 42, + index: 0, + remaining: 60, + receiver: mk(9), + collected: 10, + owner: accounts(1), + escrow_shares: 50, + }; + + let res = c.after_exec_withdraw_read(Ok(None), 42, 0, U128(100), U128(60)); + + // Should schedule payout (Promise) after crediting and zeroing remaining + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected a Promise to send payout"), + } + + // Market principal should be zeroed + assert_eq!( + *c.market_supply.get(&market).unwrap_or(&u128::MAX), + 0, + "Market principal should be updated to 0" + ); + + // Idle balance should be credited by 60 + assert_eq!( + c.idle_balance, 60, + "Idle balance should increase by credited amount" + ); + + // State should transition to Payout with amount = collected (10) + credited (60) = 70 + match &c.op_state { + OpState::Payout { amount, .. } => { + assert_eq!(*amount, 70, "Payout amount must match collected + credited"); + } + other => panic!("Unexpected state after read: {:?}", other), + } + } + + #[test] + fn after_skim_balance_zero_noop() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + // Zero balance -> Value(()) + let res = c.after_skim_balance(Ok(U128(0)), mk(10), mk(11)); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Skim with zero balance must be a no-op"), + } + } + + #[test] + fn after_skim_balance_positive_returns_promise() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + // Positive balance -> Promise to ft_transfer + let res = c.after_skim_balance(Ok(U128(123)), mk(10), mk(11)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Skim with positive balance must return a Promise"), + } + } +} From 3f22537b0384816ffa803100a6983ac6c17877a2 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 9 Oct 2025 18:27:16 +0100 Subject: [PATCH 020/121] test: more cb tests --- common/src/vault.rs | 2 +- contract/vault/src/impl_callbacks.rs | 113 +++++++++++++++++--------- contract/vault/src/lib.rs | 116 +++++++++++++++++++++++++++ contract/vault/tests/invariants.rs | 1 - 4 files changed, 193 insertions(+), 39 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 4011ee43..c57fbb87 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -108,7 +108,7 @@ pub struct PendingValue { pub valid_at: TimestampNs, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [json, borsh])] /// Operation state machine for asynchronous allocation, withdrawal, and payout flows. pub enum OpState { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 9e634582..d64474b2 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -647,6 +647,9 @@ impl Contract { #[cfg(test)] mod tests { + use std::u128; + + use crate::test_utils::*; use crate::Contract; use near_sdk::json_types::U128; use near_sdk::test_utils::{accounts, VMContextBuilder}; @@ -656,50 +659,83 @@ mod tests { use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; use test_utils::vault_configuration; - fn mk(n: u32) -> AccountId { - format!("acc{n}.testnet").parse().expect("valid account id") + #[test] + fn after_supply_1_check_allocating_not_allocating() { + let vault_id = accounts(0); + setup_env( + &vault_id, + &vault_id, + vec![PromiseResult::Successful( + near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap(), + )], + ); + + let mut c = new_test_contract(&vault_id); + + let receiver = mk(7); + + c.op_state = OpState::Idle; + + c.after_supply_1_check(Ok(U128(1)), 0, 2, Default::default()); + + assert_eq!(c.op_state, OpState::Idle); + assert_eq!(c.plan, None); } - fn setup_env( - current: &AccountId, - predecessor: &AccountId, - promise_results: Vec, - ) { - let mut builder = VMContextBuilder::new(); - builder.current_account_id(current.clone()); - builder.predecessor_account_id(predecessor.clone()); - builder.signer_account_id(predecessor.clone()); - testing_env!( - builder.build(), - test_vm_config(), - RuntimeFeesConfig::test(), - Default::default(), - promise_results + #[test] + fn after_supply_1_check_allocating_not_allocating_index() { + let vault_id = accounts(0); + setup_env( + &vault_id, + &vault_id, + vec![PromiseResult::Successful( + near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap(), + )], ); + + let mut c = new_test_contract(&vault_id); + + let op_id = 1; + let receiver = mk(7); + + c.op_state = OpState::Allocating { + op_id, + index: 0u32, + remaining: 0u128, + }; + + c.after_supply_1_check(Ok(U128(1)), op_id + 1, 0, Default::default()); + + assert_eq!(c.op_state, OpState::Idle); + assert_eq!(c.plan, None); } - fn new_test_contract(vault_id: &AccountId) -> Contract { - // Ensure env is available before constructing the contract (uses env::storage_usage etc). - setup_env(vault_id, vault_id, vec![]); - - // Basic accounts - let owner = accounts(1); - let curator = accounts(2); - let guardian = accounts(3); - let fee_recipient = accounts(4); - let skim_recipient = accounts(5); - let underlying_token_id = mk(6); - - let cfg = vault_configuration( - owner, - curator, - guardian, - underlying_token_id, - skim_recipient, - fee_recipient, + #[test] + fn after_supply_1_check_allocating() { + let vault_id = accounts(0); + setup_env( + &vault_id, + &vault_id, + vec![PromiseResult::Successful( + near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap(), + )], ); - Contract::new(cfg) + let mut c = new_test_contract(&vault_id); + + let op_id = 1; + let receiver = mk(7); + + c.op_state = OpState::Allocating { + op_id, + index: 0u32, + remaining: 0u128, + }; + + c.after_supply_1_check(Ok(U128(1)), op_id, 0, Default::default()); + + assert_eq!(c.op_state, OpState::Idle); + assert_eq!(c.plan, None); } #[test] @@ -814,4 +850,7 @@ mod tests { _ => panic!("Skim with positive balance must return a Promise"), } } + + #[test] + fn stop_and_exit_same_op() {} } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 6986009e..4b364439 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1394,3 +1394,119 @@ impl Contract { } } } + +#[cfg(test)] +mod test_utils { + use crate::Contract; + use near_sdk::{ + test_utils::{accounts, VMContextBuilder}, + test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, + }; + use rstest::rstest; + use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; + use test_utils::vault_configuration; + + pub fn mk(n: u32) -> AccountId { + format!("acc{n}.testnet").parse().expect("valid account id") + } + + pub fn setup_env( + current: &AccountId, + predecessor: &AccountId, + promise_results: Vec, + ) { + let mut builder = VMContextBuilder::new(); + builder.current_account_id(current.clone()); + builder.predecessor_account_id(predecessor.clone()); + builder.signer_account_id(predecessor.clone()); + testing_env!( + builder.build(), + test_vm_config(), + RuntimeFeesConfig::test(), + Default::default(), + promise_results + ); + } + + pub fn new_test_contract(vault_id: &AccountId) -> Contract { + // Ensure env is available before constructing the contract (uses env::storage_usage etc). + setup_env(vault_id, vault_id, vec![]); + + // Basic accounts + let owner = accounts(1); + let curator = accounts(2); + let guardian = accounts(3); + let fee_recipient = accounts(4); + let skim_recipient = accounts(5); + let underlying_token_id = mk(6); + + let cfg = vault_configuration( + owner, + curator, + guardian, + underlying_token_id, + skim_recipient, + fee_recipient, + ); + + Contract::new(cfg) + } +} + +#[cfg(test)] +mod tests { + use crate::test_utils::*; + use crate::Contract; + use near_sdk::test_utils::accounts; + use near_sdk::{json_types::U128, test_utils::VMContextBuilder, AccountId, RuntimeFeesConfig}; + use near_sdk::{test_vm_config, testing_env}; + use rstest::rstest; + use templar_common::asset::{BorrowAsset, FungibleAsset}; + use templar_common::vault::{AllocationMode, VaultConfiguration}; + + #[rstest(len => [2usize, 3, 5])] + #[should_panic = "Duplicate market"] + fn prop_supply_queue_mustnt_have_duplicates(len: usize) { + let mut c = new_test_contract(&mk(0)); + setup_env(&accounts(0), &accounts(1), vec![]); + + // Build a queue with a duplicate market id + let base = 100u32; + let dup = mk(base); + let mut queue: Vec = Vec::with_capacity(len); + if len >= 1 { + queue.push(dup.clone()); + } + for i in 1..len.saturating_sub(1) { + queue.push(mk(base + i as u32)); + } + if len >= 2 { + queue.push(dup); + } + + c.set_supply_queue(queue); + } + + #[rstest(len => [2usize, 3, 5])] + #[should_panic = "Duplicate market"] + fn prop_withdraw_queue_mustnt_have_duplicates(len: usize) { + let mut c = new_test_contract(&mk(0)); + setup_env(&accounts(0), &accounts(1), vec![]); + + // Build a queue with a duplicate market id + let base = 200u32; + let dup = mk(base); + let mut queue: Vec = Vec::with_capacity(len); + if len >= 1 { + queue.push(dup.clone()); + } + for i in 1..len.saturating_sub(1) { + queue.push(mk(base + i as u32)); + } + if len >= 2 { + queue.push(dup); + } + + c.set_withdraw_queue(queue); + } +} diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index a8eb56ed..29bb4387 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -10,7 +10,6 @@ use test_utils::{ // TODO(prop): every callback must be for the current op and market index // TODO(prop): allocation accounting: Accepted amount = new_principal - before &never more than attempted // TODO(prop): allocation attempts: any market that is enabled (new_principal > 0) must be in the withdraw queue -// TODO(prop): withdraw queue must never have duplicates // TODO(prop): enabling a market (cap > 0) must add it to the withdraw queue // TODO(prop): stop and exit: must never mutiny funds or escrow From 3a23b6f18770dce20448a614c6fdf45c3759fce7 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 9 Oct 2025 19:07:33 +0100 Subject: [PATCH 021/121] test: props for callbacks --- contract/vault/src/impl_callbacks.rs | 220 +++++++++++++++++++++++++-- contract/vault/src/lib.rs | 7 +- 2 files changed, 208 insertions(+), 19 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index d64474b2..0836631c 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -77,6 +77,7 @@ impl Contract { pub const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(4); pub const AFTER_SUPPLY_POSITION_CHECK_GAS: Gas = Gas::from_tgas(10); + // FIXME: no panics in this function! This will cause to spin if the op changes #[private] pub fn after_supply_2_read( @@ -146,9 +147,8 @@ impl Contract { } }; - // Emit step settled event let accepted_event = new_principal.saturating_sub(before.0); - // Compute refund from ground truth (attempted - accepted), ignoring token-reported value + let refunded = attempted.0.saturating_sub(accepted_event); Event::AllocationStepSettled { op_id, @@ -164,6 +164,7 @@ impl Contract { .emit(); self.market_supply.insert(market.clone(), new_principal); + // Invariant: withdraw_queue gains any market with new_principal > 0 if new_principal > 0 && !self.withdraw_queue.iter().any(|m| m == &market) { self.withdraw_queue.push(market.clone()); @@ -510,7 +511,6 @@ impl Contract { &mut self, msg: Option<&T>, ) { - // replaced log with events elsewhere; no-op here if let OpState::Allocating { op_id, index, @@ -566,7 +566,6 @@ impl Contract { } .emit(); } - // Take copies to avoid holding immutable borrows across mutable self calls. let (owner_acc, escrow) = match &self.op_state { OpState::Withdrawing { owner, @@ -605,7 +604,6 @@ impl Contract { .emit(); } } - // Take copies to avoid holding immutable borrows across mutable self calls. let (owner_acc, escrow) = match &self.op_state { OpState::Payout { owner, @@ -655,6 +653,7 @@ mod tests { use near_sdk::test_utils::{accounts, VMContextBuilder}; use near_sdk::{test_utils::testing_env_with_promise_results, AccountId, PromiseOrValue}; use near_sdk::{test_vm_config, testing_env, PromiseResult, RuntimeFeesConfig}; + use rstest::rstest; use templar_common::asset::{BorrowAsset, FungibleAsset}; use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; use test_utils::vault_configuration; @@ -747,7 +746,6 @@ mod tests { let receiver = mk(7); - // Seed idle balance and set Payout state; use zero escrow/burn to avoid FT side-effects in unit test. c.idle_balance = 1_000; c.op_state = OpState::Payout { op_id: 1, @@ -758,7 +756,6 @@ mod tests { burn_shares: 0, }; - // Provide a successful callback result let ok = c.after_send_to_user(Ok(()), 1, receiver.clone(), U128(200)); assert!(ok, "Payout should report success"); assert_eq!(c.idle_balance, 800, "Idle balance must decrease by payout"); @@ -793,20 +790,17 @@ mod tests { let res = c.after_exec_withdraw_read(Ok(None), 42, 0, U128(100), U128(60)); - // Should schedule payout (Promise) after crediting and zeroing remaining match res { - PromiseOrValue::Promise(_) => {} + PromiseOrValue::Promise(p) => {} _ => panic!("Expected a Promise to send payout"), } - // Market principal should be zeroed assert_eq!( *c.market_supply.get(&market).unwrap_or(&u128::MAX), 0, "Market principal should be updated to 0" ); - // Idle balance should be credited by 60 assert_eq!( c.idle_balance, 60, "Idle balance should increase by credited amount" @@ -828,7 +822,6 @@ mod tests { let mut c = new_test_contract(&vault_id); - // Zero balance -> Value(()) let res = c.after_skim_balance(Ok(U128(0)), mk(10), mk(11)); match res { PromiseOrValue::Value(()) => {} @@ -846,11 +839,208 @@ mod tests { // Positive balance -> Promise to ft_transfer let res = c.after_skim_balance(Ok(U128(123)), mk(10), mk(11)); match res { - PromiseOrValue::Promise(_) => {} + PromiseOrValue::Promise(_) => { //NOTE: one day we will be able to read the promise + //definition :< + } _ => panic!("Skim with positive balance must return a Promise"), } } - #[test] - fn stop_and_exit_same_op() {} + // Property: Payout failure keeps idle_balance unchanged and does not burn escrow + #[rstest( + idle => [0u128, 1, 100], + escrow => [0u128, 1, 50], + amount => [0u128, 1, 25] + )] + fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: u128) { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let receiver = mk(7); + let owner = accounts(1); + + if escrow > 0 { + use near_sdk_contract_tools::ft::Nep141Controller as _; + + c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) + .expect("seed escrow into vault"); + } + + c.idle_balance = idle; + c.op_state = OpState::Payout { + op_id: 1, + receiver: receiver.clone(), + amount, + owner: owner.clone(), + escrow_shares: escrow, + burn_shares: escrow, + }; + + let before = c.idle_balance; + let ok = c.after_send_to_user( + Err(near_sdk::PromiseError::Failed), + 1, + receiver.clone(), + U128(amount), + ); + assert!(!ok, "Payout failure should return false"); + assert_eq!( + c.idle_balance, before, + "idle_balance must stay the same on payout failure" + ); + assert!( + matches!(c.op_state, OpState::Idle), + "Vault must go Idle after payout failure" + ); + } + + // 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); + + // Single-market queue so advancing index reaches end-of-queue + let market = mk(8); + c.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 100); + + c.op_state = OpState::Withdrawing { + op_id: 7, + index: 0, + remaining: need, + receiver: mk(9), + collected, + owner: accounts(1), + escrow_shares: 0, + }; + + let res = + c.after_create_withdraw_req(Err(near_sdk::PromiseError::Failed), 7, 0, U128(need)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise after skipping to payout at end-of-queue"), + } + + match &c.op_state { + OpState::Payout { 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], + need => [0u128, 1u128, 50u128], + collected => [1u128, 2u128] + )] + fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collected: u128) { + 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.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), before); + + let initial_idle = c.idle_balance; + + c.op_state = OpState::Withdrawing { + op_id: 99, + index: 0, + remaining: need, + receiver: mk(9), + collected, + owner: accounts(1), + escrow_shares: 0, + }; + + let res = c.after_exec_withdraw_read( + Err(near_sdk::PromiseError::Failed), + 99, + 0, + U128(before), + U128(need), + ); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to send payout at end-of-queue"), + } + + assert_eq!( + *c.market_supply.get(&market).unwrap_or(&u128::MAX), + before, + "principal must remain unchanged on read failure" + ); + assert_eq!( + c.idle_balance, initial_idle, + "idle_balance must not change when nothing credited" + ); + + match &c.op_state { + OpState::Payout { amount, .. } => { + assert_eq!(*amount, collected, "Payout amount must equal collected") + } + other => panic!("Unexpected state: {:?}", other), + } + } + + // Property: Callbacks must match current op_id or index; otherwise stop and go Idle + #[rstest( + pass_op => [false, true], + pass_index => [false, true] + )] + fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_index: bool) { + 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.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 10); + + let real_op = 5u64; + let real_idx = 0u32; + + c.op_state = OpState::Withdrawing { + op_id: real_op, + index: real_idx, + remaining: 1, + receiver: mk(9), + collected: 1, + owner: accounts(1), + escrow_shares: 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 }; + + let r = c.after_exec_withdraw_read(Ok(None), call_op, call_idx, U128(10), U128(1)); + match (pass_op, pass_index) { + (true, true) => { + assert!( + !matches!(c.op_state, OpState::Idle), + "Valid callback should not immediately stop" + ); + } + _ => { + // Any mismatch should stop and go Idle + match r { + PromiseOrValue::Value(()) => {} + _ => {} + } + assert!( + matches!(c.op_state, OpState::Idle), + "Mismatched callback must stop and go Idle" + ); + } + } + } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 4b364439..ae99ca0f 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1375,7 +1375,7 @@ impl Contract { escrow_shares, burn_shares, }; - return PromiseOrValue::Promise( + PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) .then( @@ -1383,13 +1383,13 @@ impl Contract { .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) .after_send_to_user(op_id, receiver, U128(collected)), ), - ); + ) } else { // Nothing collected at all; refund escrow and stop with InsufficientLiquidity let self_id = env::current_account_id(); self.transfer_unchecked(&self_id, &owner, escrow_shares) .expect("Failed to release escrowed shares"); - return self.stop_and_exit(Some(&Error::InsufficientLiquidity)); + self.stop_and_exit(Some(&Error::InsufficientLiquidity)) } } } @@ -1429,7 +1429,6 @@ mod test_utils { } pub fn new_test_contract(vault_id: &AccountId) -> Contract { - // Ensure env is available before constructing the contract (uses env::storage_usage etc). setup_env(vault_id, vault_id, vec![]); // Basic accounts From 92eaa97088cb37507f0540b2e46dadc5d1ea4b18 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 10 Oct 2025 12:21:57 +0100 Subject: [PATCH 022/121] test: moving tests around and adding callback test suite --- contract/vault/src/impl_token_receiver.rs | 10 +- contract/vault/src/lib.rs | 122 +--------- contract/vault/src/test_utils.rs | 53 +++++ contract/vault/src/tests.rs | 266 ++++++++++++++++++++++ contract/vault/src/wad.rs | 28 +++ contract/vault/tests/invariants.rs | 3 - 6 files changed, 359 insertions(+), 123 deletions(-) create mode 100644 contract/vault/src/test_utils.rs create mode 100644 contract/vault/src/tests.rs diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 52e7b963..c6c749d5 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -95,12 +95,18 @@ impl Contract { ) -> u128 { // Invariant: Only the underlying token is accepted; others are fully refunded if token_id != self.underlying_asset.contract_id() { - Event::DepositRejectedWrongAsset { token: token_id.clone() }.emit(); + Event::DepositRejectedWrongAsset { + token: token_id.clone(), + } + .emit(); return deposit; }; if deposit == 0 { - Event::DepositRejectedZeroAmount { sender: sender_id.clone() }.emit(); + Event::DepositRejectedZeroAmount { + sender: sender_id.clone(), + } + .emit(); return 0; } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index ae99ca0f..f6b05e98 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -34,6 +34,9 @@ pub mod impl_callbacks; pub mod impl_token_receiver; pub mod wad; +#[cfg(test)] +mod test_utils; + #[derive(Debug, Clone)] #[near(serializers = [json, borsh])] #[derive(BorshStorageKey)] @@ -1385,127 +1388,10 @@ impl Contract { ), ) } else { - // Nothing collected at all; refund escrow and stop with InsufficientLiquidity - let self_id = env::current_account_id(); - self.transfer_unchecked(&self_id, &owner, escrow_shares) - .expect("Failed to release escrowed shares"); self.stop_and_exit(Some(&Error::InsufficientLiquidity)) } } } } - -#[cfg(test)] -mod test_utils { - use crate::Contract; - use near_sdk::{ - test_utils::{accounts, VMContextBuilder}, - test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, - }; - use rstest::rstest; - use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; - use test_utils::vault_configuration; - - pub fn mk(n: u32) -> AccountId { - format!("acc{n}.testnet").parse().expect("valid account id") - } - - pub fn setup_env( - current: &AccountId, - predecessor: &AccountId, - promise_results: Vec, - ) { - let mut builder = VMContextBuilder::new(); - builder.current_account_id(current.clone()); - builder.predecessor_account_id(predecessor.clone()); - builder.signer_account_id(predecessor.clone()); - testing_env!( - builder.build(), - test_vm_config(), - RuntimeFeesConfig::test(), - Default::default(), - promise_results - ); - } - - pub fn new_test_contract(vault_id: &AccountId) -> Contract { - setup_env(vault_id, vault_id, vec![]); - - // Basic accounts - let owner = accounts(1); - let curator = accounts(2); - let guardian = accounts(3); - let fee_recipient = accounts(4); - let skim_recipient = accounts(5); - let underlying_token_id = mk(6); - - let cfg = vault_configuration( - owner, - curator, - guardian, - underlying_token_id, - skim_recipient, - fee_recipient, - ); - - Contract::new(cfg) - } -} - #[cfg(test)] -mod tests { - use crate::test_utils::*; - use crate::Contract; - use near_sdk::test_utils::accounts; - use near_sdk::{json_types::U128, test_utils::VMContextBuilder, AccountId, RuntimeFeesConfig}; - use near_sdk::{test_vm_config, testing_env}; - use rstest::rstest; - use templar_common::asset::{BorrowAsset, FungibleAsset}; - use templar_common::vault::{AllocationMode, VaultConfiguration}; - - #[rstest(len => [2usize, 3, 5])] - #[should_panic = "Duplicate market"] - fn prop_supply_queue_mustnt_have_duplicates(len: usize) { - let mut c = new_test_contract(&mk(0)); - setup_env(&accounts(0), &accounts(1), vec![]); - - // Build a queue with a duplicate market id - let base = 100u32; - let dup = mk(base); - let mut queue: Vec = Vec::with_capacity(len); - if len >= 1 { - queue.push(dup.clone()); - } - for i in 1..len.saturating_sub(1) { - queue.push(mk(base + i as u32)); - } - if len >= 2 { - queue.push(dup); - } - - c.set_supply_queue(queue); - } - - #[rstest(len => [2usize, 3, 5])] - #[should_panic = "Duplicate market"] - fn prop_withdraw_queue_mustnt_have_duplicates(len: usize) { - let mut c = new_test_contract(&mk(0)); - setup_env(&accounts(0), &accounts(1), vec![]); - - // Build a queue with a duplicate market id - let base = 200u32; - let dup = mk(base); - let mut queue: Vec = Vec::with_capacity(len); - if len >= 1 { - queue.push(dup.clone()); - } - for i in 1..len.saturating_sub(1) { - queue.push(mk(base + i as u32)); - } - if len >= 2 { - queue.push(dup); - } - - c.set_withdraw_queue(queue); - } -} +mod tests; diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs new file mode 100644 index 00000000..d3e67e18 --- /dev/null +++ b/contract/vault/src/test_utils.rs @@ -0,0 +1,53 @@ +use crate::Contract; +use near_sdk::{ + test_utils::{accounts, VMContextBuilder}, + test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, +}; +use rstest::rstest; +use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; +use test_utils::vault_configuration; + +pub fn mk(n: u32) -> AccountId { + format!("acc{n}.testnet").parse().expect("valid account id") +} + +pub fn setup_env( + current: &AccountId, + predecessor: &AccountId, + promise_results: Vec, +) { + let mut builder = VMContextBuilder::new(); + builder.current_account_id(current.clone()); + builder.predecessor_account_id(predecessor.clone()); + builder.signer_account_id(predecessor.clone()); + testing_env!( + builder.build(), + test_vm_config(), + RuntimeFeesConfig::test(), + Default::default(), + promise_results + ); +} + +pub fn new_test_contract(vault_id: &AccountId) -> Contract { + setup_env(vault_id, vault_id, vec![]); + + // Basic accounts + let owner = accounts(1); + let curator = accounts(2); + let guardian = accounts(3); + let fee_recipient = accounts(4); + let skim_recipient = accounts(5); + let underlying_token_id = mk(6); + + let cfg = vault_configuration( + owner, + curator, + guardian, + underlying_token_id, + skim_recipient, + fee_recipient, + ); + + Contract::new(cfg) +} diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs new file mode 100644 index 00000000..a45db52d --- /dev/null +++ b/contract/vault/src/tests.rs @@ -0,0 +1,266 @@ +use crate::test_utils::*; +use crate::Contract; +use near_sdk::test_utils::accounts; +use near_sdk::{json_types::U128, test_utils::VMContextBuilder, AccountId, RuntimeFeesConfig}; +use near_sdk::{test_vm_config, testing_env}; +use near_sdk_contract_tools::ft::Nep141Controller as _; +use near_sdk_contract_tools::owner::OwnerExternal; +use rstest::rstest; +use templar_common::asset::{BorrowAsset, FungibleAsset}; +use templar_common::vault::MarketConfiguration; +use templar_common::vault::OpState; +use templar_common::vault::{AllocationMode, VaultConfiguration}; + +#[rstest(len => [2usize, 3, 5])] +#[should_panic = "Duplicate market"] +fn prop_supply_queue_mustnt_have_duplicates(len: usize) { + let mut c = new_test_contract(&mk(0)); + setup_env(&accounts(0), &accounts(1), vec![]); + + // Build a queue with a duplicate market id + let base = 100u32; + let dup = mk(base); + let mut queue: Vec = Vec::with_capacity(len); + if len >= 1 { + queue.push(dup.clone()); + } + for i in 1..len.saturating_sub(1) { + queue.push(mk(base + i as u32)); + } + if len >= 2 { + queue.push(dup); + } + + c.set_supply_queue(queue); +} + +#[rstest(len => [2usize, 3, 5])] +#[should_panic = "Duplicate market"] +fn prop_withdraw_queue_mustnt_have_duplicates(len: usize) { + let mut c = new_test_contract(&mk(0)); + setup_env(&accounts(0), &accounts(1), vec![]); + + // Build a queue with a duplicate market id + let base = 200u32; + let dup = mk(base); + let mut queue: Vec = Vec::with_capacity(len); + if len >= 1 { + queue.push(dup.clone()); + } + for i in 1..len.saturating_sub(1) { + queue.push(mk(base + i as u32)); + } + if len >= 2 { + queue.push(dup); + } + + c.set_withdraw_queue(queue); +} + +#[test] +fn fee_accrues_only_on_growth_unit() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Seed total supply so fees can mint + let user = accounts(1); + c.deposit_unchecked(&user, 1_000).expect("seed shares"); + c.idle_balance = 1_000; + + // Set fee to 10% + c.performance_fee = crate::wad::WAD / 10; + + // Baseline: last_total_assets = current, so no profit => no fee + c.last_total_assets = c.get_total_assets().0; + let ts_before = c.total_supply(); + c.internal_accrue_fee(); + assert_eq!(c.total_supply(), ts_before, "no profit => no fee minted"); + + // Simulate profit: increase idle_balance; now fees should mint + c.idle_balance = 1_500; + let expect = crate::wad::compute_fee_shares( + c.get_total_assets().0, + c.last_total_assets, + c.performance_fee, + c.total_supply(), + ); + c.internal_accrue_fee(); + assert_eq!( + c.total_supply(), + ts_before + expect, + "fee shares minted must match compute_fee_shares" + ); +} + +#[test] +fn contract_convert_roundtrip_bounds() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let c = new_test_contract(&vault_id); + + let a = U128(1_234_567); + let s = U128(987_654); + + // With virtual offsets, inequalities must hold + let to_sh = c.convert_to_shares(a); + let back_a = c.convert_to_assets(to_sh); + assert!(back_a.0 <= a.0); + + let to_a = c.convert_to_assets(s); + let back_s = c.convert_to_shares(to_a); + assert!(back_s.0 >= s.0); +} + +#[test] +fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + 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) + .expect("seed escrow"); + // Seed idle to cover payout + c.idle_balance = 1_000; + + // Partial payout scenario: collected/requested = 200/500 => burn 40% of escrowed shares + c.op_state = OpState::Payout { + op_id: 1, + receiver: receiver.clone(), + amount: 200, + owner: owner.clone(), + escrow_shares: 100, + burn_shares: 40, // precomputed proportional burn for test + }; + + let supply_before = c.total_supply(); + let ok = c.after_send_to_user(Ok(()), 1, receiver, U128(200)); + assert!(ok, "payout must report success"); + // Idle decreased by payout + assert_eq!(c.idle_balance, 800); + // 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)); +} + +#[test] +fn execute_next_withdrawal_request_skips_holes() { + 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![]); + + println!("vault_id: {}", vault_id); + println!("owner: {}", owner); + + // Bob gets 20 shares + c.deposit_unchecked(&owner, 20).unwrap(); + // We fake by adding idle to the vault + c.transfer_unchecked(&owner, &vault_id, 10).unwrap(); + c.transfer_unchecked(&owner, &vault_id, 10).unwrap(); + + // Vault now has 20 + assert_eq!(c.balance_of(&vault_id), 20); + + // Queue two requests at ids 1 and 3; head starts at 0 + c.next_withdraw_id = 4; + c.next_withdraw_to_execute = 0; + + let make = |owner: AccountId, receiver: AccountId| super::PendingWithdrawal { + owner, + receiver, + escrow_shares: 10, + expected_assets: 5, + requested_at: 0, + }; + let recv = mk(9); + + // FIXME: next issue is that we refund if the market doesnt exist and on InsufficientLiquidity + // balance + // EVENT_JSON:{"standard":"templar-vault","version":"1.0.0","event":"withdrawal_stopped","data":{"op_id":1,"index":0,"remaining":"5","collected":"0","reason":"InsufficientLiquidity"}} + // EVENT_JSON:{"standard":"templar-vault","version":"1.0.0","event":"withdrawal_stopped","data":{"op_id":2,"index":0,"remaining":"5","collected":"0","reason":"InsufficientLiquidity"}} + + c.pending_withdrawals + .insert(1, make(owner.clone(), recv.clone())); + c.pending_withdrawals + .insert(3, make(owner.clone(), recv.clone())); + + // First call should consume id=1 and advance head to 2 + let _ = c.execute_next_withdrawal_request(); + assert_eq!(c.next_withdraw_to_execute, 2); + + assert_eq!(c.balance_of(&vault_id), 10); + + // Second call should consume id=3 and advance head to 4 + let _ = c.execute_next_withdrawal_request(); + assert_eq!(c.next_withdraw_to_execute, 4); +} + +#[test] +#[should_panic = "unauthorized market"] +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)]); +} + +#[test] +#[should_panic = "Withdraw queue must include all enabled or holding markets"] +fn set_withdraw_queue_must_include_all_holding() { + let mut c = new_test_contract(&mk(0)); + setup_env(&mk(0), &accounts(1), vec![]); + + let m1 = mk(103); + let m2 = mk(104); + + // Both known; m1 has supply > 0 + c.config.insert(m1.clone(), MarketConfiguration::default()); + c.config.insert(m2.clone(), MarketConfiguration::default()); + c.market_supply.insert(m1.clone(), 10); + + // Missing m1 should panic + c.set_withdraw_queue(vec![m2]); +} + +#[test] +fn execute_supply_wrong_token_refunds_full() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let sender = accounts(1); + let wrong_token: AccountId = "wrong.token".parse().unwrap(); + let deposit = 1_000u128; + + let refund = c.execute_supply(sender.clone(), wrong_token.clone(), deposit); + assert_eq!(refund, deposit, "full refund expected for wrong token"); + assert_eq!(c.total_supply(), 0, "no shares should be minted"); + assert_eq!(c.idle_balance, 0, "idle must remain unchanged"); +} + +#[test] +#[should_panic = "Withdraw queue must include all enabled or holding markets"] +fn set_withdraw_queue_must_include_all_enabled() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + + let m1 = mk(101); + let m2 = mk(102); + + // m1 enabled, m2 disabled; provide both configs + let mut cfg1 = MarketConfiguration::default(); + cfg1.enabled = true; + c.config.insert(m1.clone(), cfg1); + c.config.insert(m2.clone(), MarketConfiguration::default()); + + // Missing m1 should panic + c.set_withdraw_queue(vec![m2]); +} diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index 5444be9f..e4edf1ef 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -78,6 +78,7 @@ mod tests { assert!(res <= W / 9); assert_eq!(res, (W / 9) - 1); // typical floor loss } + #[test] fn convert_roundtrip_bounds() { // For any totals, redeem(convert_to_shares(a)) ≤ a and @@ -95,4 +96,31 @@ mod tests { let back_s = mul_div_ceil(to_a, ts + 1, ta + 1); assert!(back_s >= s); } + + #[test] + fn compute_fee_shares_no_profit_or_zero_fee_or_zero_supply() { + // no profit => 0 + assert_eq!(compute_fee_shares(1_000, 1_000, W / 10, 1_000), 0); + // zero fee => 0 + assert_eq!(compute_fee_shares(2_000, 1_000, 0, 1_000), 0); + // zero supply => 0 + assert_eq!(compute_fee_shares(2_000, 1_000, W / 10, 0), 0); + } + + #[test] + fn compute_fee_shares_mints_proportionally_on_profit() { + // cur=1500, last=1000, profit=500, fee=10% => fee_assets=50 + // denom = 1500 - 50 = 1450; total_supply=1000 => fee_shares=floor(50*1000/1450)=34 + let fee = W / 10; + let minted = compute_fee_shares(1_500, 1_000, fee, 1_000); + assert_eq!(minted, 34); + } + + #[test] + fn compute_fee_shares_handles_extreme_fee() { + // 100% fee on positive profit: fee_assets=profit; denom=max(1) => finite result + let minted = compute_fee_shares(2_000, 1_000, W, 1_000); + // fee_assets=1000; denom=1_000 (2_000 - 1_000) => floor(1_000*1_000/1_000)=1_000 + assert_eq!(minted, 1_000); + } } diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 29bb4387..45503a06 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -58,9 +58,6 @@ async fn withdraw_queue_mustnt_have_duplicates() { vault.set_withdraw_queue(&vault_curator, &queue).await; } -#[tokio::test] -async fn fee_accrual_only_when_aum_grows() {} - #[tokio::test] #[should_panic = "busy"] async fn state_machine_is_locked_when_another_op_is_running() { From 8721db988d89b3ffdc7f7eba212835cc36c711dc Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 13 Oct 2025 11:09:09 +0100 Subject: [PATCH 023/121] fix: zero the plan on empty weights --- contract/vault/src/impl_callbacks.rs | 5 +-- contract/vault/src/lib.rs | 67 +++++++++++++++++----------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 0836631c..304ffde3 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -397,9 +397,8 @@ impl Contract { } else { // Nothing collected; refund escrowed shares let self_id = env::current_account_id(); - self.withdraw_unchecked(&self_id, escrow_shares) - .expect("Failed to release escrowed shares"); - self.deposit_unchecked(&owner, escrow_shares); + self.transfer_unchecked(&self_id, &owner, escrow_shares) + .expect("Failed to refund escrowed shares"); self.op_state = OpState::Idle; PromiseOrValue::Value(()) } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index f6b05e98..1996c7cc 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -791,6 +791,7 @@ impl Contract { total: U128(total), } .emit(); + self.plan = None; return self.start_allocation(total); } @@ -1351,6 +1352,9 @@ impl Contract { owner, escrow_shares, }; + env::log_str(&format!( + "Skipping withdrawal for market {market} (have {have}, remaining {remaining})" + )); return self.step_withdraw(); } PromiseOrValue::Promise( @@ -1365,31 +1369,44 @@ impl Contract { ), ) } else { - // End of withdraw queue. If we collected something, pay it out now and burn proportional shares. - if collected > 0 { - let requested = collected.saturating_add(remaining); - let burn_shares = - crate::wad::mul_div_floor(escrow_shares, collected, requested.max(1)); - self.op_state = OpState::Payout { - op_id, - receiver: receiver.clone(), - amount: collected, - owner: owner.clone(), - escrow_shares, - burn_shares, - }; - PromiseOrValue::Promise( - self.underlying_asset - .transfer(receiver.clone(), U128(collected).into()) - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, receiver, U128(collected)), - ), - ) - } else { - self.stop_and_exit(Some(&Error::InsufficientLiquidity)) - } + self.pay_collected(op_id, remaining, receiver, collected, owner, escrow_shares) + } + } + + // If we collected something, pay it out now and burn proportional shares or pay directly from idle balance + // TODO: should directly check idle balance first? + // TODO: unit test me + fn pay_collected( + &mut self, + op_id: u64, + remaining: u128, + receiver: AccountId, + collected: u128, + owner: AccountId, + escrow_shares: u128, + ) -> PromiseOrValue<()> { + if collected > 0 { + let requested = collected.saturating_add(remaining); + let burn_shares = crate::wad::mul_div_floor(escrow_shares, collected, requested.max(1)); + self.op_state = OpState::Payout { + op_id, + receiver: receiver.clone(), + amount: collected, + owner: owner.clone(), + escrow_shares, + burn_shares, + }; + PromiseOrValue::Promise( + self.underlying_asset + .transfer(receiver.clone(), U128(collected).into()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) + .after_send_to_user(op_id, receiver, U128(collected)), + ), + ) + } else { + self.stop_and_exit(Some(&Error::InsufficientLiquidity)) } } } From 61abec76c148a820f3aa3fd3af35684cebdb188f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 13 Oct 2025 13:42:11 +0100 Subject: [PATCH 024/121] fix: verify mt is underlying --- contract/vault/src/impl_token_receiver.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index c6c749d5..b43462a7 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -48,10 +48,6 @@ impl Nep245Receiver for Contract { ) -> PromiseOrValue> { const RETURN_STYLE: ReturnStyle = ReturnStyle::Nep245MtTransferCall; - // NEP-245: This could be an authorized account ID. We only care about - // the actual previous owner. - let _ = sender_id; - let msg = near_sdk::serde_json::from_str::(&msg) .unwrap_or_else(|_| env::panic_str("Invalid deposit msg")); @@ -70,15 +66,14 @@ impl Nep245Receiver for Contract { match msg { DepositMsg::Supply => { - let refund = self.execute_supply( - sender_id.clone(), - // FIXME: this is incorrect, we should abstract this into the underlying to - // determine the kind. - token_id - .parse() - .unwrap_or_else(|_| env::panic_str("Invalid token ID")), - amount.into(), - ); + let mt = env::predecessor_account_id(); + + if !self.underlying_asset.is_nep245(&mt, token_id) { + Event::DepositRejectedWrongAsset { token: mt }.emit(); + return PromiseOrValue::Value(vec![amount]); + } + + let refund = self.execute_supply(sender_id.clone(), mt, amount.into()); PromiseOrValue::Value(vec![U128(refund)]) } From bf4a3f282d1c3f1d8511b0bb33e3b1a1fa10b439 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 13 Oct 2025 15:46:59 +0100 Subject: [PATCH 025/121] fix: require allocations to maintain idle --- contract/vault/src/lib.rs | 117 +++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 60 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 1996c7cc..3f0c971c 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -632,7 +632,6 @@ impl Contract { "Withdraw queue length exceeds max" ); - // Ensure no duplicates in the new queue let mut seen = std::collections::HashSet::new(); for id in &queue { if !seen.insert(id.clone()) { @@ -792,57 +791,57 @@ impl Contract { } .emit(); self.plan = None; - return self.start_allocation(total); - } - - // Validate unique markets and accumulate weight sum - let mut seen = std::collections::HashSet::new(); - let mut sum_w: u128 = 0; + self.start_allocation(total) + } else { + // Validate unique markets and accumulate weight sum + let mut seen = std::collections::HashSet::new(); + let mut sum_w: u128 = 0; - for (m, w) in &weights { - if !seen.insert(m.clone()) { - env::panic_str(&format!("Duplicate market in weights: {m}")); + for (m, w) in &weights { + if !seen.insert(m.clone()) { + env::panic_str(&format!("Duplicate market in weights: {m}")); + } + sum_w = sum_w.saturating_add(u128::from(*w)); + } + if sum_w == 0 { + env::panic_str("Sum of weights is zero"); } - sum_w = sum_w.saturating_add(u128::from(*w)); - } - if sum_w == 0 { - env::panic_str("Sum of weights is zero"); - } - // Clamp total allocation by idle balance and aggregate room - let requested: u128 = amount.map_or(self.idle_balance, |x| x.0); - let max_room = self.get_max_deposit().0; - let total = requested.min(self.idle_balance).min(max_room); - if total == 0 { - env::panic_str("No funds to allocate"); - } + // Clamp total allocation by idle balance and aggregate room + let requested: u128 = amount.map_or(self.idle_balance, |x| x.0); + let max_room = self.get_max_deposit().0; + let total = requested.min(self.idle_balance).min(max_room); + if total == 0 { + env::panic_str("No funds to allocate"); + } - // Emit request and plan events - let op_id = self.next_op_id; - let weights_for_event: Vec<(AccountId, U128)> = weights - .iter() - .map(|(m, w)| (m.clone(), U128((*w).into()))) - .collect(); - Event::AllocationRequestedWeighted { - op_id, - total: U128(total), - weights: weights_for_event.clone(), - } - .emit(); - Event::AllocationPlanSet { - op_id, - plan: weights_for_event, - } - .emit(); + // Emit request and plan events + let op_id = self.next_op_id; + let weights_for_event: Vec<(AccountId, U128)> = weights + .iter() + .map(|(m, w)| (m.clone(), U128((*w).into()))) + .collect(); + Event::AllocationRequestedWeighted { + op_id, + total: U128(total), + weights: weights_for_event.clone(), + } + .emit(); + Event::AllocationPlanSet { + op_id, + plan: weights_for_event, + } + .emit(); - // Store an ephemeral plan of (market, weight) to drive weighted allocation. - let plan: AllocationPlan = weights - .into_iter() - .map(|(m, w)| (m, u128::from(w))) - .collect(); + // Store an ephemeral plan of (market, weight) to drive weighted allocation. + let plan: AllocationPlan = weights + .into_iter() + .map(|(m, w)| (m, u128::from(w))) + .collect(); - self.plan = Some(plan); - self.start_allocation(total) + self.plan = Some(plan); + self.start_allocation(total) + } } } @@ -917,11 +916,7 @@ impl Contract { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); - U128(crate::wad::mul_div_floor( - a, - new_total_supply, - new_total_assets, - )) + U128(mul_div_floor(a, new_total_supply, new_total_assets)) } /// Converts an amount of shares to underlying assets, flooring the result. @@ -932,11 +927,7 @@ impl Contract { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); - U128(crate::wad::mul_div_floor( - s, - new_total_assets, - new_total_supply, - )) + U128(mul_div_floor(s, new_total_assets, new_total_supply)) } /// Preview the number of shares minted for a deposit of `assets` (floored). @@ -1094,7 +1085,13 @@ impl Contract { return self.stop_and_exit(Some(&Error::ZeroAmount)); } self.ensure_idle(); - self.idle_balance = 0; + + assert!( + amount <= self.idle_balance, + "Invariant Violation: reserve amount must be <= idle_balance" + ); + self.idle_balance -= amount; + let op_id = self.next_op_id; self.next_op_id += 1; self.op_state = OpState::Allocating { @@ -1119,6 +1116,7 @@ impl Contract { } => (*op_id, *index, *remaining), _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), }; + if remaining == 0 { return self.stop_and_exit::(None); } @@ -1139,7 +1137,7 @@ impl Contract { let target = if sum_w == 0 || idx + 1 == plan.len() { remaining } else { - crate::wad::mul_div_floor(remaining, *weight, sum_w) + mul_div_floor(remaining, *weight, sum_w) }; let cap = self.config.get(&market_id).map_or(0, |c| c.cap); @@ -1261,7 +1259,6 @@ impl Contract { ), ) } else { - // Shouldn't happen if max_deposit used; stop and reconcile remaining in stop_and_exit self.stop_and_exit(Some("Market not found")) } } @@ -1387,7 +1384,7 @@ impl Contract { ) -> PromiseOrValue<()> { if collected > 0 { let requested = collected.saturating_add(remaining); - let burn_shares = crate::wad::mul_div_floor(escrow_shares, collected, requested.max(1)); + let burn_shares = mul_div_floor(escrow_shares, collected, requested.max(1)); self.op_state = OpState::Payout { op_id, receiver: receiver.clone(), From 4c07b0924d5008d62eb8875574a0a79fe07de136 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 13 Oct 2025 17:24:49 +0100 Subject: [PATCH 026/121] feat: invariant violations and proper tests README --- Cargo.lock | 22 +- Cargo.toml | 1 + contract/vault/README.md | 391 +++++++++++++++++--- contract/vault/src/impl_callbacks.rs | 395 ++++++++++++++------ contract/vault/src/lib.rs | 149 +++++--- contract/vault/src/test_utils.rs | 46 ++- contract/vault/src/tests.rs | 520 ++++++++++++++++++++++++++- 7 files changed, 1278 insertions(+), 246 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4dbec65..93fe3b1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,9 +329,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ "serde", ] @@ -1991,7 +1991,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "cfg-if 1.0.0", "libc", ] @@ -2135,7 +2135,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "libc", "redox_syscall", ] @@ -2939,7 +2939,7 @@ version = "0.10.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "cfg-if 1.0.0", "foreign-types", "libc", @@ -3356,7 +3356,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", ] [[package]] @@ -3629,7 +3629,7 @@ version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.4.14", @@ -3786,7 +3786,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "core-foundation", "core-foundation-sys", "libc", @@ -4163,7 +4163,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.9.4", "byteorder", "bytes", "crc", @@ -4207,7 +4207,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.9.4", "byteorder", "crc", "dotenvy", @@ -4387,7 +4387,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "core-foundation", "system-configuration-sys", ] diff --git a/Cargo.toml b/Cargo.toml index f88ab02f..dc9d5b58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ rstest = { version = "0.24" } schemars = { version = "0.8" } templar-common = { path = "./common" } templar-universal-account = { path = "./universal-account" } +template-vault-contract = { path = "./contract/vault" } test-utils = { path = "./test-utils" } thiserror = "2.0.11" tokio = { version = "1.30.0", features = ["full"] } diff --git a/contract/vault/README.md b/contract/vault/README.md index 399dc834..f1c22ef9 100644 --- a/contract/vault/README.md +++ b/contract/vault/README.md @@ -1,61 +1,340 @@ -# Templar Vault – Withdrawals +# Templar Vault: Architecture, Codebase, and Flows -This document explains how withdrawals work in the vault as currently implemented. +This document explains how the vault works end-to-end: roles and permissions, data flow, deposits and withdrawals, and the async allocation/withdraw pipelines. -Summary -- The vault performs “best-effort now” withdrawals. There is no persistent vault-level withdrawal queue. -- If there is insufficient liquidity across all markets, the vault refunds escrowed shares and stops. No payout is made. -- Partial progress may occur while attempting the withdrawal (some markets may return funds), but payout to the user only happens when the full requested amount is collected. +## High-level overview + +- The vault issues shares over an underlying asset and allocates liquidity into configured markets. +- Two ordered queues drive behavior: + - supply_queue: allocation order for deposits/idle funds to be supplied to markets. + - withdraw_queue: priority order to pull liquidity back from markets. +- Operations are asynchronous and guarded by a single state machine (OpState): + - Idle -> Allocating -> Idle + - Idle -> Withdrawing -> Payout -> Idle +- Performance fees accrue by minting fee shares on growth only. +- Strict invariants ensure queue correctness and safe removal of markets. + +## Codebase map + +- src/lib.rs + - Main contract entrypoint and storage. Declares the NEP-141 share token via FungibleToken, Owner, and Rbac derives. + - Core public API: governance (owner/curator/guardian/timelock), queue setters, allocation entrypoint (allocate), user flows (withdraw/redeem), and utility views (totals, previews, conversions). + - Storage: market configs, queues, market_supply, idle_balance, fee config, pending timelocks/guardian, and pending withdrawal FIFO. + - Op state machine (OpState) and orchestration for allocation and withdraw/payout. +- src/impl_callbacks.rs + - All async callback handlers (after_supply_*, after_create_withdraw_req, after_exec_withdraw_* and after_send_to_user). + - Context guards (ctx_allocating/ctx_withdrawing), market resolvers, reconciliation helpers, and stop_and_exit* helpers. + - Gas constants for cross-contract calls (GET_SUPPLY_POSITION_GAS, AFTER_*_GAS). +- src/impl_token_receiver.rs + - NEP-141 token receiver for deposits. Mints shares on correct token; fully refunds on wrong token (see test execute_supply_wrong_token_refunds_full). + - Updates idle_balance on deposit; allocation remains separate/async. +- src/wad.rs + - Fixed-point math utilities: mul_div_floor/mul_div_ceil, WAD constants, and compute_fee_shares. +- src/aux.rs + - Small helpers and shared utilities used across the contract (kept minimal). +- src/tests.rs and src/impl_callbacks.rs tests + - Invariants and property tests for flows, queues, conversions, and payout correctness. +- templar_common (external crate) + - Shared types and cross-contract interfaces: BorrowAsset/FungibleAsset, market::ext_market and messages, vault types (Error, Event, OpState, MarketConfiguration, etc.). + +## Roles and permissions + +Roles are enforced via RBAC. The Curator is also granted the Allocator role at init. + +- Owner + - set_curator(account) + - set_is_allocator(account, allowed) + - submit_guardian(new_g), accept_guardian(), revoke_pending_guardian() + - submit_timelock(seconds), accept_timelock(), revoke_pending_timelock() + - set_fee_recipient(account), set_performance_fee(fee) + - set_skim_recipient(account), skim(token) +- Curator (Curator also has Allocator) + - submit_cap(market, new_cap), accept_cap(market), revoke_pending_cap(market) + - submit_market_removal(market), revoke_pending_market_removal(market) +- Allocator + - set_supply_queue(markets) + - set_withdraw_queue(markets) + - allocate(weights, amount) + - execute_next_withdrawal_request() +- Guardian + - revoke_pending_timelock() + +Note +- All mutating ops require the vault to be Idle (single-op-at-a-time). Methods enforce this via ensure_idle(). + +## External integrations and interfaces + +- Underlying token (NEP-141) + - The vault is a NEP-141 receiver. Users deposit via ft_transfer_call to the vault; only the configured underlying token is accepted. + - On correct token: the vault mints shares and increases idle_balance. + - On wrong token: the vault refunds in full and mints no shares. +- Market adapters + - Allocation to markets uses underlying_asset.transfer_call(..., DepositMsg::Supply). + - Withdrawals use the market interface: + - create_supply_withdrawal_request(BorrowAssetAmount) + - execute_next_supply_withdrawal_request() + - get_supply_position(vault_id) to verify changes and reconcile accounting. +- Gas model + - Cross-contract calls use fixed gas budgets: + - AFTER_SUPPLY_ENSURE_GAS, GET_SUPPLY_POSITION_GAS, AFTER_SUPPLY_POSITION_CHECK_GAS + - AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS + - On any callback mismatch or failure, the operation gracefully stops and reverts to Idle with safe reconciliation. + +## Integrating a new market + +- Required market endpoints (templar_common::market::ext_market) + - get_supply_position(vault_id) -> SupplyPosition + - create_supply_withdrawal_request(BorrowAssetAmount) + - execute_next_supply_withdrawal_request() +- Deposit message and units + - Underlying allocation uses DepositMsg::Supply with underlying units. +- Queue membership + - Ensure the market is in withdraw_queue whenever principal > 0; the vault also enforces this on its own after allocation steps. +- Safety + - The vault tolerates failures by stopping/retrying or refunding escrow; design market adapters to fail fast and re-entrant safe. + +## Key storage and concepts + +- MarketConfiguration per market: { cap, enabled, removable_at } +- market_supply[market] = current principal supplied to that market +- idle_balance = underlying tokens held by the vault +- supply_queue and withdraw_queue (ordered lists of market AccountIds) +- pending_cap, pending_timelock, pending_guardian with timelock semantics +- pending_withdrawals FIFO queue (id -> {owner, receiver, escrow_shares, expected_assets, requested_at}) +- Fee/virtual offsets for conversions: + - performance_fee (WAD fraction) + - last_total_assets (fee accrual anchor) + - virtual_shares, virtual_assets (stability offsets for conversions/previews) + +## Conversions and fees + +- Views: + - get_total_assets() = idle + sum(principal across withdraw_queue markets) + - get_total_supply() + - get_max_deposit() aggregates per-market remaining caps in supply_queue order + - convert_to_shares(assets), convert_to_assets(shares) + - preview_deposit/mint/withdraw/redeem +- Fees: + - internal_accrue_fee() mints fee shares only on growth (current_total_assets > last_total_assets). + - Conversions simulate fee accrual and include virtual offsets via compute_effective_totals. + +- Effective totals + - All previews and conversions simulate fee accrual first and apply virtual_shares and virtual_assets to stabilize edge cases at low supply/assets. +- Accrual policy + - internal_accrue_fee() mints fee shares only when get_total_assets() > last_total_assets (no fees on losses or flat performance). + - Fee rate is a WAD fraction and bounded; fee_recipient changes first accrue under the old recipient. + +## Execution model at a glance + +- Single-operation state machine, enforced by ensure_idle() on all mutating entrypoints: + - Idle -> Allocating -> Idle + - Idle -> Withdrawing -> Payout -> Idle +- Queue-driven orchestration + - supply_queue defines allocation order; withdraw_queue defines liquidity pull priority. + - Weighted allocation mode uses a temporary in-memory plan (plan) for proportional steps. +- Consistent stop behavior + - Any index/op_id drift or cross-contract error stops the op, reconciles remaining (for allocation), or refunds escrow (for withdrawal), then returns to Idle. + +## Deposit and mint flow + +User deposits underlying and receives vault shares. Allocation into markets is separate. + +- User interface: + - Preview: preview_deposit(assets) -> expected shares + - Convert: convert_to_shares + - Mint preview: preview_mint(shares) + +- Actual deposit: + - The vault expects to receive the underlying via NEP-141 transfer (see token receiver). + - If an unexpected token sends funds, the vault refunds fully (see test execute_supply_wrong_token_refunds_full). + +- Post-deposit state: + - idle_balance increases + - No automatic allocation: allocation is triggered by Allocator via allocate(...) + +- Token receiver path + - Accept only the configured underlying token. Wrong-token deposits are refunded 100%. + - On success: idle_balance += assets; shares minted according to convert_to_shares (fee- and virtual-offset-aware). +- No auto-allocation + - Deposits remain idle until an Allocator triggers allocate(...). + +## Allocation pipeline (Idle -> Allocating -> Idle) + +Triggered by Allocator: +- allocate(weights=[], amount=None) + - Queue-based if weights empty; weighted if provided. + - total reserved = clamp_allocation_total(requested or idle), subject to get_max_deposit(). + - start_allocation(total) reserves from idle (idle_balance -= total), sets OpState::Allocating { remaining=total, index=0 }, emits AllocationStarted. + +Async loop (step_allocation): +- Picks the next market from plan (weighted) or supply_queue (queue-based). +- Computes room and to_supply, emits AllocationStepPlanned. +- If to_supply == 0, skips and advances index. +- Else transfers underlying to market via transfer_call(..., DepositMsg::Supply) and awaits after_supply_1_check. + +Callbacks: +- after_supply_1_check: + - Validates current op and resolves market. + - If transfer failed, stops and returns remaining back to idle (stop_and_exit_allocating). + - Else reads position via get_supply_position(...) -> after_supply_2_read. +- after_supply_2_read: + - Reads new_principal, computes accepted_event = new_principal - before. + - Updates market_supply, emits AllocationStepSettled. + - Ensures market is in withdraw_queue if principal > 0. + - Advances index and remaining; loops or exits. + +Exit: +- stop_and_exit_allocating(None) emits AllocationCompleted and returns any remaining to idle. +- Any error stops, returns remaining to idle, clears plan, and goes Idle. + +- Weighted vs queue-based + - If weights are provided, per-step targets are proportional to remaining and residual weights; the last market takes the remainder. + - If no weights, the vault allocates in supply_queue order, up to room (cap - current principal). +- Reservation and reconciliation + - start_allocation reserves only the planned amount (idle_balance -= amount). + - On completion or on any failure, remaining is returned to idle_balance. +- Market (re)inclusion + - If a market’s principal becomes > 0, it is ensured to be present in withdraw_queue. Re-including a market with pre-existing principal adjusts last_total_assets to avoid fee-on-reinclude. + +## Withdrawal and redeem flow + +Two phases: user requests (escrow) and allocator executes (pull liquidity, pay out). + +1) User request (escrow shares) -Entry points - withdraw(amount, receiver) - - Convenience wrapper that computes shares = preview_withdraw(amount) and calls redeem(shares, receiver). + - Computes shares_needed via preview_withdraw and defers to redeem. - redeem(shares, receiver) - - Escrows the caller’s shares in the vault and starts a withdrawal operation. - -Operational flow -1) Escrow shares - - redeem(shares) transfers the shares from the caller into the vault (escrow). Shares are not burned yet. - -2) Accrue fees and compute targets - - The vault accrues any pending performance fee, then computes the underlying amount to return for the given shares. - -3) Consume idle liquidity first - - The vault immediately uses idle_balance (underlying already held in the vault) to cover part of the request, if available. - -4) Iterate markets in withdraw_queue - - For the remaining amount, the vault iterates withdraw_queue in order. - - For each market, it requests and executes a supply withdrawal on that market (create_supply_withdrawal_request + execute_next_supply_withdrawal_request). - - After execution, the vault reads the market position to reconcile the new principal and determine how much underlying actually came back. - -5) Payout on full fulfillment - - When collected == requested (remaining == 0), the vault transfers underlying to receiver. - - On transfer success: idle_balance decreases by the payout and escrowed shares are burned. - - On transfer failure: escrowed shares are returned to the owner; idle_balance remains unchanged. - -6) Insufficient liquidity (end of queue) - - If the vault reaches the end of withdraw_queue with remaining > 0: - - Escrowed shares are returned to the owner. - - Any partial funds that did come back from markets remain in the vault’s idle_balance (they are not paid out). - - The withdrawal operation stops. Callers can retry later. - -Events to watch -- RedeemRequested { shares, estimated_assets } – emitted when a withdrawal begins. -- WithdrawalPositionMissing / WithdrawalPositionReadFailed – diagnostics when reading a market position after a withdrawal step fails. -- WithdrawalStopped { remaining, collected, reason } – emitted when the withdrawal stops without completing (e.g., InsufficientLiquidity). -- PayoutStopped / PayoutUnexpectedState – diagnostics for payout errors. -- Note: There is currently no explicit “payout succeeded” event; payout success is the normal completion path. - -Design rationale (simplicity) -- No persistent queue in the vault: fewer invariants, fewer public methods, and no long-lived state. -- Users/integrators can pre-check with preview_withdraw(amount) to estimate shares and can retry later if markets are illiquid. - -Integrator tips -- Prefer preview_withdraw(amount) to understand the share cost beforehand. -- To reduce the chance of stopping due to illiquidity, withdraw smaller amounts that can be satisfied by idle_balance or by likely-available market liquidity. -- Monitor events: - - Successful payout: the final callback after_send_to_user returns success (no explicit event). - - Stopped without payout: WithdrawalStopped will include remaining and collected amounts and a reason. - -Future enhancements considered -- Vault-level queued withdrawals (keep shares escrowed and resume later) can improve UX during illiquid periods, but add complexity (queue state, execution/cancel flows, griefing protections). The current implementation intentionally opts for the simpler “best-effort now” model. + - Transfers shares from owner to the vault (escrow) without burning. + - Converts shares to assets via convert_to_assets (estimated). + - Emits WithdrawQueued; enqueues pending withdrawal (owner, receiver, escrow_shares, expected_assets). + - Does NOT start withdrawal; allocator must call execute_next_withdrawal_request(). + +2) Allocator executes (Idle -> Withdrawing -> Payout -> Idle) + +- execute_next_withdrawal_request(): + - Pops the next pending withdrawal by id and calls start_withdraw(expected_assets, receiver, owner, escrow_shares). + +start_withdraw: +- Uses idle-first: collected = min(idle_balance, amount), remaining = amount - collected. +- Sets OpState::Withdrawing { index=0, remaining, receiver, collected, owner, escrow_shares }. + +step_withdraw: +- If remaining == 0: + - Switches to OpState::Payout and transfers collected to receiver; after_send_to_user burns escrow proportionally and refunds unused escrow. +- Else: + - Iterates withdraw_queue[index]: + - If market principal is zero, skip (advance index). + - Else create_supply_withdrawal_request(to_request) -> after_create_withdraw_req -> execute_next_supply_withdrawal_request() -> after_exec_withdraw_req -> read position -> after_exec_withdraw_read. + +Callbacks: +- after_create_withdraw_req: + - On failure: advance index; if end-of-queue, transition to Payout/Refund based on collected. +- after_exec_withdraw_req: + - Reads position afterwards to verify change. +- after_exec_withdraw_read: + - Computes credited and updates: + - credited = min(before - new, need), remaining_next = rem - credited, collected_next = coll + credited, idle += credited. + - If remaining_next == 0: + - If collected_next > 0 => Payout + - Else refund full escrow and go Idle. + - Else advance to next market and continue. + +Payout finalization: +- after_send_to_user: + - On success: + - idle_balance -= payout_amount + - Burn only the proportional shares and refund the remainder: + - burn_shares = compute_burn_shares(escrow_shares, collected, requested_total) + - (to_burn, refund_shares) = compute_escrow_settlement(escrow_shares, burn_shares) + - burn to_burn from escrow; transfer refund_shares back to owner + - Go Idle. + - On failure: + - Refund full escrow to owner; leave idle unchanged; go Idle. + +Stop behavior: +- Any callback receiving stale op_id or mismatched index will gracefully stop the op, refunding escrow (for withdraw) or reconciling remaining (for allocation), and return to Idle. + +- Two-phase withdrawal + - User redeem/withdraw: shares are escrowed in the vault account (not burned yet) and a pending withdrawal is queued with an expected_assets estimate. + - Operator execute_next_withdrawal_request(): drives the async pipeline to collect assets and pay out. +- Idle-first payout + - The vault first uses idle_balance. Any remaining amount is pulled from markets in withdraw_queue order. +- On-market withdrawal + - For each market: create request, execute next request, then read position to verify principal reduction. Credited amounts increase idle_balance. +- Payout finalization + - On success: idle_balance -= paid amount; burn only the proportional fraction of escrow_shares corresponding to the paid fraction; refund remaining escrow to the owner. + - On failure: refund full escrow; idle_balance unchanged. + +## Queues and market management + +- set_supply_queue(markets): + - Requires Idle; rejects duplicates; each market must have cap > 0. +- set_withdraw_queue(queue): + - Requires Idle; rejects duplicates; every enabled or holding market must be present. + - Removing a market requires: + - cap == 0 + - no pending cap change + - if principal > 0: removable_at set and timelock elapsed + - Removing a market also removes its configuration. + +- submit_cap(market, new_cap), accept_cap(market): + - Lowering cap applies immediately (and may disable the market if cap == 0). + - Raising cap is timelocked; accept after timelock. + - Enabling a market ensures it’s present in withdraw_queue. + +- submit_market_removal(market), revoke_pending_market_removal(market): + - Start/stop a removal timelock; actual removal occurs via set_withdraw_queue. + +- Before removing a market from withdraw_queue: + - cap == 0 and no pending cap raise. + - If principal > 0: removable_at set via submit_market_removal and timelock elapsed. +- Removing a market deletes its configuration but does not clear market_supply; off-queue principal is intentionally ignored by get_total_assets(). + +## Fee policy + +- set_performance_fee(fee) sets the WAD fraction (capped; fees accrue only on profits). +- internal_accrue_fee() mints fee shares to fee_recipient and updates last_total_assets. +- Conversions use compute_effective_totals to simulate fee shares and apply virtual offsets. + +## Reference: primary external methods by role + +- Deposits: + - User: ft_transfer_call to the vault (see token receiver), or application-level front-end wraps this. +- Allocation: + - Allocator: allocate(weights, amount) +- Withdrawals: + - User: redeem(shares, receiver) or withdraw(amount, receiver) + - Allocator: execute_next_withdrawal_request() +- Governance: + - Owner/Curator/Guardian as listed above. + +## Error handling and stop semantics + +- Allocation + - Any transfer/position read error or state mismatch stops the operation, returns remaining to idle, clears plan, and returns to Idle. +- Withdrawal + - Any state mismatch or market call failure advances to the next market; reaching end-of-queue triggers payout-if-collected or escrow refund. +- Payout + - On success: burn proportional escrow and refund the rest; on failure: refund full escrow; in both cases the vault returns to Idle. +- All stop paths emit structured events for indexing and debugging. + +## Key invariants + +- Single op in flight; ensure_idle() on all mutating entrypoints. +- Withdraw queue must contain every enabled or holding market. +- Allocation reservation never exceeds idle or available cap (clamp_allocation_total). +- Payout success always reduces idle by paid amount and burns only proportional escrow. +- Fees mint only on positive growth. + +## Testing and local development + +- Unit/property tests cover: + - Queue invariants, cap/timelock and market removal rules. + - Allocation/withdraw pipelines, payout success/failure, and escrow settlement math. + - Fee accrual on growth only, and conversion/preview bounds with virtual offsets. + - Token receiver behavior (wrong token refund). +- Running tests: + - cargo test -p templar-vault +- Tips: + - When integrating a new market, first wire get_supply_position and dry-run the withdraw path to validate reconciliation. diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 304ffde3..0c27f710 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -24,22 +24,15 @@ impl Contract { attempted: U128, ) -> PromiseOrValue<()> { // Invariant: Index drift or stale op_id results in a graceful stop - match &self.op_state { - OpState::Allocating { op_id: cur, .. } if *cur == op_id => {} - _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), - } + let _ = match self.ctx_allocating(op_id) { + Ok(_) => (), + Err(e) => return self.stop_and_exit(Some(&e)), + }; // Resolve market by plan or supply_queue - let market: AccountId = if let Some(plan) = &self.plan { - if let Some((m, _)) = plan.get(market_index as usize) { - m.clone() - } else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); - } - } else if let Some(m) = self.supply_queue.get(market_index) { - m.clone() - } else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + let market = match self.resolve_supply_market(market_index) { + Ok(m) => m, + Err(e) => return self.stop_and_exit(Some(&e)), }; // If the transfer failed, do not attempt to reconcile; stop and leave remaining untouched @@ -89,13 +82,9 @@ impl Contract { attempted: U128, accepted: U128, ) -> PromiseOrValue<()> { - let (idx, rem) = match &self.op_state { - OpState::Allocating { - op_id: cur, - index, - remaining, - } if *cur == op_id => (*index, *remaining), - _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), + let (idx, rem) = match self.ctx_allocating(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), }; // Invariant: Index drift or stale op_id results in a graceful stop @@ -104,24 +93,17 @@ impl Contract { } // Resolve market by plan (if present) or supply_queue - let market: AccountId = if let Some(plan) = &self.plan { - if let Some((m, _)) = plan.get(market_index as usize) { - m.clone() - } else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); - } - } else if let Some(m) = self.supply_queue.get(market_index) { - m.clone() - } else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + let market = match self.resolve_supply_market(market_index) { + Ok(m) => m, + Err(e) => return self.stop_and_exit(Some(&e)), }; - let (new_principal, remaining_next) = match position { + let (new_principal, accepted_event, remaining_next) = match position { Ok(Some(position)) => { let new_principal: u128 = position.get_deposit().total().into(); - let accepted = new_principal.saturating_sub(before.0); - let remaining = rem.saturating_sub(accepted); - (new_principal, remaining) + let accepted_event = new_principal.saturating_sub(before.0); + let remaining = rem.saturating_sub(accepted_event); + (new_principal, accepted_event, remaining) } Ok(None) => { Event::AllocationPositionMissing { @@ -147,8 +129,6 @@ impl Contract { } }; - let accepted_event = new_principal.saturating_sub(before.0); - let refunded = attempted.0.saturating_sub(accepted_event); Event::AllocationStepSettled { op_id, @@ -167,6 +147,11 @@ impl Contract { // Invariant: withdraw_queue gains any market with new_principal > 0 if new_principal > 0 && !self.withdraw_queue.iter().any(|m| m == &market) { + // If the market had pre-existing principal but wasn't in the withdraw_queue, + // bump last_total_assets by that pre-existing amount to avoid fee accrual on re-inclusion. + if before.0 > 0 { + self.last_total_assets = self.last_total_assets.saturating_add(before.0); + } self.withdraw_queue.push(market.clone()); } @@ -188,24 +173,9 @@ impl Contract { market_index: u32, need: U128, ) -> PromiseOrValue<()> { - let (idx, rem, recv, coll, owner, escrow_shares) = match &self.op_state { - OpState::Withdrawing { - op_id: cur, - index, - remaining, - receiver, - collected, - owner, - escrow_shares, - } if *cur == op_id => ( - *index, - *remaining, - receiver.clone(), - *collected, - owner.clone(), - *escrow_shares, - ), - _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), + let (idx, rem, recv, coll, owner, escrow_shares) = match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), }; // Invariant: Index drift or stale op_id results in a graceful stop @@ -213,8 +183,9 @@ impl Contract { return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); } - let Some(market) = self.withdraw_queue.get(market_index) else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + let market = match self.resolve_withdraw_market(market_index) { + Ok(m) => m, + Err(e) => return self.stop_and_exit(Some(&e)), }; if let Ok(()) = did_create { @@ -252,24 +223,9 @@ impl Contract { market_index: u32, need: U128, ) -> PromiseOrValue<()> { - let (idx, _rem, _recv, _coll, _owner, _escrow_shares) = match &self.op_state { - OpState::Withdrawing { - op_id: cur, - index, - remaining, - receiver, - collected, - owner, - escrow_shares, - } if *cur == op_id => ( - *index, - *remaining, - receiver.clone(), - *collected, - owner.clone(), - *escrow_shares, - ), - _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), + let (idx, _rem, _recv, _coll, _owner, _escrow_shares) = match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), }; // Invariant: Index drift or stale op_id results in a graceful stop @@ -277,12 +233,13 @@ impl Contract { return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); } - let Some(market) = self.withdraw_queue.get(market_index) else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + let market = match self.resolve_withdraw_market(market_index) { + Ok(m) => m, + Err(e) => return self.stop_and_exit(Some(&e)), }; // Verify actual withdrawal by reading market position after execution - let before = *self.market_supply.get(market).unwrap_or(&0); + let before = *self.market_supply.get(&market).unwrap_or(&0); PromiseOrValue::Promise( ext_market::ext(market.clone()) .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) @@ -304,32 +261,18 @@ impl Contract { before: U128, need: U128, ) -> PromiseOrValue<()> { - let (idx, rem, recv, coll, owner, escrow_shares) = match &self.op_state { - OpState::Withdrawing { - op_id: cur, - index, - remaining, - receiver, - collected, - owner, - escrow_shares, - } if *cur == op_id => ( - *index, - *remaining, - receiver.clone(), - *collected, - owner.clone(), - *escrow_shares, - ), - _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), + let (idx, rem, recv, coll, owner, escrow_shares) = match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), }; if idx != market_index { return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); } - let Some(market) = self.withdraw_queue.get(market_index) else { - return self.stop_and_exit(Some(&Error::MissingMarket(market_index))); + let market = match self.resolve_withdraw_market(market_index) { + Ok(m) => m, + Err(e) => return self.stop_and_exit(Some(&e)), }; let before_principal = before.0; @@ -363,15 +306,13 @@ impl Contract { } }; - let withdrawn = before_principal.saturating_sub(new_principal); - let credited = withdrawn.min(need.0); + let (credited, remaining, collected, idle_delta) = + self.reconcile_withdraw_outcome(before_principal, new_principal, need.0, rem, coll); // Update accounting to match market state self.market_supply.insert(market.clone(), new_principal); - let remaining = rem.saturating_sub(credited); - let collected = coll.saturating_add(credited); - if credited > 0 { - self.idle_balance = self.idle_balance.saturating_add(credited); + if idle_delta > 0 { + self.idle_balance = self.idle_balance.saturating_add(idle_delta); } if remaining == 0 { @@ -452,12 +393,12 @@ impl Contract { // Invariant: On payout success, idle_balance -= payout_amount. // Burn only the proportional shares and refund the remainder to the owner. self.idle_balance = self.idle_balance.saturating_sub(payout_amount); - let to_burn = burn_shares.min(escrow_shares); + let (to_burn, refund_shares) = + Self::compute_escrow_settlement(escrow_shares, burn_shares); if to_burn > 0 { self.withdraw_unchecked(&env::current_account_id(), to_burn) .expect("Failed to burn escrowed shares"); } - let refund_shares = escrow_shares.saturating_sub(to_burn); if refund_shares > 0 { #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&env::current_account_id(), &owner, refund_shares) @@ -642,6 +583,90 @@ impl Contract { } } +impl Contract { + // Validate current op is Allocating and return (index, remaining) + pub(crate) fn ctx_allocating(&self, op_id: u64) -> Result<(u32, u128), Error> { + match &self.op_state { + OpState::Allocating { + op_id: cur, + index, + remaining, + } if *cur == op_id => Ok((*index, *remaining)), + _ => Err(Error::NotAllocating(self.op_state.clone())), + } + } + + // Validate current op is Withdrawing and return context tuple + pub(crate) fn ctx_withdrawing( + &self, + op_id: u64, + ) -> Result<(u32, u128, AccountId, u128, AccountId, u128), Error> { + match &self.op_state { + OpState::Withdrawing { + op_id: cur, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + } if *cur == op_id => Ok(( + *index, + *remaining, + receiver.clone(), + *collected, + owner.clone(), + *escrow_shares, + )), + _ => Err(Error::NotWithdrawing(self.op_state.clone())), + } + } + + // Resolve a market for allocation by plan (if present) or supply_queue + pub(crate) fn resolve_supply_market(&self, market_index: u32) -> Result { + if let Some(plan) = &self.plan { + if let Some((m, _)) = plan.get(market_index as usize) { + return Ok(m.clone()); + } + return Err(Error::MissingMarket(market_index)); + } + self.supply_queue + .get(market_index) + .cloned() + .ok_or(Error::MissingMarket(market_index)) + } + + // Resolve a market for withdraw by withdraw_queue + pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result { + self.withdraw_queue + .get(market_index) + .cloned() + .ok_or(Error::MissingMarket(market_index)) + } + + // Pure reconciliation for withdraw read outcome to enable unit tests + pub(crate) fn reconcile_withdraw_outcome( + &self, + before_principal: u128, + new_principal: u128, + need: u128, + rem: u128, + coll: u128, + ) -> ( + u128, /* credited */ + u128, /* remaining_next */ + u128, /* collected_next */ + u128, /* idle_delta */ + ) { + let withdrawn = before_principal.saturating_sub(new_principal); + let credited = withdrawn.min(need); + let remaining_next = rem.saturating_sub(credited); + let collected_next = coll.saturating_add(credited); + let idle_delta = credited; + (credited, remaining_next, collected_next, idle_delta) + } +} + #[cfg(test)] mod tests { use std::u128; @@ -654,6 +679,7 @@ mod tests { use near_sdk::{test_vm_config, testing_env, PromiseResult, RuntimeFeesConfig}; use rstest::rstest; use templar_common::asset::{BorrowAsset, FungibleAsset}; + use templar_common::vault::Error; use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; use test_utils::vault_configuration; @@ -1042,4 +1068,161 @@ mod tests { } } } + + #[test] + fn refund_path_consistency() { + 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); + + // Seed escrowed shares into the vault's own account + let owner = accounts(1); + c.deposit_unchecked(&near_sdk::env::current_account_id(), 10) + .expect("seed escrow into vault"); + + // Single-market withdraw queue (not used functionally here, just to satisfy path) + let market = mk(12); + c.withdraw_queue.push(market); + + // Withdrawing state with remaining=0 and collected=0 forces refund path + c.op_state = OpState::Withdrawing { + op_id: 77, + index: 0, + remaining: 0, + receiver: mk(9), + collected: 0, + owner: owner.clone(), + escrow_shares: 10, + }; + + 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); + + // Read result with need=0 ensures credited=0; triggers refund branch + let res = c.after_exec_withdraw_read(Ok(None), 77, 0, U128(0), U128(0)); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) on immediate escrow refund"), + } + + // No burn/mint => total supply unchanged + assert_eq!( + c.total_supply(), + supply_before, + "no supply change on refund" + ); + // Escrow shares transferred back to owner + assert_eq!( + c.balance_of(&near_sdk::env::current_account_id()), + vault_before.saturating_sub(10), + "vault should lose refunded escrow" + ); + assert_eq!( + c.balance_of(&owner), + owner_before.saturating_add(10), + "owner should receive refunded escrow" + ); + // Vault returns to Idle + assert!( + matches!(c.op_state, OpState::Idle), + "Vault must go Idle after refund" + ); + } + + #[test] + fn ctx_allocating_ok_and_err() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + c.op_state = OpState::Allocating { + op_id: 42, + index: 3, + remaining: 77, + }; + + let ok = c.ctx_allocating(42).expect("ctx_allocating should succeed"); + assert_eq!(ok, (3, 77)); + + // Wrong op_id => error + assert!(c.ctx_allocating(43).is_err()); + } + + #[test] + fn ctx_withdrawing_ok_and_err() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let recv = mk(1); + let owner = accounts(1); + + c.op_state = OpState::Withdrawing { + op_id: 7, + index: 1, + remaining: 50, + receiver: recv.clone(), + collected: 5, + owner: owner.clone(), + escrow_shares: 10, + }; + + let (idx, rem, r, coll, o, escrow) = c + .ctx_withdrawing(7) + .expect("ctx_withdrawing should succeed"); + assert_eq!(idx, 1); + assert_eq!(rem, 50); + assert_eq!(r, recv); + assert_eq!(coll, 5); + assert_eq!(o, owner); + assert_eq!(escrow, 10); + + // Wrong op_id => error + assert!(c.ctx_withdrawing(8).is_err()); + } + + #[test] + fn resolve_market_helpers_supply_and_withdraw() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Prepare markets + let m1 = mk(1001); + let m2 = mk(1002); + + // Supply: plan takes precedence + c.plan = Some(vec![(m2.clone(), 1u128)]); + c.supply_queue.push(m1.clone()); + c.supply_queue.push(m2.clone()); + + assert_eq!(c.resolve_supply_market(0).unwrap(), m2); + assert!(matches!( + c.resolve_supply_market(1), + Err(Error::MissingMarket(1)) + )); + + // Without plan, use queue + c.plan = None; + assert_eq!(c.resolve_supply_market(0).unwrap(), m1); + assert_eq!(c.resolve_supply_market(1).unwrap(), m2); + assert!(matches!( + c.resolve_supply_market(2), + Err(Error::MissingMarket(2)) + )); + + // Withdraw resolver uses withdraw_queue + c.withdraw_queue.push(m1.clone()); + c.withdraw_queue.push(m2.clone()); + assert_eq!(c.resolve_withdraw_market(0).unwrap(), m1); + assert_eq!(c.resolve_withdraw_market(1).unwrap(), m2); + assert!(matches!( + c.resolve_withdraw_market(2), + Err(Error::MissingMarket(2)) + )); + } + } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 3f0c971c..38fd4e74 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -457,12 +457,7 @@ impl Contract { if new_cap < config.cap { // If lowering the cap, we can apply the delta immediately - config.cap = new_cap; - // Disable market if cap is zero - if new_cap == 0 { - config.enabled = false; - } } else { let valid_at = env::block_timestamp() + self.timelock_ns; self.pending_cap.insert( @@ -529,8 +524,6 @@ impl Contract { } } cfg.removable_at = 0; - } else { - cfg.enabled = false; } Event::SupplyCapSet { market: market.clone(), @@ -643,48 +636,54 @@ impl Contract { let current: std::collections::HashSet = self.withdraw_queue.iter().cloned().collect(); - // Each id in the new queue must correspond to a known market for id in &queue { - assert!(self.config.get(id).is_some(), "Unknown market in new queue"); + assert!( + self.config.get(id).is_some(), + "Invariant violation: Unknown market in new queue" + ); } - // Enforce invariant: withdraw_queue must include all enabled or holding markets for (id, cfg) in self.config.iter() { let has_supply = *self.market_supply.get(id).unwrap_or(&0) > 0; - if cfg.enabled || has_supply { - assert!( - seen.contains(id), - "Withdraw queue must include all enabled or holding markets" - ); + println!( + "ID: {}, Enabled: {}, Has Supply: {}, Removable At: {}", + id, cfg.enabled, has_supply, cfg.removable_at + ); + + if (cfg.enabled || has_supply) && !seen.contains(id) { + if current.contains(id) { + // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. + assert!( + cfg.cap == 0, + "Invariant violation: Cannot remove market with non-zero cap" + ); + assert!( + self.pending_cap.get(id).is_none(), + "Invariant violation: Cannot remove market with pending cap change" + ); + if has_supply { + assert!( + cfg.removable_at > 0, + "Invariant violation: Market still has supply but no removal scheduled" + ); + assert!( + env::block_timestamp() >= cfg.removable_at, + "Invariant violation: Removal timelock not elapsed for market" + ); + } + } else { + // Not in current queue: must be included if enabled or holding. + env::panic_str( + "Invariant violation: Withdraw queue must include all enabled or holding markets", + ); + } } } - // For every market being removed, enforce safety invariants before removal for id in current.difference(&seen).cloned().collect::>() { - #[allow(clippy::expect_used, reason = "No side effects")] - let config = self.config.get_mut(&id).expect("Market not found"); - - assert!(config.cap == 0, "Cannot remove market with non-zero cap"); - assert!( - self.pending_cap.get(&id).is_none(), - "Cannot remove market with pending cap change" - ); - let position = *self.market_supply.get(&id).unwrap_or(&0); - if position > 0 { - assert!( - config.removable_at > 0, - "Market still has supply but no removal scheduled" - ); - assert!( - env::block_timestamp() >= config.removable_at, - "Removal timelock not elapsed for market" - ); - } - // Remove market configuration self.config.remove(&id); } - // Replace withdraw_queue atomically self.withdraw_queue.clear(); for id in &queue { self.withdraw_queue.push(id.clone()); @@ -710,11 +709,11 @@ impl Contract { let assets = self.convert_to_assets(U128(shares)).0; - let owner = env::predecessor_account_id(); + let sender = env::predecessor_account_id(); - // Move shares into vault escrow; do not burn yet + // Move shares into escrow #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&owner, &env::current_account_id(), shares) + self.transfer_unchecked(&sender, &env::current_account_id(), shares) .expect("Redeem failed to move shares into escrow"); self.internal_accrue_fee(); @@ -725,7 +724,7 @@ impl Contract { } .emit(); - self.enqueue_pending_withdrawal(&owner, &receiver, shares, assets); + self.enqueue_pending_withdrawal(&sender, &receiver, shares, assets); PromiseOrValue::Value(()) } @@ -778,9 +777,7 @@ impl Contract { // If no weights provided, use queue order; clamp total and emit request event. if weights.is_empty() { - let requested: u128 = amount.map_or(self.idle_balance, |x| x.0); - let max_room = self.get_max_deposit().0; - let total = requested.min(self.idle_balance).min(max_room); + let total = self.clamp_allocation_total(amount.map(|x| x.0)); if total == 0 { return self.stop_and_exit(Some(&Error::ZeroAmount)); } @@ -808,9 +805,7 @@ impl Contract { } // Clamp total allocation by idle balance and aggregate room - let requested: u128 = amount.map_or(self.idle_balance, |x| x.0); - let max_room = self.get_max_deposit().0; - let total = requested.min(self.idle_balance).min(max_room); + let total = self.clamp_allocation_total(amount.map(|x| x.0)); if total == 0 { env::panic_str("No funds to allocate"); } @@ -848,6 +843,7 @@ impl Contract { /* ----- Views ----- */ #[near] impl Contract { + #[allow(clippy::expect_used, reason = "No side effects")] pub fn get_configuration(&self) -> VaultConfiguration { let timelock_sec = self.timelock_ns / 1_000_000_000; VaultConfiguration { @@ -1015,15 +1011,62 @@ impl Contract { fn effective_totals_fee_aware(&self) -> (u128, u128) { let cur = self.get_total_assets().0; let ts = self.total_supply(); - let fee_shares = - crate::wad::compute_fee_shares(cur, self.last_total_assets, self.performance_fee, ts); - let new_total_supply = ts + Self::compute_effective_totals( + cur, + self.last_total_assets, + self.performance_fee, + ts, + self.virtual_shares, + self.virtual_assets, + ) + } + + // Pure helper to compute how many escrowed shares to burn on partial payout + fn compute_burn_shares( + &self, + escrow_shares: u128, + collected: u128, + requested_total: u128, + ) -> u128 { + mul_div_floor(escrow_shares, collected, requested_total.max(1)) + } + + pub(crate) fn compute_effective_totals( + cur_assets: u128, + last_total_assets: u128, + performance_fee: u128, + total_supply: u128, + virtual_shares: u128, + virtual_assets: u128, + ) -> (u128, u128) { + let fee_shares = crate::wad::compute_fee_shares( + cur_assets, + last_total_assets, + performance_fee, + total_supply, + ); + let new_total_supply = total_supply .saturating_add(fee_shares) - .saturating_add(self.virtual_shares); - let new_total_assets = cur.saturating_add(self.virtual_assets); + .saturating_add(virtual_shares); + let new_total_assets = cur_assets.saturating_add(virtual_assets); (new_total_supply, new_total_assets) } + pub(crate) 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, + ) -> (u128 /* to_burn */, u128 /* refund */) { + let to_burn = burn_shares.min(escrow_shares); + let refund = escrow_shares.saturating_sub(to_burn); + (to_burn, refund) + } + /* ----- Internal: fee, shares ----- */ pub fn mint_shares(&mut self, to: &AccountId, amount: u128) { if amount == 0 { @@ -1384,7 +1427,7 @@ impl Contract { ) -> PromiseOrValue<()> { if collected > 0 { let requested = collected.saturating_add(remaining); - let burn_shares = mul_div_floor(escrow_shares, collected, requested.max(1)); + let burn_shares = self.compute_burn_shares(escrow_shares, collected, requested); self.op_state = OpState::Payout { op_id, receiver: receiver.clone(), diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index d3e67e18..40d458a5 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -1,8 +1,9 @@ use crate::Contract; -use near_sdk::{ +pub use near_sdk::{ test_utils::{accounts, VMContextBuilder}, test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, }; +use near_sdk_contract_tools::ft::Nep141Controller as _; use rstest::rstest; use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; use test_utils::vault_configuration; @@ -51,3 +52,46 @@ pub fn new_test_contract(vault_id: &AccountId) -> Contract { Contract::new(cfg) } +// Set the block timestamp and keep caller/predecessor consistent for tests +pub fn set_block_ts(vault_id: &AccountId, signer: &AccountId, ts: u64) { + let mut ctx = VMContextBuilder::new(); + ctx.current_account_id(vault_id.clone()); + ctx.signer_account_id(signer.clone()); + ctx.predecessor_account_id(signer.clone()); + ctx.block_timestamp(ts); + testing_env!(ctx.build()); +} + +// Ensure a market exists with given configuration and optionally adds to queues and supply +pub fn ensure_market( + c: &mut crate::Contract, + id: AccountId, + cap: u128, + enabled: bool, + supply: u128, + in_withdraw: bool, + in_supply: bool, + removable_at: u64, +) { + let mut cfg = templar_common::vault::MarketConfiguration::default(); + cfg.cap = cap; + cfg.enabled = enabled; + cfg.removable_at = removable_at; + c.config.insert(id.clone(), cfg); + if supply > 0 { + c.market_supply.insert(id.clone(), supply); + } + if in_withdraw && !c.withdraw_queue.iter().any(|m| m == &id) { + c.withdraw_queue.push(id.clone()); + } + if in_supply && !c.supply_queue.iter().any(|m| m == &id) { + c.supply_queue.push(id.clone()); + } +} + +// Seed shares into the vault's own account (used for escrow/burn tests) +pub fn seed_vault_shares(c: &mut crate::Contract, shares: u128) { + #[allow(clippy::expect_used, reason = "test helper")] + c.deposit_unchecked(&near_sdk::env::current_account_id(), shares) + .expect("seed escrow into vault"); +} diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index a45db52d..67de4346 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,7 +1,8 @@ use crate::test_utils::*; use crate::Contract; +use near_sdk::env; use near_sdk::test_utils::accounts; -use near_sdk::{json_types::U128, test_utils::VMContextBuilder, AccountId, RuntimeFeesConfig}; +use near_sdk::{json_types::U128, AccountId, RuntimeFeesConfig}; use near_sdk::{test_vm_config, testing_env}; use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::owner::OwnerExternal; @@ -93,24 +94,6 @@ fn fee_accrues_only_on_growth_unit() { ); } -#[test] -fn contract_convert_roundtrip_bounds() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let c = new_test_contract(&vault_id); - - let a = U128(1_234_567); - let s = U128(987_654); - - // With virtual offsets, inequalities must hold - let to_sh = c.convert_to_shares(a); - let back_a = c.convert_to_assets(to_sh); - assert!(back_a.0 <= a.0); - - let to_a = c.convert_to_assets(s); - let back_s = c.convert_to_shares(to_a); - assert!(back_s.0 >= s.0); -} #[test] fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { @@ -264,3 +247,502 @@ fn set_withdraw_queue_must_include_all_enabled() { // Missing m1 should panic c.set_withdraw_queue(vec![m2]); } + +#[test] +fn start_allocation_reserves_only_amount() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Configure a single market with cap = 80 in the supply queue + let m1 = mk(2000); + let mut cfg = MarketConfiguration::default(); + cfg.cap = 80; + cfg.enabled = true; + c.config.insert(m1.clone(), cfg); + c.supply_queue.push(m1.clone()); + + // 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"); + + // Reserve only the amount to allocate (intended behavior) + let total = c.get_max_deposit().0.min(c.idle_balance); + c.start_allocation(total); + + // Emulate allocation completing successfully: 80 moved to market + c.market_supply.insert(m1.clone(), 80); + if !c.withdraw_queue.iter().any(|x| x == &m1) { + c.withdraw_queue.push(m1.clone()); + } + // Force completion and exit op + if let crate::OpState::Allocating { op_id, index, .. } = c.op_state.clone() { + c.op_state = crate::OpState::Allocating { + op_id, + index, + remaining: 0, + }; + } else { + panic!("expected Allocating state"); + } + let _ = c.stop_and_exit::(None); + + // Expected post-conditions: + // - idle should retain 20 + // - total assets (idle + market principals) should remain 100 + assert_eq!( + c.idle_balance, 20, + "idle should retain unallocated amount (100 - 80)" + ); + assert_eq!( + c.get_total_assets().0, + 100, + "total assets must remain unchanged at 100" + ); +} + +#[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(), vec![]); + + // Supply queue has m1; stale plan points to m2 + let m1 = mk(3001); + let m2 = mk(3002); + + let mut cfg1 = MarketConfiguration::default(); + cfg1.cap = 10; + cfg1.enabled = true; + c.config.insert(m1.clone(), cfg1); + c.withdraw_queue.push(m1.clone()); + c.supply_queue.push(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" + ); +} + +#[test] +#[should_panic = "Market still has supply but no removal scheduled"] +fn set_withdraw_queue_disallow_nonzero_position_removal() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + + let m1 = mk(4001); + + let mut cfg = MarketConfiguration::default(); + cfg.cap = 0; // required precondition to attempt removal + cfg.enabled = true; + c.config.insert(m1.clone(), cfg); + + // Market has non-zero position but no removal scheduled + c.market_supply.insert(m1.clone(), 1); + + // Present in current withdraw queue so removal logic executes + c.withdraw_queue.push(m1); + + // Attempting to remove should panic due to non-zero position without removal schedule + c.set_withdraw_queue(vec![]); +} + +#[rstest( + escrow, collected, requested, expect, + case(100u128, 200u128, 500u128, 40u128), // 40% + case(123u128, 0u128, 456u128, 0u128), // no collection => no burn + case(100u128, 1u128, 3u128, 33u128), // floor on rounding + case(50u128, 10u128, 0u128, 500u128) // denom clamp to 1 +)] +fn compute_burn_shares_cases(escrow: u128, collected: u128, requested: u128, expect: u128) { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let c = new_test_contract(&vault_id); + + assert_eq!(c.compute_burn_shares(escrow, collected, requested), expect); +} + + + +#[test] +fn compute_effective_totals_fee_share_and_virtuals() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let c = new_test_contract(&vault_id); + + let cur = 1_500u128; + let last = 1_000u128; + let perf = crate::wad::WAD / 10; // 10% + let ts = 1_000u128; + let vs = 1u128; + let va = 1u128; + + let (nts, nta) = Contract::compute_effective_totals(cur, last, perf, ts, vs, va); + let expected_fee = crate::wad::compute_fee_shares(cur, last, perf, ts); + + assert_eq!(nts, ts + expected_fee + vs); + assert_eq!(nta, cur + va); +} + + +#[test] +fn compute_escrow_settlement_burns_min_and_refunds_rest() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let c = new_test_contract(&vault_id); + + assert_eq!(Contract::compute_escrow_settlement(100, 40), (40, 60)); + assert_eq!(Contract::compute_escrow_settlement(100, 200), (100, 0)); + assert_eq!(Contract::compute_escrow_settlement(0, 50), (0, 0)); +} + + +#[test] +fn removing_holding_market_hides_assets_and_leaves_orphan_supply() { + + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c.own_get_owner().unwrap(); + + let m = mk(7001); + + // Market is known, holding > 0, with cap=0 and removal already scheduled. + // This satisfies current preconditions in set_withdraw_queue for omission. + let mut cfg = MarketConfiguration::default(); + cfg.cap = 0; + cfg.enabled = true; + cfg.removable_at = 1; // scheduled in the past relative to the block timestamp we set below + c.config.insert(m.clone(), cfg); + c.market_supply.insert(m.clone(), 10); + + // Present in current withdraw queue + c.withdraw_queue.push(m.clone()); + + // Advance block timestamp so timelock precondition passes + set_block_ts(&vault_id, &owner, 2); + + // Remove the market from the queue (new queue empty) + c.set_withdraw_queue(vec![]); + + // Config was removed, but supply mapping still exists (orphaned) + assert!(c.config.get(&m).is_none(), "Config should be removed"); + assert_eq!( + *c.market_supply.get(&m).unwrap_or(&0), + 10, + "Principal remains in market_supply but is orphaned" + ); + + // Total assets now undercount because get_total_assets sums withdraw_queue only + assert_eq!( + c.get_total_assets().0, + c.idle_balance, // withdraw_queue is empty, so principal is ignored + "Total assets should not silently drop due to queue-based accounting" + ); +} + +#[test] +fn cap_zero_keeps_enabled_and_submit_removal_works() { + 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![]); + + let m = mk(8001); + + // Seed a known, enabled market with cap > 0 + let mut cfg = MarketConfiguration::default(); + cfg.cap = 10; + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + + // Lower cap to zero: should NOT disable the market anymore + c.submit_cap(m.clone(), U128(0)); + let cfg_after = c.config.get(&m).expect("market must exist"); + assert_eq!(cfg_after.cap, 0, "cap must be updated to 0"); + assert!(cfg_after.enabled, "enabled must remain true when cap is 0"); + + set_block_ts(&vault_id, &owner, 2); + + // Now we can schedule removal + c.submit_market_removal(m.clone()); + let cfg_after2 = c.config.get(&m).expect("market must exist"); + assert!(cfg_after2.removable_at > 0, "removal must be scheduled"); +} + +#[test] +fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { + 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![]); + + let m = mk(8002); + + // Start disabled with cap=0 + c.config.insert(m.clone(), MarketConfiguration::default()); + + // Submit raise -> pending + let raise = 5u128; + c.submit_cap(m.clone(), U128(raise)); + // Fast-forward timelock to accept the raise + set_block_ts(&vault_id, &owner, env::block_timestamp() + 1_000_000_000); + c.accept_cap(m.clone()); + + let cfg1 = c.config.get(&m).unwrap(); + assert_eq!(cfg1.cap, raise); + assert!(cfg1.enabled, "market should be enabled after raise"); + assert!( + c.withdraw_queue.iter().any(|x| x == &m), + "market must be in withdraw queue after enabling" + ); + + // Now lower back to 0 (immediate path) and ensure enabled stays true + c.submit_cap(m.clone(), U128(0)); + let cfg2 = c.config.get(&m).unwrap(); + assert_eq!(cfg2.cap, 0); + assert!(cfg2.enabled, "enabled must remain true on cap=0"); +} + +#[test] +#[should_panic = "Invariant violation: Cannot remove market with non-zero cap"] +fn set_withdraw_queue_disallow_nonzero_cap_removal() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + + let m = mk(5000); + let mut cfg = MarketConfiguration::default(); + cfg.cap = 1; // non-zero cap + cfg.enabled = true; // must be enabled or holding to trigger invariant + c.config.insert(m.clone(), cfg); + c.withdraw_queue.push(m.clone()); + + // Attempt to remove from queue should panic due to non-zero cap + c.set_withdraw_queue(vec![]); +} + +#[test] +#[should_panic = "Invariant violation: Cannot remove market with pending cap change"] +fn set_withdraw_queue_disallow_pending_cap_removal() { + 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![]); + + let m = mk(5001); + let mut cfg = MarketConfiguration::default(); + cfg.cap = 0; + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + c.withdraw_queue.push(m.clone()); + + // Insert a pending cap change + c.pending_cap.insert( + m.clone(), + templar_common::vault::PendingValue { + value: 1, + valid_at: env::block_timestamp() + 1, + }, + ); + + // Attempt to remove from queue should panic due to pending cap change + c.set_withdraw_queue(vec![]); +} + +#[test] +#[should_panic = "Invariant violation: Removal timelock not elapsed for market"] +fn set_withdraw_queue_disallow_timelock_not_elapsed() { + 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![]); + + let m = mk(5002); + let mut cfg = MarketConfiguration::default(); + cfg.cap = 0; + cfg.enabled = true; + cfg.removable_at = 10; // in the future relative to block timestamp we set below + c.config.insert(m.clone(), cfg); + c.market_supply.insert(m.clone(), 1); // non-zero supply enforces timelock path + c.withdraw_queue.push(m.clone()); + + // Set block timestamp below removable_at so timelock has not elapsed + set_block_ts(&vault_id, &owner, 5); + + // Attempt to remove from queue should panic due to timelock not elapsed + c.set_withdraw_queue(vec![]); +} + +#[test] +fn set_withdraw_queue_allows_zero_supply_removal() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + + let m = mk(5003); + let mut cfg = MarketConfiguration::default(); + cfg.cap = 0; + cfg.enabled = true; + // removable_at irrelevant when supply is zero + c.config.insert(m.clone(), cfg); + c.withdraw_queue.push(m.clone()); + + // Supply is zero; removal should be allowed immediately + c.set_withdraw_queue(vec![]); + + // Config should be deleted + assert!( + c.config.get(&m).is_none(), + "Config must be removed for omitted market with zero supply" + ); + // And the queue should be empty + assert!( + !c.withdraw_queue.iter().any(|x| x == &m), + "Withdraw queue must not contain the removed market" + ); +} + +#[test] +#[should_panic = "Invariant violation: Unknown market in new queue"] +fn set_withdraw_queue_rejects_unknown_market() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + + // No config for this market + let unknown = mk(5999); + c.set_withdraw_queue(vec![unknown]); +} + +#[rstest( + before, + new_principal, + need, + rem, + coll, + case(100u128, 55u128, 40u128, 50u128, 10u128), + case(100u128, 80u128, 40u128, 50u128, 10u128), + case(0u128, 0u128, 0u128, 0u128, 0u128), + case(1000u128, 1000u128, 500u128, 800u128, 100u128), + case(200u128, 0u128, 300u128, 0u128, 0u128) +)] +fn reconcile_withdraw_outcome_invariants_cases( + before: u128, + new_principal: u128, + need: u128, + rem: u128, + coll: u128, +) { + let c = new_test_contract(&mk(0)); + let (credited, remaining_next, collected_next, idle_delta) = + c.reconcile_withdraw_outcome(before, new_principal, need, rem, coll); + + let withdrawn = before.saturating_sub(new_principal); + let expected_credited = withdrawn.min(need); + + assert_eq!(credited, expected_credited); + assert!(credited <= need); + assert_eq!(remaining_next, rem.saturating_sub(credited)); + assert_eq!(collected_next, coll.saturating_add(credited)); + assert_eq!(idle_delta, credited); +} + +#[rstest( + assets, + shares, + case(0u128, 0u128), + case(1u128, 1u128), + case(1_000_000_000_000_000_000u128, 1u128), + case(123_456_789u128, 987_654_321u128), + case(1u128, 1_000_000_000_000_000_000u128) +)] +fn convert_roundtrip_bounds_cases(assets: u128, shares: u128) { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let c = new_test_contract(&vault_id); + + let to_sh = c.convert_to_shares(U128(assets)); + let back_a = c.convert_to_assets(to_sh); + assert!( + back_a.0 <= assets, + "assets->shares->assets must not increase" + ); + + let to_a = c.convert_to_assets(U128(shares)); + let back_s = c.convert_to_shares(to_a); + assert!( + back_s.0 >= shares, + "shares->assets->shares must not decrease" + ); +} + +#[rstest( + cap, + cur, + idle, + req, + case(100u128, 60u128, 80u128, None), + case(100u128, 0u128, 80u128, Some(50u128)), + case(10u128, 10u128, 80u128, None), + case(0u128, 0u128, 0u128, Some(1u128)) +)] +fn clamp_allocation_total_matches_min_bounds_cases( + cap: u128, + cur: u128, + idle: u128, + req: Option, +) { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let m = mk(1); + let mut cfg = MarketConfiguration::default(); + cfg.cap = cap; + cfg.enabled = cap > 0; + c.config.insert(m.clone(), cfg); + c.market_supply.insert(m.clone(), cur); + c.supply_queue.push(m.clone()); + c.idle_balance = idle; + + let room = if cap > cur { cap - cur } else { 0 }; + let requested = req.unwrap_or(c.idle_balance); + let expect = requested.min(c.idle_balance).min(room); + + let got = c.clamp_allocation_total(req); + assert_eq!(got, expect); +} + +#[rstest( + principal, + idle, + case(0u128, 0u128), + case(123u128, 0u128), + case(0u128, 456u128), + case(789u128, 1_011u128) +)] +fn total_assets_ignores_offqueue_cases(principal: u128, idle: u128) { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + let m = mk(7003); + c.config.insert(m.clone(), MarketConfiguration::default()); + c.market_supply.insert(m.clone(), principal); + c.idle_balance = idle; + + assert_eq!(c.get_total_assets().0, idle); +} From 37d4552b1237085c96f697f142461573c37691c4 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 14 Oct 2025 12:05:34 +0100 Subject: [PATCH 027/121] feat: storage management :< --- contract/vault/src/lib.rs | 62 ++++++++++++++- contract/vault/src/storage_management.rs | 96 ++++++++++++++++++++++++ contract/vault/src/test_utils.rs | 12 ++- contract/vault/src/tests.rs | 21 ++++-- test-utils/src/controller/vault.rs | 11 ++- 5 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 contract/vault/src/storage_management.rs diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 38fd4e74..6a73bcd5 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -29,9 +29,15 @@ use templar_common::{ }; pub use wad::*; +use crate::storage_management::{ + require_attached_at_least, require_attached_for_pending_withdrawal, + storage_bytes_for_queue_item, yocto_for_bytes, yocto_for_new_market, yocto_for_pending_cap, +}; + pub mod aux; pub mod impl_callbacks; pub mod impl_token_receiver; +pub mod storage_management; pub mod wad; #[cfg(test)] @@ -427,9 +433,21 @@ impl Contract { /* ----- Market config / queues ----- */ /// Submits a change to a market's supply cap. /// Decreases apply immediately; increases are subject to the governance timelock. + #[payable] pub fn submit_cap(&mut self, market: AccountId, new_cap: U128) { Self::assert_curator_or_owner(); self.ensure_idle(); + + let mut required_deposit: u128 = 0; + if self.config.get(&market).is_none() { + required_deposit = required_deposit.saturating_add(yocto_for_new_market(&market)); + } + let current_cap = self.config.get(&market).map_or(0, |c| c.cap); + if new_cap.0 > current_cap { + required_deposit = required_deposit.saturating_add(yocto_for_pending_cap(&market)); + } + require_attached_at_least(required_deposit, "submit_cap"); + let config = match self.config.get_mut(&market) { None => { self.config @@ -438,6 +456,8 @@ impl Contract { market: market.clone(), } .emit(); + // Pre-allocate a market_supply record (principal=0) so allocations don't create storage later + self.market_supply.insert(market.clone(), 0); #[allow(clippy::unwrap_used, reason = "No side effects")] self.config.get_mut(&market).unwrap() } @@ -477,6 +497,7 @@ impl Contract { } /// Accepts a pending cap increase for `market` once the timelock has elapsed. + #[payable] pub fn accept_cap(&mut self, market: AccountId) { Self::assert_curator_or_owner(); self.ensure_idle(); @@ -505,6 +526,10 @@ impl Contract { } .emit(); } else { + require_attached_at_least( + yocto_for_bytes(storage_bytes_for_queue_item(&market)), + "withdraw queue entry", + ); self.withdraw_queue.push(market.clone()); Event::MarketEnabled { market: market.clone(), @@ -588,6 +613,7 @@ impl Contract { /// Sets the ordered supply (allocation) queue. /// Rejects duplicates and markets without a positive cap. Requires the vault to be idle. + #[payable] pub fn set_supply_queue(&mut self, markets: Vec) { Self::assert_allocator(); self.ensure_idle(); @@ -600,11 +626,20 @@ impl Contract { env::panic_str(&format!("Duplicate market {m}")); } } - - self.supply_queue.clear(); + // Validate all markets are authorized (cap > 0) before charging storage for m in &markets { let cap = self.config.get(m).map_or(0, |c| c.cap); assert!(cap > 0, "unauthorized market"); + } + + // Compute and require storage for additions (no refunds for removals in this pass) + let current: std::collections::HashSet = + self.supply_queue.iter().cloned().collect(); + let required_yocto = storage_management::yocto_for_queue_additions(¤t, &markets); + require_attached_at_least(required_yocto, "supply queue update"); + + self.supply_queue.clear(); + for m in &markets { self.supply_queue.push(m.clone()); } } @@ -617,6 +652,7 @@ impl Contract { /// If the vault still has a supply in that market (vault_shares_in_market > 0), the market must have had submit_market_removal called (removable_at set) and the timelock must have passed. /// Sets the ordered withdraw queue. /// Enforces safety invariants and the policy that all enabled/holding markets must be present. + #[payable] pub fn set_withdraw_queue(&mut self, queue: Vec) { Self::assert_allocator(); self.ensure_idle(); @@ -680,6 +716,8 @@ impl Contract { } } + let required_yocto = storage_management::yocto_for_queue_additions(¤t, &queue); + require_attached_at_least(required_yocto, "withdraw queue update"); for id in current.difference(&seen).cloned().collect::>() { self.config.remove(&id); } @@ -697,6 +735,7 @@ impl Contract { /* ----- Withdraw / Redeem ----- */ /// Burns the necessary shares to withdraw `amount` of underlying to `receiver`. /// Internally calls `redeem` after computing the share amount. + #[payable] pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { let shares_needed = self.preview_withdraw(amount).0; self.redeem(U128(shares_needed), receiver) @@ -704,6 +743,7 @@ impl Contract { /// Redeems `shares` for underlying assets sent to `receiver`. /// Shares are escrowed to the contract and only burned after successful payout. + #[payable] pub fn redeem(&mut self, shares: U128, receiver: AccountId) -> PromiseOrValue<()> { let shares = shares.0; @@ -711,6 +751,9 @@ impl Contract { let sender = env::predecessor_account_id(); + // Require storage deposit for the pending withdrawal entry + let req_yocto = require_attached_for_pending_withdrawal(&sender, &receiver); + // Move shares into escrow #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&sender, &env::current_account_id(), shares) @@ -724,7 +767,7 @@ impl Contract { } .emit(); - self.enqueue_pending_withdrawal(&sender, &receiver, shares, assets); + self.enqueue_pending_withdrawal(&sender, &receiver, shares, assets, req_yocto); PromiseOrValue::Value(()) } @@ -767,6 +810,7 @@ impl Contract { ) } + #[payable] pub fn allocate( &mut self, weights: AllocationWeights, @@ -775,6 +819,17 @@ impl Contract { Self::assert_allocator(); self.ensure_idle(); + // Require storage deposit up-front for any markets that may be added to withdraw_queue + let existing: std::collections::HashSet = + self.withdraw_queue.iter().cloned().collect(); + let candidates: Vec = if weights.is_empty() { + self.supply_queue.iter().cloned().collect() + } else { + weights.iter().map(|(m, _)| m.clone()).collect() + }; + let required_yocto = storage_management::yocto_for_queue_additions(&existing, &candidates); + require_attached_at_least(required_yocto, "potential queue additions"); + // If no weights provided, use queue order; clamp total and emit request event. if weights.is_empty() { let total = self.clamp_allocation_total(amount.map(|x| x.0)); @@ -978,6 +1033,7 @@ impl Contract { receiver: &AccountId, escrow_shares: u128, expected_assets: u128, + deposit_yocto: u128, ) { let id = self.next_withdraw_id; self.next_withdraw_id = self.next_withdraw_id.saturating_add(1); diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs new file mode 100644 index 00000000..ae1c2024 --- /dev/null +++ b/contract/vault/src/storage_management.rs @@ -0,0 +1,96 @@ +use near_sdk::borsh::{self, BorshSerialize}; +use near_sdk::{env, AccountId}; +use std::collections::HashSet; +use templar_common::vault::MarketConfiguration; + +// Conservative per-entry overheads to cover collection metadata, prefixes, etc. +pub const MAP_ENTRY_OVERHEAD: u64 = 64; +pub const VEC_ITEM_OVERHEAD: u64 = 16; + +// Borsh length of an AccountId (4-byte length + bytes) +pub fn storage_bytes_for_account_id(id: &AccountId) -> u64 { + 4 + (id.as_str().as_bytes().len() as u64) +} + +pub fn storage_bytes_for_queue_item(id: &AccountId) -> u64 { + VEC_ITEM_OVERHEAD + storage_bytes_for_account_id(id) +} + +pub fn storage_bytes_for_config_entry(market: &AccountId) -> u64 { + let key = storage_bytes_for_account_id(market); + // Value size from default config serialization (upper-bound enough for our use) + let cfg = MarketConfiguration::default(); + let val = borsh::to_vec(&cfg).map(|v| v.len() as u64).unwrap_or(32); + MAP_ENTRY_OVERHEAD + key + val +} + +pub fn storage_bytes_for_market_supply_entry(market: &AccountId) -> u64 { + let key = storage_bytes_for_account_id(market); + // u128 principal + let val = 16u64; + MAP_ENTRY_OVERHEAD + key + val +} + +pub fn storage_bytes_for_pending_cap_entry(market: &AccountId) -> u64 { + let key = storage_bytes_for_account_id(market); + // PendingValue { value: u128, valid_at: u64 } + let val = 16u64 + 8u64; + MAP_ENTRY_OVERHEAD + key + val +} + +pub fn storage_bytes_for_pending_withdrawal(owner: &AccountId, receiver: &AccountId) -> u64 { + // Key is u64 id -> 8 bytes; value is Borsh of the struct members + let key = 8u64; + let val = storage_bytes_for_account_id(owner) + + storage_bytes_for_account_id(receiver) + + 16 // escrow_shares: u128 + + 16 // expected_assets: u128 + + 8 // requested_at: u64 + + 16; // deposit_yocto: u128 + MAP_ENTRY_OVERHEAD + key + val +} + +pub fn yocto_for_bytes(bytes: u64) -> u128 { + let price = env::storage_byte_cost().as_yoctonear(); + u128::from(bytes).saturating_mul(price) +} + +pub fn yocto_for_new_market(market: &AccountId) -> u128 { + yocto_for_bytes( + storage_bytes_for_config_entry(market) + .saturating_add(storage_bytes_for_market_supply_entry(market)), + ) +} + +pub fn yocto_for_pending_cap(market: &AccountId) -> u128 { + yocto_for_bytes(storage_bytes_for_pending_cap_entry(market)) +} + +pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { + new.iter().fold(0u128, |acc, id| { + if current.contains(id) { + acc + } else { + acc.saturating_add(yocto_for_bytes(storage_bytes_for_queue_item(id))) + } + }) +} + +pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { + let attached = env::attached_deposit().as_yoctonear(); + assert!( + attached >= required_yocto, + "Insufficient storage deposit for {ctx}: required {required_yocto}, attached {attached}" + ); + required_yocto +} + +pub fn require_attached_for_bytes(bytes: u64, ctx: &str) -> u128 { + let req = yocto_for_bytes(bytes); + require_attached_at_least(req, ctx) +} + +pub fn require_attached_for_pending_withdrawal(owner: &AccountId, receiver: &AccountId) -> u128 { + let bytes = storage_bytes_for_pending_withdrawal(owner, receiver); + require_attached_for_bytes(bytes, "withdrawal request") +} diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 40d458a5..e2f6c5ca 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -1,4 +1,5 @@ use crate::Contract; +use near_sdk::NearToken; pub use near_sdk::{ test_utils::{accounts, VMContextBuilder}, test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, @@ -54,11 +55,20 @@ pub fn new_test_contract(vault_id: &AccountId) -> Contract { } // Set the block timestamp and keep caller/predecessor consistent for tests pub fn set_block_ts(vault_id: &AccountId, signer: &AccountId, ts: u64) { + set_ctx(vault_id, signer, Some(ts), None); +} + +pub fn set_ctx(vault_id: &AccountId, signer: &AccountId, ts: Option, deposit: Option) { let mut ctx = VMContextBuilder::new(); ctx.current_account_id(vault_id.clone()); ctx.signer_account_id(signer.clone()); ctx.predecessor_account_id(signer.clone()); - ctx.block_timestamp(ts); + if let Some(ts) = ts { + ctx.block_timestamp(ts); + } + if let Some(amount) = deposit { + ctx.attached_deposit(NearToken::from_yoctonear(amount)); + } testing_env!(ctx.build()); } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 67de4346..03cd8e40 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,3 +1,8 @@ +use crate::storage_management; +use crate::storage_management::storage_bytes_for_pending_cap_entry; +use crate::storage_management::storage_bytes_for_queue_item; +use crate::storage_management::yocto_for_bytes; +use crate::storage_management::yocto_for_new_market; use crate::test_utils::*; use crate::Contract; use near_sdk::env; @@ -94,7 +99,6 @@ fn fee_accrues_only_on_growth_unit() { ); } - #[test] fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { let vault_id = accounts(0); @@ -372,8 +376,6 @@ fn compute_burn_shares_cases(escrow: u128, collected: u128, requested: u128, exp assert_eq!(c.compute_burn_shares(escrow, collected, requested), expect); } - - #[test] fn compute_effective_totals_fee_share_and_virtuals() { let vault_id = accounts(0); @@ -394,7 +396,6 @@ fn compute_effective_totals_fee_share_and_virtuals() { assert_eq!(nta, cur + va); } - #[test] fn compute_escrow_settlement_burns_min_and_refunds_rest() { let vault_id = accounts(0); @@ -406,10 +407,8 @@ fn compute_escrow_settlement_burns_min_and_refunds_rest() { assert_eq!(Contract::compute_escrow_settlement(0, 50), (0, 0)); } - #[test] fn removing_holding_market_hides_assets_and_leaves_orphan_supply() { - let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); let owner = c.own_get_owner().unwrap(); @@ -479,7 +478,6 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { let cfg_after2 = c.config.get(&m).expect("market must exist"); assert!(cfg_after2.removable_at > 0, "removal must be scheduled"); } - #[test] fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { let vault_id = accounts(0); @@ -495,9 +493,16 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { // Submit raise -> pending let raise = 5u128; + set_ctx(&vault_id, &owner, None, Some(yocto_for_new_market(&m))); c.submit_cap(m.clone(), U128(raise)); + // Fast-forward timelock to accept the raise - set_block_ts(&vault_id, &owner, env::block_timestamp() + 1_000_000_000); + set_ctx( + &vault_id, + &owner, + Some(env::block_timestamp() + 1_000_000_000), + Some(yocto_for_bytes(storage_bytes_for_queue_item(&m))), + ); c.accept_cap(m.clone()); let cfg1 = c.config.get(&m).unwrap(); diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 76e8a776..73d6a4e8 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -83,23 +83,22 @@ impl VaultController { #[call(exec, tgas(300))] pub fn allocate(weights: AllocationWeights, amount: Option); - #[call(exec, tgas(30))] + #[call(exec, tgas(30), deposit(NearToken::from_yoctonear(2020000000000000000000)))] pub fn withdraw(amount: U128, receiver: AccountId); #[call(exec, tgas(300))] pub fn execute_next_withdrawal_request(); - // User redemption path; expects escrowed shares already held by the contract. - #[call(exec, tgas(300))] + #[call(exec, tgas(300), deposit(NearToken::from_yoctonear(2020000000000000000000)))] pub fn redeem(shares: U128, receiver: AccountId); #[call(exec, tgas(50))] pub fn skim["skim"](token: AccountId); - #[call(exec, tgas(5))] + #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(3680000000000000000000)))] pub fn submit_cap(market: AccountId, new_cap: U128); - #[call(exec, tgas(5))] + #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(530000000000000000000)))] pub fn accept_cap(market: AccountId); #[call(exec, tgas(5))] @@ -144,7 +143,7 @@ impl VaultController { #[call(exec, tgas(50))] pub fn revoke_pending_timelock(); - #[call(exec, tgas(50))] + #[call(exec, tgas(50), deposit(NearToken::from_yoctonear(530000000000000000000)))] pub fn set_supply_queue(markets: Vec); #[call(exec, tgas(50))] From b3a5157d33747962ca149085356e1a26f6e684dd Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 14 Oct 2025 14:16:59 +0100 Subject: [PATCH 028/121] refactor: constant size for storage --- common/src/vault.rs | 6 +++ contract/vault/README.md | 38 ++++++++++++++ contract/vault/src/lib.rs | 29 ++++++++--- contract/vault/src/storage_management.rs | 65 ++++++++++++------------ contract/vault/src/tests.rs | 6 +-- test-utils/src/controller/vault.rs | 10 ++-- 6 files changed, 105 insertions(+), 49 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index c57fbb87..c11ca9e6 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -62,6 +62,12 @@ pub struct MarketConfiguration { pub removable_at: TimestampNs, } +impl MarketConfiguration { + pub const fn encoded_size() -> usize { + 32 + 1 + 8 + } +} + #[derive(Clone)] #[near(serializers = [json, borsh])] pub struct VaultConfiguration { diff --git a/contract/vault/README.md b/contract/vault/README.md index f1c22ef9..f52a0a0b 100644 --- a/contract/vault/README.md +++ b/contract/vault/README.md @@ -338,3 +338,41 @@ Stop behavior: - cargo test -p templar-vault - Tips: - When integrating a new market, first wire get_supply_position and dry-run the withdraw path to validate reconciliation. + +## Storage management + +This vault uses a per-entry storage charging model. Callers attach deposits only when their action may +create new storage entries. We size entries conservatively using AccountId::MAX_LEN and fixed field sizes, +to avoid relying on runtime storage usage “diffs”. + +What the contract pays for +- RBAC storage: role membership (Owner/RBAC lists) is paid by the contract. Callers are not charged +storage deposits for set_curator, set_is_allocator, or guardian role changes. + +Conservative sizing +- AccountId bytes are charged at MAX_LEN to keep pricing simple and deterministic. +- Map/queue overheads are charged with fixed constants. +- PendingWithdrawal size is a fixed upper bound of its fields. + +When a deposit is required +- submit_cap(market, new_cap) + - If market is new: config entry + market_supply entry. + - If raising cap above current: pending_cap entry. +- accept_cap(market) + - If enabling (cap > 0) and the market is not in withdraw_queue: 1 queue slot. +- set_supply_queue(markets) + - Storage for markets added that were not previously in the queue. +- set_withdraw_queue(queue) + - Storage for markets added that were not previously in the queue. +- allocate(weights, amount) + - Up-front deposit to cover potential withdraw_queue insertions for any candidate market in the +allocation run (supply_queue for queue mode; weighted plan markets for weighted mode). +- withdraw/redeem + - PendingWithdrawal queue entry per request (escrowed shares are held until payout/refund). + +Refund policy +- For simplicity and in line with many Ethereum contracts, we do not refund storage on removals (e.g., +queue removals, consumed pending withdrawals, deleted configs). This avoids complexity and edge cases +around attribution. + + diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 6a73bcd5..cec58d47 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -31,7 +31,8 @@ pub use wad::*; use crate::storage_management::{ require_attached_at_least, require_attached_for_pending_withdrawal, - storage_bytes_for_queue_item, yocto_for_bytes, yocto_for_new_market, yocto_for_pending_cap, + storage_bytes_for_account_id, storage_bytes_for_queue_account_id, yocto_for_bytes, + yocto_for_new_market, yocto_for_pending_cap, }; pub mod aux; @@ -81,12 +82,24 @@ pub struct PendingWithdrawal { pub requested_at: u64, } +impl PendingWithdrawal { + pub const fn encoded_size() -> u64 { + storage_bytes_for_account_id() + + storage_bytes_for_account_id() + + 16 // escrow_shares: u128 + + 16 // expected_assets: u128 + + 8 // requested_at: u64 + + 16 // deposit_yocto: u128 + } +} + #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] // FIXME: #[nep145(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] #[near(contract_state)] /// Vault contract that issues shares over an underlying fungible asset and allocates liquidity /// across configured markets. Implements 4626-like deposit/withdraw semantics. +/// Note: RBAC storage (role membership) is paid by the contract; callers are not charged deposits for RBAC changes. pub struct Contract { mode: AllocationMode, plan: Option, @@ -440,11 +453,11 @@ impl Contract { let mut required_deposit: u128 = 0; if self.config.get(&market).is_none() { - required_deposit = required_deposit.saturating_add(yocto_for_new_market(&market)); + required_deposit = required_deposit.saturating_add(yocto_for_new_market()); } let current_cap = self.config.get(&market).map_or(0, |c| c.cap); if new_cap.0 > current_cap { - required_deposit = required_deposit.saturating_add(yocto_for_pending_cap(&market)); + required_deposit = required_deposit.saturating_add(yocto_for_pending_cap()); } require_attached_at_least(required_deposit, "submit_cap"); @@ -527,7 +540,7 @@ impl Contract { .emit(); } else { require_attached_at_least( - yocto_for_bytes(storage_bytes_for_queue_item(&market)), + yocto_for_bytes(storage_bytes_for_queue_account_id()), "withdraw queue entry", ); self.withdraw_queue.push(market.clone()); @@ -752,7 +765,7 @@ impl Contract { let sender = env::predecessor_account_id(); // Require storage deposit for the pending withdrawal entry - let req_yocto = require_attached_for_pending_withdrawal(&sender, &receiver); + let req_yocto = require_attached_for_pending_withdrawal(); // Move shares into escrow #[allow(clippy::expect_used, reason = "No side effects")] @@ -767,7 +780,7 @@ impl Contract { } .emit(); - self.enqueue_pending_withdrawal(&sender, &receiver, shares, assets, req_yocto); + self.enqueue_pending_withdrawal(&sender, &receiver, shares, assets); PromiseOrValue::Value(()) } @@ -1033,7 +1046,6 @@ impl Contract { receiver: &AccountId, escrow_shares: u128, expected_assets: u128, - deposit_yocto: u128, ) { let id = self.next_withdraw_id; self.next_withdraw_id = self.next_withdraw_id.saturating_add(1); @@ -1354,6 +1366,7 @@ impl Contract { .then( ext_self::ext(env::current_account_id()) .with_static_gas(Self::AFTER_SUPPLY_ENSURE_GAS) + .with_unused_gas_weight(0) .after_supply_1_check(op_id, index, U128(to_supply)), ), ) @@ -1456,7 +1469,7 @@ impl Contract { PromiseOrValue::Promise( templar_common::market::ext_market::ext(market.clone()) // FIXME: incorrect - .with_static_gas(Gas::from_tgas(10)) + .with_static_gas(Self::CREATE_WITHDRAW_REQ_GAS) .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(*to_request))) .then( ext_self::ext(env::current_account_id()) diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index ae1c2024..1c339619 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -1,52 +1,49 @@ +use crate::PendingWithdrawal; use near_sdk::borsh::{self, BorshSerialize}; use near_sdk::{env, AccountId}; use std::collections::HashSet; use templar_common::vault::MarketConfiguration; +/// Set of hacks because near-sdk does not support borshschema and its overkill to implement +/// We do not implement refunds for storage management ops, to avoid any potential issues with +/// accounting. + // Conservative per-entry overheads to cover collection metadata, prefixes, etc. pub const MAP_ENTRY_OVERHEAD: u64 = 64; -pub const VEC_ITEM_OVERHEAD: u64 = 16; -// Borsh length of an AccountId (4-byte length + bytes) -pub fn storage_bytes_for_account_id(id: &AccountId) -> u64 { - 4 + (id.as_str().as_bytes().len() as u64) +// Worst case size encoded for AccountId +pub const fn storage_bytes_for_account_id() -> u64 { + AccountId::MAX_LEN as u64 } -pub fn storage_bytes_for_queue_item(id: &AccountId) -> u64 { - VEC_ITEM_OVERHEAD + storage_bytes_for_account_id(id) +pub const VEC_ITEM_OVERHEAD: u64 = 16; +pub fn storage_bytes_for_queue_account_id() -> u64 { + VEC_ITEM_OVERHEAD + storage_bytes_for_account_id() } -pub fn storage_bytes_for_config_entry(market: &AccountId) -> u64 { - let key = storage_bytes_for_account_id(market); - // Value size from default config serialization (upper-bound enough for our use) - let cfg = MarketConfiguration::default(); - let val = borsh::to_vec(&cfg).map(|v| v.len() as u64).unwrap_or(32); - MAP_ENTRY_OVERHEAD + key + val +pub fn storage_bytes_for_config_entry() -> u64 { + let key = storage_bytes_for_account_id(); + MAP_ENTRY_OVERHEAD + key + MarketConfiguration::encoded_size() as u64 } -pub fn storage_bytes_for_market_supply_entry(market: &AccountId) -> u64 { - let key = storage_bytes_for_account_id(market); +pub fn storage_bytes_for_market_supply_entry() -> u64 { + let key = storage_bytes_for_account_id(); // u128 principal let val = 16u64; MAP_ENTRY_OVERHEAD + key + val } -pub fn storage_bytes_for_pending_cap_entry(market: &AccountId) -> u64 { - let key = storage_bytes_for_account_id(market); +pub fn storage_bytes_for_pending_cap_entry() -> u64 { + let key = storage_bytes_for_account_id(); // PendingValue { value: u128, valid_at: u64 } let val = 16u64 + 8u64; MAP_ENTRY_OVERHEAD + key + val } -pub fn storage_bytes_for_pending_withdrawal(owner: &AccountId, receiver: &AccountId) -> u64 { - // Key is u64 id -> 8 bytes; value is Borsh of the struct members +pub fn storage_bytes_for_pending_withdrawal() -> u64 { + // Key is u64 id -> 8 bytes let key = 8u64; - let val = storage_bytes_for_account_id(owner) - + storage_bytes_for_account_id(receiver) - + 16 // escrow_shares: u128 - + 16 // expected_assets: u128 - + 8 // requested_at: u64 - + 16; // deposit_yocto: u128 + let val = PendingWithdrawal::encoded_size(); MAP_ENTRY_OVERHEAD + key + val } @@ -55,15 +52,14 @@ pub fn yocto_for_bytes(bytes: u64) -> u128 { u128::from(bytes).saturating_mul(price) } -pub fn yocto_for_new_market(market: &AccountId) -> u128 { +pub fn yocto_for_new_market() -> u128 { yocto_for_bytes( - storage_bytes_for_config_entry(market) - .saturating_add(storage_bytes_for_market_supply_entry(market)), + storage_bytes_for_config_entry().saturating_add(storage_bytes_for_market_supply_entry()), ) } -pub fn yocto_for_pending_cap(market: &AccountId) -> u128 { - yocto_for_bytes(storage_bytes_for_pending_cap_entry(market)) +pub fn yocto_for_pending_cap() -> u128 { + yocto_for_bytes(storage_bytes_for_pending_cap_entry()) } pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { @@ -71,7 +67,7 @@ pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId] if current.contains(id) { acc } else { - acc.saturating_add(yocto_for_bytes(storage_bytes_for_queue_item(id))) + acc.saturating_add(yocto_for_bytes(storage_bytes_for_queue_account_id())) } }) } @@ -80,7 +76,10 @@ pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { let attached = env::attached_deposit().as_yoctonear(); assert!( attached >= required_yocto, - "Insufficient storage deposit for {ctx}: required {required_yocto}, attached {attached}" + "Insufficient storage deposit for {}: required {}, attached {}", + ctx, + required_yocto, + attached ); required_yocto } @@ -90,7 +89,7 @@ pub fn require_attached_for_bytes(bytes: u64, ctx: &str) -> u128 { require_attached_at_least(req, ctx) } -pub fn require_attached_for_pending_withdrawal(owner: &AccountId, receiver: &AccountId) -> u128 { - let bytes = storage_bytes_for_pending_withdrawal(owner, receiver); +pub fn require_attached_for_pending_withdrawal() -> u128 { + let bytes = storage_bytes_for_pending_withdrawal(); require_attached_for_bytes(bytes, "withdrawal request") } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 03cd8e40..6de488f5 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,6 +1,6 @@ use crate::storage_management; use crate::storage_management::storage_bytes_for_pending_cap_entry; -use crate::storage_management::storage_bytes_for_queue_item; +use crate::storage_management::storage_bytes_for_queue_account_id; use crate::storage_management::yocto_for_bytes; use crate::storage_management::yocto_for_new_market; use crate::test_utils::*; @@ -493,7 +493,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { // Submit raise -> pending let raise = 5u128; - set_ctx(&vault_id, &owner, None, Some(yocto_for_new_market(&m))); + set_ctx(&vault_id, &owner, None, Some(yocto_for_new_market())); c.submit_cap(m.clone(), U128(raise)); // Fast-forward timelock to accept the raise @@ -501,7 +501,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { &vault_id, &owner, Some(env::block_timestamp() + 1_000_000_000), - Some(yocto_for_bytes(storage_bytes_for_queue_item(&m))), + Some(yocto_for_bytes(storage_bytes_for_queue_account_id())), ); c.accept_cap(m.clone()); diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 73d6a4e8..3cf7f448 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -83,22 +83,22 @@ impl VaultController { #[call(exec, tgas(300))] pub fn allocate(weights: AllocationWeights, amount: Option); - #[call(exec, tgas(30), deposit(NearToken::from_yoctonear(2020000000000000000000)))] + #[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(); - #[call(exec, tgas(300), deposit(NearToken::from_yoctonear(2020000000000000000000)))] + #[call(exec, tgas(300), deposit(NearToken::from_yoctonear(2560000000000000000000)))] pub fn redeem(shares: U128, receiver: AccountId); #[call(exec, tgas(50))] pub fn skim["skim"](token: AccountId); - #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(3680000000000000000000)))] + #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(4650000000000000000000)))] pub fn submit_cap(market: AccountId, new_cap: U128); - #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(530000000000000000000)))] + #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(800000000000000000000)))] pub fn accept_cap(market: AccountId); #[call(exec, tgas(5))] @@ -143,7 +143,7 @@ impl VaultController { #[call(exec, tgas(50))] pub fn revoke_pending_timelock(); - #[call(exec, tgas(50), deposit(NearToken::from_yoctonear(530000000000000000000)))] + #[call(exec, tgas(50), deposit(NearToken::from_yoctonear(800000000000000000000)))] pub fn set_supply_queue(markets: Vec); #[call(exec, tgas(50))] From e2b0a2c67b84fb56fb1678109a30cf534d9de569 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 10:16:49 +0100 Subject: [PATCH 029/121] fix: cap should be unlimited in controller --- contract/vault/examples/gas_report.rs | 155 ++++++++++++++++++++++++++ test-utils/src/controller/vault.rs | 16 ++- test-utils/src/lib.rs | 3 +- 3 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 contract/vault/examples/gas_report.rs diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs new file mode 100644 index 00000000..514e2f64 --- /dev/null +++ b/contract/vault/examples/gas_report.rs @@ -0,0 +1,155 @@ +#![allow(clippy::unwrap_used, clippy::wildcard_imports)] + +use near_sdk::Gas; +use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; + +const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(30); +const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(4); +const AFTER_SUPPLY_POSITION_CHECK_GAS: Gas = Gas::from_tgas(10); + +const CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); +const EXECUTE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); +const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(20); +const AFTER_EXEC_WITHDRAW_READ_GAS: Gas = Gas::from_tgas(10); + +const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(5); + +// Conservative per-tx budgets +const TARGET_GAS_CONSERVATIVE: Gas = Gas::from_tgas(285); +const TARGET_GAS_FULL: Gas = Gas::from_tgas(300); + +fn sum_gas(vals: &[Gas]) -> Gas { + Gas::from_gas( + vals.iter() + .fold(0u64, |acc, g| acc.saturating_add(g.as_gas())), + ) +} + +fn max_iters(budget: Gas, step: Gas) -> u64 { + if step.as_gas() == 0 { + return 0; + } + budget.as_gas() / step.as_gas() +} + +fn allocation_step_gas() -> Gas { + // 1 allocation step: + // - ft_transfer_call to market (supply) + // - callback after_supply_1_check + // - market.get_supply_position + // - callback after_supply_2_read + sum_gas(&[ + GAS_FOR_FT_TRANSFER_CALL, + AFTER_SUPPLY_ENSURE_GAS, + GET_SUPPLY_POSITION_GAS, + AFTER_SUPPLY_POSITION_CHECK_GAS, + ]) +} + +fn withdraw_market_step_gas() -> Gas { + // 1 withdraw "market step" (not including final payout to user): + // - create_supply_withdrawal_request + callback + // - execute_next_supply_withdrawal_request + callback + // - get_supply_position + callback + sum_gas(&[ + CREATE_WITHDRAW_REQ_GAS, + AFTER_CREATE_WITHDRAW_REQ_GAS, + EXECUTE_WITHDRAW_REQ_GAS, + AFTER_CREATE_WITHDRAW_REQ_GAS, // used for after_exec_withdraw_req + GET_SUPPLY_POSITION_GAS, + AFTER_EXEC_WITHDRAW_READ_GAS, + ]) +} + +fn payout_gas() -> Gas { + // ft_transfer to user + callback + sum_gas(&[GAS_FOR_FT_TRANSFER_CALL, AFTER_SEND_TO_USER_GAS]) +} + +fn print_header() { + println!("## Vault Gas Report"); + println!(); + println!("This report is static and based on the unified gas constants in the vault."); + println!("It estimates:"); + println!("- Per-allocation-step gas and max steps within 285/300 Tgas."); + println!("- Per-withdraw-market-step gas (excluding final payout) and payout cost."); + println!(); +} + +fn print_constants() { + println!("### Constants"); + println!(); + println!("| Label | Gas |"); + println!("| ----: | --: |"); + println!("| GAS_FOR_FT_TRANSFER_CALL | {GAS_FOR_FT_TRANSFER_CALL} |"); + println!("| AFTER_SUPPLY_ENSURE_GAS | {AFTER_SUPPLY_ENSURE_GAS} |"); + println!("| GET_SUPPLY_POSITION_GAS | {GET_SUPPLY_POSITION_GAS} |"); + println!("| AFTER_SUPPLY_POSITION_CHECK_GAS | {AFTER_SUPPLY_POSITION_CHECK_GAS} |"); + println!("| CREATE_WITHDRAW_REQ_GAS | {CREATE_WITHDRAW_REQ_GAS} |"); + println!("| EXECUTE_WITHDRAW_REQ_GAS | {EXECUTE_WITHDRAW_REQ_GAS} |"); + println!("| AFTER_CREATE_WITHDRAW_REQ_GAS | {AFTER_CREATE_WITHDRAW_REQ_GAS} |"); + println!("| AFTER_EXEC_WITHDRAW_READ_GAS | {AFTER_EXEC_WITHDRAW_READ_GAS} |"); + println!("| AFTER_SEND_TO_USER_GAS | {AFTER_SEND_TO_USER_GAS} |"); + println!(); +} + +fn print_allocation_report() { + println!("### Allocation pipeline"); + println!(); + + let per_step = allocation_step_gas(); + let steps_285 = max_iters(TARGET_GAS_CONSERVATIVE, per_step); + let steps_300 = max_iters(TARGET_GAS_FULL, per_step); + + println!("Per allocation step (approx): {per_step}"); + println!("Max steps within 285 Tgas (conservative): {steps_285}"); + println!("Max steps within 300 Tgas (full): {steps_300}"); + println!(); +} + +fn print_withdraw_report() { + println!("### Withdraw pipeline"); + println!(); + + let per_market_step = withdraw_market_step_gas(); + let payout = payout_gas(); + + let steps_285 = max_iters(TARGET_GAS_CONSERVATIVE, per_market_step); + let steps_300 = max_iters(TARGET_GAS_FULL, per_market_step); + + println!("Per withdraw market-step (without final payout): {per_market_step}"); + println!("Payout (ft_transfer + callback): {}", payout); + println!("Max market-steps within 285 Tgas (excl. payout): {steps_285}"); + println!("Max market-steps within 300 Tgas (excl. payout): {steps_300}"); + println!(); +} + +fn print_summary() { + let alloc_step = allocation_step_gas(); + let w_step = withdraw_market_step_gas(); + let payout = payout_gas(); + + println!("### Summary"); + println!(); + println!("| Item | Gas |"); + println!("| ---: | --: |"); + println!("| allocation_step | {alloc_step} |"); + println!("| withdraw_market_step (no payout) | {w_step} |"); + println!("| payout | {payout} |"); + println!("| budget_conservative | {TARGET_GAS_CONSERVATIVE} |"); + println!("| budget_full | {TARGET_GAS_FULL} |"); + println!(); +} + +fn main() { + print_header(); + print_constants(); + print_allocation_report(); + print_withdraw_report(); + print_summary(); + + println!("Note:"); + println!("- These are static estimates derived from constant budgets."); + println!("- Actual on-chain gas may differ slightly due to runtime overhead and receipts."); + println!("- Use this report to choose safe per-tx iteration counts for allocation/withdraw orchestration."); +} diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 3cf7f448..af25c6ef 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -11,7 +11,7 @@ use near_sdk::{ use near_workspaces::{ network::Sandbox, result::ExecutionSuccess, types::SecretKey, Account, Contract, Worker, }; -use std::ops::Deref; +use std::{env, ops::Deref}; use templar_common::vault::*; use tokio::sync::OnceCell; @@ -206,7 +206,7 @@ impl UnifiedVaultController { vault, configuration, market, - debug: true, + debug: is_debug(), } } @@ -219,7 +219,7 @@ impl UnifiedVaultController { vault, configuration, market, - debug: true, + debug: is_debug(), } } @@ -272,7 +272,7 @@ impl UnifiedVaultController { allocator: &Account, weights: AllocationWeights, amount: Option, - ) { + ) -> ExecutionSuccess { let e = self .vault .allocate(allocator, weights, amount.unwrap_or(1000.into())) @@ -280,6 +280,7 @@ impl UnifiedVaultController { if self.debug { print_execution(&e); } + e } pub async fn withdraw(&self, withdrawer: &Account, amount: U128, receiver: Option) { @@ -331,3 +332,10 @@ impl UnifiedVaultController { } } } + +fn is_debug() -> bool { + env::var("RUST_LOG") + .map(|s| s.contains("debug")) + .unwrap_or_default() + || env::var("DEBUG").is_ok() +} diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index e1e86b8d..1e782c39 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -324,7 +324,8 @@ pub async fn setup_everything( v.storage_deposits(&fee_recipient), ); - v.setup_caps(&vault_owner, &[mkt.id().clone()], 1000).await; + v.setup_caps(&vault_owner, &[mkt.id().clone()], u128::MAX) + .await; SetupEverything { c, From d8191f48bd52b323b9d4990c9e7c830f693b7d29 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 12:22:03 +0100 Subject: [PATCH 030/121] feat: gas reports --- common/src/vault.rs | 100 ++++++++- contract/vault/Cargo.toml | 2 + contract/vault/examples/gas_report.rs | 246 +++++++++------------- contract/vault/examples/receipt_gas.rs | 50 ----- contract/vault/src/impl_callbacks.rs | 47 ++--- contract/vault/src/impl_token_receiver.rs | 12 +- contract/vault/src/lib.rs | 48 ++--- contract/vault/src/storage_management.rs | 7 +- test-utils/src/controller/vault.rs | 36 +++- test-utils/src/lib.rs | 2 +- 10 files changed, 277 insertions(+), 273 deletions(-) delete mode 100644 contract/vault/examples/receipt_gas.rs diff --git a/common/src/vault.rs b/common/src/vault.rs index c11ca9e6..81868683 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -1,4 +1,4 @@ -use near_sdk::{json_types::U128, near, AccountId}; +use near_sdk::{json_types::U128, near, AccountId, Gas, Promise, PromiseOrValue}; use crate::asset::{BorrowAsset, FungibleAsset}; @@ -43,6 +43,7 @@ pub enum AllocationMode { #[default] Lazy, } + /// Parsed from the string parameter `msg` passed by `*_transfer_call` to /// `*_on_transfer` calls. #[near(serializers = [json])] @@ -85,6 +86,74 @@ pub struct VaultConfiguration { pub decimals: u8, } +#[near_sdk::ext_contract(ext_vault)] +pub trait VaultExt { + // Role and admin + fn set_curator(account: AccountId); + fn set_is_allocator(account: AccountId, allowed: bool); + fn submit_guardian(new_g: AccountId); + fn accept_guardian(); + fn revoke_pending_guardian(); + fn set_skim_recipient(account: AccountId); + fn set_fee_recipient(account: AccountId); + fn set_performance_fee(fee: U128); + fn submit_timelock(new_timelock_secs: u32); + fn accept_timelock(); + fn revoke_pending_timelock(); + + // Market config and queues + fn submit_cap(market: AccountId, new_cap: U128); + fn accept_cap(market: AccountId); + fn revoke_pending_cap(market: AccountId); + fn submit_market_removal(market: AccountId); + fn revoke_pending_market_removal(market: AccountId); + fn set_supply_queue(markets: Vec); + fn set_withdraw_queue(queue: Vec); + + // User flows + fn withdraw(amount: U128, receiver: AccountId) -> PromiseOrValue<()>; + fn redeem(shares: U128, receiver: AccountId) -> PromiseOrValue<()>; + fn execute_next_withdrawal_request() -> PromiseOrValue<()>; + fn skim(token: AccountId) -> Promise; + fn allocate(weights: AllocationWeights, amount: Option) -> PromiseOrValue<()>; + + // Views + fn get_configuration() -> VaultConfiguration; + fn get_total_assets() -> U128; + fn get_total_supply() -> U128; + fn get_max_deposit() -> U128; + fn convert_to_shares(assets: U128) -> U128; + fn convert_to_assets(shares: U128) -> U128; + fn preview_deposit(assets: U128) -> U128; + fn preview_mint(shares: U128) -> U128; + fn preview_withdraw(assets: U128) -> U128; + fn preview_redeem(shares: U128) -> U128; +} + +// FIXME: move to market +pub const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(4); +// FIXME: move to market +pub const CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); +// FIXME: move to market +pub const EXECUTE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); + +pub const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(30); +pub const AFTER_SUPPLY_POSITION_CHECK_GAS: Gas = Gas::from_tgas(10); +pub const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(20); +pub const AFTER_EXEC_WITHDRAW_READ_GAS: Gas = Gas::from_tgas(10); +pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(5); + +// Add a 20% buffer to a gas estimate +pub const fn buffer(size: usize) -> Gas { + Gas::from_tgas(size as u64 * 2 / 5) +} + +// NOTE: these are taken after running the contract with the gas report +pub const SUPPLY_GAS: Gas = buffer(8); +pub const ALLOCATE_GAS: Gas = buffer(21); +pub const WITHDRAW_GAS: Gas = buffer(4); +pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); + #[near_sdk::ext_contract(ext_self)] pub trait Callbacks { fn after_supply_1_check(&mut self, op_id: u64, market_index: u32, attempted: U128) -> bool; @@ -96,13 +165,10 @@ pub trait Callbacks { attempted: U128, accepted: U128, ) -> bool; - fn after_create_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; fn after_exec_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; fn after_exec_withdraw_read(&mut self, op_id: u64, market_index: u32, before: U128, need: U128); - fn after_send_to_user(&mut self, op_id: u64, receiver: AccountId, amount: U128) -> bool; - fn after_skim_balance(&mut self, token: AccountId, recipient: AccountId) -> bool; } @@ -166,6 +232,32 @@ impl std::fmt::Display for Error { } } +#[derive(Clone, Debug)] +#[near(serializers = [json, borsh])] +pub struct PendingWithdrawal { + pub owner: AccountId, + pub receiver: AccountId, + pub escrow_shares: u128, + pub expected_assets: u128, + pub requested_at: u64, +} + +impl PendingWithdrawal { + pub const fn encoded_size() -> u64 { + storage_bytes_for_account_id() + + storage_bytes_for_account_id() + + 16 // escrow_shares: u128 + + 16 // expected_assets: u128 + + 8 // requested_at: u64 + + 16 // deposit_yocto: u128 + } +} + +// Worst case size encoded for AccountId +pub const fn storage_bytes_for_account_id() -> u64 { + AccountId::MAX_LEN as u64 +} + #[near(event_json(standard = "templar-vault"))] pub enum Event { #[event_version("1.0.0")] diff --git a/contract/vault/Cargo.toml b/contract/vault/Cargo.toml index c3b377df..7c4bbf62 100644 --- a/contract/vault/Cargo.toml +++ b/contract/vault/Cargo.toml @@ -42,6 +42,8 @@ rstest.workspace = true test-utils.workspace = true tokio.workspace = true templar-relayer = { path = "../../service/relayer" } +rand = "0.8" +futures.workspace = true [lints] workspace = true diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index 514e2f64..f7086dad 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -1,155 +1,107 @@ -#![allow(clippy::unwrap_used, clippy::wildcard_imports)] - -use near_sdk::Gas; -use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; - -const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(30); -const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(4); -const AFTER_SUPPLY_POSITION_CHECK_GAS: Gas = Gas::from_tgas(10); - -const CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); -const EXECUTE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); -const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(20); -const AFTER_EXEC_WITHDRAW_READ_GAS: Gas = Gas::from_tgas(10); - -const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(5); - -// Conservative per-tx budgets -const TARGET_GAS_CONSERVATIVE: Gas = Gas::from_tgas(285); -const TARGET_GAS_FULL: Gas = Gas::from_tgas(300); - -fn sum_gas(vals: &[Gas]) -> Gas { - Gas::from_gas( - vals.iter() - .fold(0u64, |acc, g| acc.saturating_add(g.as_gas())), - ) -} - -fn max_iters(budget: Gas, step: Gas) -> u64 { - if step.as_gas() == 0 { - return 0; +#![allow(clippy::wildcard_imports)] + +use near_sdk::{json_types::U128, Gas}; +use rand::Rng as _; +use test_utils::*; + +#[tokio::main] +async fn main() { + const ITERATIONS: usize = 128; + + setup_test!( + extract(vault, c, vault_curator) + accounts(user1, user2, user3) + ); + + vault.init_account(&user1).await; + vault.init_account(&user2).await; + vault.init_account(&user3).await; + + let max = c.borrow_asset.balance_of(user1.id()).await; + let g = || rand::thread_rng().gen_range(0..=max); + + let weights = vec![(c.market.contract().id().clone(), U128(1))]; + let user1_amount = max / ITERATIONS as u128; + + let futures = (0..ITERATIONS).map(|_| async { + let supply_gas = vault + .supply(&user1, user1_amount) + .await + .total_gas_burnt + .as_gas() as f64; + + let allocation_gas = vault + .allocate(&vault_curator, weights.clone(), Some(U128(user1_amount))) + .await + .total_gas_burnt + .as_gas() as f64; + + (supply_gas, allocation_gas) + }); + let results = futures::future::join_all(futures) + .await + .expect("All futures failed") + .into_iter(); + + let mut supply_gas_average = 0f64; + let mut allocation_gas_average = 0f64; + // Aggregate and compute averages. + for (s, a) in results { + supply_gas_average += s / ITERATIONS as f64; + allocation_gas_average += a / ITERATIONS as f64; } - budget.as_gas() / step.as_gas() -} - -fn allocation_step_gas() -> Gas { - // 1 allocation step: - // - ft_transfer_call to market (supply) - // - callback after_supply_1_check - // - market.get_supply_position - // - callback after_supply_2_read - sum_gas(&[ - GAS_FOR_FT_TRANSFER_CALL, - AFTER_SUPPLY_ENSURE_GAS, - GET_SUPPLY_POSITION_GAS, - AFTER_SUPPLY_POSITION_CHECK_GAS, - ]) -} - -fn withdraw_market_step_gas() -> Gas { - // 1 withdraw "market step" (not including final payout to user): - // - create_supply_withdrawal_request + callback - // - execute_next_supply_withdrawal_request + callback - // - get_supply_position + callback - sum_gas(&[ - CREATE_WITHDRAW_REQ_GAS, - AFTER_CREATE_WITHDRAW_REQ_GAS, - EXECUTE_WITHDRAW_REQ_GAS, - AFTER_CREATE_WITHDRAW_REQ_GAS, // used for after_exec_withdraw_req - GET_SUPPLY_POSITION_GAS, - AFTER_EXEC_WITHDRAW_READ_GAS, - ]) -} - -fn payout_gas() -> Gas { - // ft_transfer to user + callback - sum_gas(&[GAS_FOR_FT_TRANSFER_CALL, AFTER_SEND_TO_USER_GAS]) -} -fn print_header() { - println!("## Vault Gas Report"); - println!(); - println!("This report is static and based on the unified gas constants in the vault."); - println!("It estimates:"); - println!("- Per-allocation-step gas and max steps within 285/300 Tgas."); - println!("- Per-withdraw-market-step gas (excluding final payout) and payout cost."); - println!(); -} - -fn print_constants() { - println!("### Constants"); - println!(); - println!("| Label | Gas |"); - println!("| ----: | --: |"); - println!("| GAS_FOR_FT_TRANSFER_CALL | {GAS_FOR_FT_TRANSFER_CALL} |"); - println!("| AFTER_SUPPLY_ENSURE_GAS | {AFTER_SUPPLY_ENSURE_GAS} |"); - println!("| GET_SUPPLY_POSITION_GAS | {GET_SUPPLY_POSITION_GAS} |"); - println!("| AFTER_SUPPLY_POSITION_CHECK_GAS | {AFTER_SUPPLY_POSITION_CHECK_GAS} |"); - println!("| CREATE_WITHDRAW_REQ_GAS | {CREATE_WITHDRAW_REQ_GAS} |"); - println!("| EXECUTE_WITHDRAW_REQ_GAS | {EXECUTE_WITHDRAW_REQ_GAS} |"); - println!("| AFTER_CREATE_WITHDRAW_REQ_GAS | {AFTER_CREATE_WITHDRAW_REQ_GAS} |"); - println!("| AFTER_EXEC_WITHDRAW_READ_GAS | {AFTER_EXEC_WITHDRAW_READ_GAS} |"); - println!("| AFTER_SEND_TO_USER_GAS | {AFTER_SEND_TO_USER_GAS} |"); - println!(); -} + // Supply to vault + let user2_amount = g(); + let user3_amount = g(); + tokio::join!( + vault.supply(&user2, user2_amount), + vault.supply(&user3, user3_amount) + ); + + // Create all futures first so they can be awaited concurrently. + let futures = (0..ITERATIONS).map(|_| async { + let withdraw_gas = vault + .withdraw(&user2, U128(1), None) + .await + .total_gas_burnt + .as_gas() as f64; + let execute_gas = vault + .execute_next_withdrawal(&vault_curator) + .await + .total_gas_burnt + .as_gas() as f64; + (withdraw_gas, execute_gas) + }); + + let results = futures::future::join_all(futures).await; + + let mut withdraw_gas_average = 0f64; + let mut execute_withdraw_gas_average = 0f64; + for (w, e) in results { + withdraw_gas_average += w / ITERATIONS as f64; + execute_withdraw_gas_average += e / ITERATIONS as f64; + } -fn print_allocation_report() { - println!("### Allocation pipeline"); + println!("## Gas Report"); println!(); - - let per_step = allocation_step_gas(); - let steps_285 = max_iters(TARGET_GAS_CONSERVATIVE, per_step); - let steps_300 = max_iters(TARGET_GAS_FULL, per_step); - - println!("Per allocation step (approx): {per_step}"); - println!("Max steps within 285 Tgas (conservative): {steps_285}"); - println!("Max steps within 300 Tgas (full): {steps_300}"); + println!("Estimated allocation limit: 0"); println!(); -} - -fn print_withdraw_report() { - println!("### Withdraw pipeline"); + println!("### Action Gas Descriptors"); println!(); - - let per_market_step = withdraw_market_step_gas(); - let payout = payout_gas(); - - let steps_285 = max_iters(TARGET_GAS_CONSERVATIVE, per_market_step); - let steps_300 = max_iters(TARGET_GAS_FULL, per_market_step); - - println!("Per withdraw market-step (without final payout): {per_market_step}"); - println!("Payout (ft_transfer + callback): {}", payout); - println!("Max market-steps within 285 Tgas (excl. payout): {steps_285}"); - println!("Max market-steps within 300 Tgas (excl. payout): {steps_300}"); - println!(); -} - -fn print_summary() { - let alloc_step = allocation_step_gas(); - let w_step = withdraw_market_step_gas(); - let payout = payout_gas(); - - println!("### Summary"); - println!(); - println!("| Item | Gas |"); - println!("| ---: | --: |"); - println!("| allocation_step | {alloc_step} |"); - println!("| withdraw_market_step (no payout) | {w_step} |"); - println!("| payout | {payout} |"); - println!("| budget_conservative | {TARGET_GAS_CONSERVATIVE} |"); - println!("| budget_full | {TARGET_GAS_FULL} |"); + println!("| Action | Gas |"); + println!("| -----: | ---: |"); + let list = vec![ + ("supply", Gas::from_gas(supply_gas_average as u64)), + ("allocate", Gas::from_gas(allocation_gas_average as u64)), + ("withdraw", Gas::from_gas(withdraw_gas_average as u64)), + ( + "execute withdraw", + Gas::from_gas(execute_withdraw_gas_average as u64), + ), + ]; + for (action_label, gas) in list { + println!("| `{action_label}` | {gas} |"); + } println!(); } - -fn main() { - print_header(); - print_constants(); - print_allocation_report(); - print_withdraw_report(); - print_summary(); - - println!("Note:"); - println!("- These are static estimates derived from constant budgets."); - println!("- Actual on-chain gas may differ slightly due to runtime overhead and receipts."); - println!("- Use this report to choose safe per-tx iteration counts for allocation/withdraw orchestration."); -} diff --git a/contract/vault/examples/receipt_gas.rs b/contract/vault/examples/receipt_gas.rs deleted file mode 100644 index fe677a60..00000000 --- a/contract/vault/examples/receipt_gas.rs +++ /dev/null @@ -1,50 +0,0 @@ -#![allow(clippy::wildcard_imports)] - -use templar_common::fee::Fee; -use test_utils::*; - -#[tokio::main] -async fn main() { - setup_test!( - extract(c, insurance_yield_user) - accounts(borrow_user, supply_user, liquidator_user) - config(|c| { - c.borrow_origination_fee = Fee::zero(); - }) - ); - - tokio::join!( - c.supply_and_harvest_until_activation(&supply_user, 20_000), - c.collateralize(&borrow_user, 13_000), - ); - - c.borrow(&borrow_user, 10_000).await; - - // c.repay(&borrow_user, 10_000).await; - - // c.set_collateral_asset_price(0.85).await; - c.liquidate( - &liquidator_user, - borrow_user.id(), - 13_000.into(), - 11_000.into(), - ) - .await; - - // c.liquidate(&liquidator_user, borrow_user.id(), 11_000) - // .await; - - // let r = c - // .withdraw_static_yield(&insurance_yield_user, None, None) - // .await; - - c.create_supply_withdrawal_request(&supply_user, 1_000) - .await; - let r = c.execute_next_supply_withdrawal_request(&supply_user).await; - - for receipt in r.receipt_outcomes() { - eprintln!("{}: {}", receipt.executor_id, receipt.gas_burnt); - } - - eprintln!("Total gas: {}", r.total_gas_burnt); -} diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 0c27f710..2d189643 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -7,12 +7,18 @@ use near_sdk::{ PromiseOrValue, }; use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; -use templar_common::{market::ext_market, supply::SupplyPosition, vault::Event}; +use templar_common::{ + market::ext_market, + supply::SupplyPosition, + vault::{ + Callbacks, Event, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_EXEC_WITHDRAW_READ_GAS, + AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_POSITION_CHECK_GAS, EXECUTE_WITHDRAW_REQ_GAS, + GET_SUPPLY_POSITION_GAS, + }, +}; #[near] impl Contract { - pub const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(30); - #[private] pub fn after_supply_1_check( &mut self, @@ -24,9 +30,8 @@ impl Contract { attempted: U128, ) -> PromiseOrValue<()> { // Invariant: Index drift or stale op_id results in a graceful stop - let _ = match self.ctx_allocating(op_id) { - Ok(_) => (), - Err(e) => return self.stop_and_exit(Some(&e)), + let _ = if let Err(e) = self.ctx_allocating(op_id) { + return self.stop_and_exit(Some(&e)); }; // Resolve market by plan or supply_queue @@ -51,12 +56,12 @@ impl Contract { PromiseOrValue::Promise( ext_market::ext(market.clone()) - .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) + .with_static_gas(GET_SUPPLY_POSITION_GAS) .with_unused_gas_weight(0) .get_supply_position(env::current_account_id()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SUPPLY_POSITION_CHECK_GAS) + .with_static_gas(AFTER_SUPPLY_POSITION_CHECK_GAS) .after_supply_2_read( op_id, market_index, @@ -68,10 +73,6 @@ impl Contract { ) } - pub const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(4); - pub const AFTER_SUPPLY_POSITION_CHECK_GAS: Gas = Gas::from_tgas(10); - - // FIXME: no panics in this function! This will cause to spin if the op changes #[private] pub fn after_supply_2_read( &mut self, @@ -163,8 +164,6 @@ impl Contract { self.step_allocation() } - pub const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(20); - #[private] pub fn after_create_withdraw_req( &mut self, @@ -191,13 +190,13 @@ impl Contract { if let Ok(()) = did_create { PromiseOrValue::Promise( ext_market::ext(market.clone()) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .with_static_gas(EXECUTE_WITHDRAW_REQ_GAS) // TODO: we can only do this if there is sufficient liquidity in the market, we // should check that there is first, but even so, we can be rugged .execute_next_supply_withdrawal_request() .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_CREATE_WITHDRAW_REQ_GAS) + .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) .after_exec_withdraw_req(op_id, market_index, need), ), ) @@ -242,11 +241,12 @@ impl Contract { let before = *self.market_supply.get(&market).unwrap_or(&0); PromiseOrValue::Promise( ext_market::ext(market.clone()) - .with_static_gas(Self::GET_SUPPLY_POSITION_GAS) + .with_static_gas(GET_SUPPLY_POSITION_GAS) + .with_unused_gas_weight(0) .get_supply_position(env::current_account_id()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_CREATE_WITHDRAW_REQ_GAS) + .with_static_gas(AFTER_EXEC_WITHDRAW_READ_GAS) .after_exec_withdraw_read(op_id, market_index, U128(before), need), ), ) @@ -283,6 +283,7 @@ impl Contract { } Ok(None) => { // No position => treat as principal = 0 + // NOTE: this is a successful withdraw Event::WithdrawalPositionMissing { op_id, market: market.clone(), @@ -331,7 +332,7 @@ impl Contract { .transfer(recv.clone(), U128(collected).into()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) + .with_static_gas(AFTER_SEND_TO_USER_GAS) .after_send_to_user(op_id, recv, U128(collected)), ), ) @@ -357,8 +358,6 @@ impl Contract { } } - pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(5); - #[private] pub fn after_send_to_user( &mut self, @@ -446,7 +445,9 @@ impl Contract { ) } } +} +impl Contract { fn stop_and_exit_allocating( &mut self, msg: Option<&T>, @@ -581,9 +582,6 @@ impl Contract { } PromiseOrValue::Value(()) } -} - -impl Contract { // Validate current op is Allocating and return (index, remaining) pub(crate) fn ctx_allocating(&self, op_id: u64) -> Result<(u32, u128), Error> { match &self.op_state { @@ -1224,5 +1222,4 @@ mod tests { Err(Error::MissingMarket(2)) )); } - } diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index b43462a7..3c5a832b 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -1,7 +1,7 @@ use crate::{aux::ReturnStyle, Contract, ContractExt, OpState}; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; -use templar_common::vault::{AllocationMode, DepositMsg, Event}; +use templar_common::vault::{AllocationMode, DepositMsg, Event, SUPPLY_GAS}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; @@ -26,6 +26,11 @@ impl FungibleTokenReceiver for Contract { match msg { DepositMsg::Supply => { + require!( + env::prepaid_gas() > SUPPLY_GAS, + format!("Not enough gas, required {SUPPLY_GAS}") + ); + let refund = self.execute_supply(sender_id, asset_id, amount.into()); PromiseOrValue::Value(refund.into()) } @@ -66,6 +71,11 @@ impl Nep245Receiver for Contract { match msg { DepositMsg::Supply => { + require!( + env::prepaid_gas() > SUPPLY_GAS, + format!("Not enough gas, required {SUPPLY_GAS}") + ); + let mt = env::predecessor_account_id(); if !self.underlying_asset.is_nep245(&mt, token_id) { diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index cec58d47..156e88c9 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -6,8 +6,7 @@ use near_sdk::{ json_types::U128, near, serde_json, store::{IterableMap, LookupMap, Vector}, - AccountId, BorshStorageKey, Gas, IntoStorageKey, NearToken, PanicOnDefault, Promise, - PromiseOrValue, + AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, }; use near_sdk_contract_tools::{ ft::{ @@ -23,16 +22,18 @@ use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ ext_self, AllocationMode, AllocationPlan, AllocationWeights, Error, Event, - MarketConfiguration, OpState, PendingValue, TimestampNs, VaultConfiguration, MAX_QUEUE_LEN, - MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, + MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, + VaultConfiguration, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, + AFTER_SUPPLY_ENSURE_GAS, CREATE_WITHDRAW_REQ_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, + MIN_TIMELOCK_NS, }, }; pub use wad::*; use crate::storage_management::{ require_attached_at_least, require_attached_for_pending_withdrawal, - storage_bytes_for_account_id, storage_bytes_for_queue_account_id, yocto_for_bytes, - yocto_for_new_market, yocto_for_pending_cap, + storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, + yocto_for_pending_cap, }; pub mod aux; @@ -72,27 +73,6 @@ pub enum Role { Allocator, } -#[derive(Clone, Debug)] -#[near(serializers = [json, borsh])] -pub struct PendingWithdrawal { - pub owner: AccountId, - pub receiver: AccountId, - pub escrow_shares: u128, - pub expected_assets: u128, - pub requested_at: u64, -} - -impl PendingWithdrawal { - pub const fn encoded_size() -> u64 { - storage_bytes_for_account_id() - + storage_bytes_for_account_id() - + 16 // escrow_shares: u128 - + 16 // expected_assets: u128 - + 8 // requested_at: u64 - + 16 // deposit_yocto: u128 - } -} - #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] // FIXME: #[nep145(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] @@ -652,6 +632,7 @@ impl Contract { require_attached_at_least(required_yocto, "supply queue update"); self.supply_queue.clear(); + for m in &markets { self.supply_queue.push(m.clone()); } @@ -1305,7 +1286,7 @@ impl Contract { ) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SUPPLY_ENSURE_GAS) + .with_static_gas(AFTER_SUPPLY_ENSURE_GAS) .with_unused_gas_weight(0) .after_supply_1_check(op_id, index, U128(to_supply)), ), @@ -1365,7 +1346,7 @@ impl Contract { ) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SUPPLY_ENSURE_GAS) + .with_static_gas(AFTER_SUPPLY_ENSURE_GAS) .with_unused_gas_weight(0) .after_supply_1_check(op_id, index, U128(to_supply)), ), @@ -1443,7 +1424,7 @@ impl Contract { .transfer(receiver.clone(), U128(collected).into()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) + .with_static_gas(AFTER_SEND_TO_USER_GAS) .after_send_to_user(op_id, receiver, U128(collected)), ), ); @@ -1469,11 +1450,11 @@ impl Contract { PromiseOrValue::Promise( templar_common::market::ext_market::ext(market.clone()) // FIXME: incorrect - .with_static_gas(Self::CREATE_WITHDRAW_REQ_GAS) + .with_static_gas(CREATE_WITHDRAW_REQ_GAS) .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(*to_request))) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_CREATE_WITHDRAW_REQ_GAS) + .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) .after_create_withdraw_req(op_id, index, U128(*to_request)), ), ) @@ -1510,7 +1491,7 @@ impl Contract { .transfer(receiver.clone(), U128(collected).into()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(Self::AFTER_SEND_TO_USER_GAS) + .with_static_gas(AFTER_SEND_TO_USER_GAS) .after_send_to_user(op_id, receiver, U128(collected)), ), ) @@ -1519,5 +1500,6 @@ impl Contract { } } } + #[cfg(test)] mod tests; diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 1c339619..4ddf5328 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -2,7 +2,7 @@ use crate::PendingWithdrawal; use near_sdk::borsh::{self, BorshSerialize}; use near_sdk::{env, AccountId}; use std::collections::HashSet; -use templar_common::vault::MarketConfiguration; +use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; /// Set of hacks because near-sdk does not support borshschema and its overkill to implement /// We do not implement refunds for storage management ops, to avoid any potential issues with @@ -11,11 +11,6 @@ use templar_common::vault::MarketConfiguration; // Conservative per-entry overheads to cover collection metadata, prefixes, etc. pub const MAP_ENTRY_OVERHEAD: u64 = 64; -// Worst case size encoded for AccountId -pub const fn storage_bytes_for_account_id() -> u64 { - AccountId::MAX_LEN as u64 -} - pub const VEC_ITEM_OVERHEAD: u64 = 16; pub fn storage_bytes_for_queue_account_id() -> u64 { VEC_ITEM_OVERHEAD + storage_bytes_for_account_id() diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index af25c6ef..1b7aad0e 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -283,7 +283,12 @@ impl UnifiedVaultController { e } - pub async fn withdraw(&self, withdrawer: &Account, amount: U128, receiver: Option) { + pub async fn withdraw( + &self, + withdrawer: &Account, + amount: U128, + receiver: Option, + ) -> ExecutionSuccess { let e = self .vault .withdraw( @@ -295,41 +300,60 @@ impl UnifiedVaultController { if self.debug { print_execution(&e); } + e } - pub async fn execute_next_withdrawal(&self, allocator: &Account) { + pub async fn execute_next_withdrawal(&self, allocator: &Account) -> ExecutionSuccess { let e = self.vault.execute_next_withdrawal_request(allocator).await; if self.debug { print_execution(&e); } + e } - pub async fn submit_cap(&self, submitter: &Account, market: AccountId, amount: U128) { + pub async fn submit_cap( + &self, + submitter: &Account, + market: AccountId, + amount: U128, + ) -> ExecutionSuccess { let e = self.vault.submit_cap(submitter, market, amount).await; if self.debug { print_execution(&e); } + e } - pub async fn accept_cap(&self, acceptor: &Account, market: AccountId) { + pub async fn accept_cap(&self, acceptor: &Account, market: AccountId) -> ExecutionSuccess { let e = self.vault.accept_cap(acceptor, market).await; if self.debug { print_execution(&e); } + e } - pub async fn set_supply_queue(&self, allocator: &Account, markets: &[AccountId]) { + pub async fn set_supply_queue( + &self, + allocator: &Account, + markets: &[AccountId], + ) -> ExecutionSuccess { let e = self.vault.set_supply_queue(allocator, markets).await; if self.debug { print_execution(&e); } + e } - pub async fn set_withdraw_queue(&self, allocator: &Account, markets: &[AccountId]) { + pub async fn set_withdraw_queue( + &self, + allocator: &Account, + markets: &[AccountId], + ) -> ExecutionSuccess { let e = self.vault.set_withdraw_queue(allocator, markets).await; if self.debug { print_execution(&e); } + e } } diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 1e782c39..011cc3c4 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -100,7 +100,7 @@ macro_rules! setup_test { let $crate::SetupEverything { $($e,)* .. } = s; }; ($w:ident extract($($e:ident),*) accounts($($n:ident),*)) => { - $crate::setup_test_w!($w extract($($e),*) accounts($($n),*) config(|_| {}), vconfig(|_| {})) + $crate::setup_test_w!($w extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})) }; } From 83ab8375b8d46af06cfcba9a0f7c75cee9bd76fb Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 12:28:43 +0100 Subject: [PATCH 031/121] feat: assert gas minimums on each fn --- common/src/vault.rs | 10 +++++++++- contract/vault/examples/gas_report.rs | 5 +---- contract/vault/src/impl_token_receiver.rs | 14 +++----------- contract/vault/src/lib.rs | 13 ++++++++----- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 81868683..32f42eae 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -1,4 +1,4 @@ -use near_sdk::{json_types::U128, near, AccountId, Gas, Promise, PromiseOrValue}; +use near_sdk::{env, json_types::U128, near, require, AccountId, Gas, Promise, PromiseOrValue}; use crate::asset::{BorrowAsset, FungibleAsset}; @@ -154,6 +154,14 @@ pub const ALLOCATE_GAS: Gas = buffer(21); pub const WITHDRAW_GAS: Gas = buffer(4); pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); +pub fn require_at_least(needed: Gas) { + let gas = env::prepaid_gas(); + require!( + gas >= needed, + format!("Insufficient gas: {}, needed: {needed}", gas) + ); +} + #[near_sdk::ext_contract(ext_self)] pub trait Callbacks { fn after_supply_1_check(&mut self, op_id: u64, market_index: u32, attempted: U128) -> bool; diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index f7086dad..ff653c80 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -38,10 +38,7 @@ async fn main() { (supply_gas, allocation_gas) }); - let results = futures::future::join_all(futures) - .await - .expect("All futures failed") - .into_iter(); + let results = futures::future::join_all(futures).await.into_iter(); let mut supply_gas_average = 0f64; let mut allocation_gas_average = 0f64; diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 3c5a832b..7bbc904b 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -1,7 +1,7 @@ use crate::{aux::ReturnStyle, Contract, ContractExt, OpState}; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; -use templar_common::vault::{AllocationMode, DepositMsg, Event, SUPPLY_GAS}; +use templar_common::vault::{require_at_least, AllocationMode, DepositMsg, Event, SUPPLY_GAS}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; @@ -26,11 +26,7 @@ impl FungibleTokenReceiver for Contract { match msg { DepositMsg::Supply => { - require!( - env::prepaid_gas() > SUPPLY_GAS, - format!("Not enough gas, required {SUPPLY_GAS}") - ); - + require_at_least(SUPPLY_GAS); let refund = self.execute_supply(sender_id, asset_id, amount.into()); PromiseOrValue::Value(refund.into()) } @@ -71,11 +67,7 @@ impl Nep245Receiver for Contract { match msg { DepositMsg::Supply => { - require!( - env::prepaid_gas() > SUPPLY_GAS, - format!("Not enough gas, required {SUPPLY_GAS}") - ); - + require_at_least(SUPPLY_GAS); let mt = env::predecessor_account_id(); if !self.underlying_asset.is_nep245(&mt, token_id) { diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 156e88c9..cbd7c274 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -4,7 +4,7 @@ use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, json_types::U128, - near, serde_json, + near, require, serde_json, store::{IterableMap, LookupMap, Vector}, AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, }; @@ -21,11 +21,11 @@ use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ - ext_self, AllocationMode, AllocationPlan, AllocationWeights, Error, Event, - MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, + ext_self, require_at_least, AllocationMode, AllocationPlan, AllocationWeights, Error, + Event, MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_ENSURE_GAS, CREATE_WITHDRAW_REQ_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, - MIN_TIMELOCK_NS, + AFTER_SUPPLY_ENSURE_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, + MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; pub use wad::*; @@ -731,6 +731,7 @@ impl Contract { /// Internally calls `redeem` after computing the share amount. #[payable] pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { + require_at_least(WITHDRAW_GAS); let shares_needed = self.preview_withdraw(amount).0; self.redeem(U128(shares_needed), receiver) } @@ -768,6 +769,7 @@ impl Contract { /// Executes the next pending withdrawal request, if any, using the existing withdraw pipeline. /// This defers creating market-side withdrawal requests until explicitly invoked. pub fn execute_next_withdrawal_request(&mut self) -> PromiseOrValue<()> { + require_at_least(EXECUTE_WITHDRAW_GAS); self.ensure_idle(); Self::assert_allocator(); @@ -810,6 +812,7 @@ impl Contract { weights: AllocationWeights, amount: Option, ) -> PromiseOrValue<()> { + require_at_least(ALLOCATE_GAS); Self::assert_allocator(); self.ensure_idle(); From f6eb73e5df83efd9b8b441c3dfc4e6ff6721eac2 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 12:45:51 +0100 Subject: [PATCH 032/121] fix: allocations and withdraw executions can't be parallel --- common/src/vault.rs | 6 ++- contract/vault/examples/gas_report.rs | 76 ++++++++++++++++----------- contract/vault/src/lib.rs | 7 +-- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 32f42eae..7c1fe67e 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -145,7 +145,10 @@ pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(5); // Add a 20% buffer to a gas estimate pub const fn buffer(size: usize) -> Gas { - Gas::from_tgas(size as u64 * 2 / 5) + // 20% buffer => multiply by 6/5 (≈1.2x) + Gas::from_tgas(size as u64) + .saturating_mul(6) + .saturating_div(5) } // NOTE: these are taken after running the contract with the gas report @@ -153,6 +156,7 @@ pub const SUPPLY_GAS: Gas = buffer(8); pub const ALLOCATE_GAS: Gas = buffer(21); pub const WITHDRAW_GAS: Gas = buffer(4); pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); +pub const SUBMIT_CAP_GAS: Gas = buffer(4); pub fn require_at_least(needed: Gas) { let gas = env::prepaid_gas(); diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index ff653c80..36c970b2 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -23,61 +23,72 @@ async fn main() { let weights = vec![(c.market.contract().id().clone(), U128(1))]; let user1_amount = max / ITERATIONS as u128; - let futures = (0..ITERATIONS).map(|_| async { - let supply_gas = vault + // Run supplies concurrently. + let supply_futures = (0..ITERATIONS).map(|_| async { + vault .supply(&user1, user1_amount) .await .total_gas_burnt - .as_gas() as f64; + .as_gas() as f64 + }); + let supply_results = futures::future::join_all(supply_futures).await; + + let mut supply_gas_average = 0f64; + for s in supply_results { + supply_gas_average += s / ITERATIONS as f64; + } + let mut allocation_gas_average = 0f64; + for _ in 0..ITERATIONS { let allocation_gas = vault .allocate(&vault_curator, weights.clone(), Some(U128(user1_amount))) .await .total_gas_burnt .as_gas() as f64; - - (supply_gas, allocation_gas) - }); - let results = futures::future::join_all(futures).await.into_iter(); - - let mut supply_gas_average = 0f64; - let mut allocation_gas_average = 0f64; - // Aggregate and compute averages. - for (s, a) in results { - supply_gas_average += s / ITERATIONS as f64; - allocation_gas_average += a / ITERATIONS as f64; + allocation_gas_average += allocation_gas / ITERATIONS as f64; } // Supply to vault let user2_amount = g(); + vault.supply(&user2, user2_amount).await; + let user3_amount = g(); - tokio::join!( - vault.supply(&user2, user2_amount), - vault.supply(&user3, user3_amount) - ); - // Create all futures first so they can be awaited concurrently. - let futures = (0..ITERATIONS).map(|_| async { - let withdraw_gas = vault + // Submitting a smaller gas limit will not require a timelock + let submit_cap_gas = vault + .submit_cap( + &vault_curator, + c.market.contract().id().clone(), + U128(user3_amount), + ) + .await + .total_gas_burnt + .as_gas() as f64; + + vault.supply(&user3, user3_amount).await; + + let withdraw_futures = (0..ITERATIONS).map(|_| async { + vault .withdraw(&user2, U128(1), None) .await .total_gas_burnt - .as_gas() as f64; + .as_gas() as f64 + }); + let withdraw_results = futures::future::join_all(withdraw_futures).await; + + let mut withdraw_gas_average = 0f64; + for w in withdraw_results { + withdraw_gas_average += w / ITERATIONS as f64; + } + + let mut execute_withdraw_gas_average = 0f64; + for _ in 0..ITERATIONS { let execute_gas = vault .execute_next_withdrawal(&vault_curator) .await .total_gas_burnt .as_gas() as f64; - (withdraw_gas, execute_gas) - }); - - let results = futures::future::join_all(futures).await; - - let mut withdraw_gas_average = 0f64; - let mut execute_withdraw_gas_average = 0f64; - for (w, e) in results { - withdraw_gas_average += w / ITERATIONS as f64; - execute_withdraw_gas_average += e / ITERATIONS as f64; + execute_withdraw_gas_average += execute_gas / ITERATIONS as f64; } println!("## Gas Report"); @@ -96,6 +107,7 @@ async fn main() { "execute withdraw", Gas::from_gas(execute_withdraw_gas_average as u64), ), + ("submit_cap", Gas::from_gas(submit_cap_gas as u64)), ]; for (action_label, gas) in list { println!("| `{action_label}` | {gas} |"); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index cbd7c274..34c953c8 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -741,13 +741,10 @@ impl Contract { #[payable] pub fn redeem(&mut self, shares: U128, receiver: AccountId) -> PromiseOrValue<()> { let shares = shares.0; - let assets = self.convert_to_assets(U128(shares)).0; - let sender = env::predecessor_account_id(); - // Require storage deposit for the pending withdrawal entry - let req_yocto = require_attached_for_pending_withdrawal(); + require_attached_for_pending_withdrawal(); // Move shares into escrow #[allow(clippy::expect_used, reason = "No side effects")] @@ -1171,7 +1168,7 @@ impl Contract { fn ensure_idle(&self) { // Invariant: Only one op in flight; ensure_idle() guards all mutating ops. if !matches!(self.op_state, OpState::Idle) { - env::panic_str("busy"); + env::panic_str("Invariant: Only one op in flight"); } } From 2ab8afb2308eb248a756d76175447b85b730101c Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 14:46:38 +0100 Subject: [PATCH 033/121] fix: update schemars --- Cargo.lock | 8 ++++---- common/src/vault.rs | 2 +- contract/vault/tests/invariants.rs | 2 +- test-utils/src/lib.rs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93fe3b1b..ce940eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3713,9 +3713,9 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "schemars_derive", @@ -3725,9 +3725,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", diff --git a/common/src/vault.rs b/common/src/vault.rs index 7c1fe67e..67b8ba0c 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -65,7 +65,7 @@ pub struct MarketConfiguration { impl MarketConfiguration { pub const fn encoded_size() -> usize { - 32 + 1 + 8 + 16 + 1 + 8 } } diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 45503a06..3f4aae59 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -59,7 +59,7 @@ async fn withdraw_queue_mustnt_have_duplicates() { } #[tokio::test] -#[should_panic = "busy"] +#[should_panic = "Invariant: Only one op in flight"] async fn state_machine_is_locked_when_another_op_is_running() { setup_test!( extract(vault, c, vault_curator) diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 011cc3c4..0cacf96d 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -1,5 +1,6 @@ use std::{path::Path, str::FromStr}; +use crate::controller::vault::{UnifiedVaultController, VaultController}; pub use controller::{ ft::FtController, market::{MarketController, UnifiedMarketController}, @@ -30,7 +31,6 @@ use templar_common::{ registry::DeployMode, vault::VaultConfiguration, }; -use crate::controller::vault::{UnifiedVaultController, VaultController}; pub const DEFAULT_COLLATERAL_PRICE_ID: PriceIdentifier = PriceIdentifier(hex_literal::hex!( "cccccccc232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588" @@ -82,7 +82,7 @@ macro_rules! accounts { } #[macro_export] -macro_rules! setup_test { +macro_rules! setup_test_w { ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { $crate::accounts!($w, $($n),*); let s = $crate::setup_everything(&$w, $f, |_| {}).await; From bb2f1ff70ec92c591ff390d605420bc01f28654a Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 15:29:32 +0100 Subject: [PATCH 034/121] fix: compilation after worker macro was merged --- contract/vault/tests/happy_path.rs | 4 ++-- contract/vault/tests/invariants.rs | 4 ++-- test-utils/src/lib.rs | 8 ++------ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index e282b614..4740108f 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -1,8 +1,8 @@ use near_sdk::{json_types::U128, AccountId}; use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; use test_utils::{ - controller::vault::UnifiedVaultController, setup_test, setup_test_w, ContractController, - MarketController, UnifiedMarketController, + controller::vault::UnifiedVaultController, setup_test, ContractController, MarketController, + UnifiedMarketController, }; #[tokio::test] diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 3f4aae59..f9bcf6b9 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -1,8 +1,8 @@ use near_sdk::{json_types::U128, AccountId}; use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; use test_utils::{ - controller::vault::UnifiedVaultController, setup_test, setup_test_w, ContractController, - MarketController, UnifiedMarketController, + controller::vault::UnifiedVaultController, setup_test, ContractController, MarketController, + UnifiedMarketController, }; // TODO(unit?): on allocation-failure, reconcile to idle diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 0cacf96d..dfc588ae 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -82,7 +82,7 @@ macro_rules! accounts { } #[macro_export] -macro_rules! setup_test_w { +macro_rules! setup_test { ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { $crate::accounts!($w, $($n),*); let s = $crate::setup_everything(&$w, $f, |_| {}).await; @@ -100,12 +100,8 @@ macro_rules! setup_test_w { let $crate::SetupEverything { $($e,)* .. } = s; }; ($w:ident extract($($e:ident),*) accounts($($n:ident),*)) => { - $crate::setup_test_w!($w extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})) + $crate::setup_test!($w extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})) }; -} - -#[macro_export] -macro_rules! setup_test { (extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { let worker = near_workspaces::sandbox().await.unwrap(); $crate::accounts!(worker, $($n),*); From 9702f0e885c606f7526c052666da5b823f0b570f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 15:31:09 +0100 Subject: [PATCH 035/121] fix: lints --- contract/vault/src/aux.rs | 2 +- contract/vault/src/impl_callbacks.rs | 62 +++++++++++------------- contract/vault/src/lib.rs | 8 +-- contract/vault/src/storage_management.rs | 30 +++++------- contract/vault/src/test_utils.rs | 2 - contract/vault/src/tests.rs | 13 ++--- contract/vault/src/wad.rs | 8 +-- contract/vault/tests/happy_path.rs | 4 +- contract/vault/tests/invariants.rs | 5 +- 9 files changed, 57 insertions(+), 77 deletions(-) diff --git a/contract/vault/src/aux.rs b/contract/vault/src/aux.rs index b0e77b92..ea1cf9e2 100644 --- a/contract/vault/src/aux.rs +++ b/contract/vault/src/aux.rs @@ -33,7 +33,7 @@ pub enum ReturnStyle { // TODO: use this impl ReturnStyle { - pub fn serialize( + #[must_use] pub fn serialize( &self, amount: templar_common::asset::FungibleAssetAmount, ) -> serde_json::Value { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 2d189643..e89edc59 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -3,7 +3,7 @@ use std::fmt::Display; use crate::{ext_self, near, Contract, ContractExt, Error, Nep141Controller, OpState}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ - env, json_types::U128, serde_json, AccountId, Gas, NearToken, Promise, PromiseError, + env, json_types::U128, AccountId, NearToken, PromiseError, PromiseOrValue, }; use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; @@ -30,7 +30,7 @@ impl Contract { attempted: U128, ) -> PromiseOrValue<()> { // Invariant: Index drift or stale op_id results in a graceful stop - let _ = if let Err(e) = self.ctx_allocating(op_id) { + let () = if let Err(e) = self.ctx_allocating(op_id) { return self.stop_and_exit(Some(&e)); }; @@ -503,7 +503,7 @@ impl Contract { index, remaining: U128(remaining), collected: U128(collected), - reason: msg.map(|m| m.to_string()), + reason: msg.map(std::string::ToString::to_string), } .emit(); } @@ -540,7 +540,7 @@ impl Contract { op_id: *op_id, receiver: receiver.clone(), amount: U128(*amount), - reason: msg.map(|m| m.to_string()), + reason: msg.map(std::string::ToString::to_string), } .emit(); } @@ -670,16 +670,16 @@ mod tests { use std::u128; use crate::test_utils::*; - use crate::Contract; + use near_sdk::json_types::U128; - use near_sdk::test_utils::{accounts, VMContextBuilder}; - use near_sdk::{test_utils::testing_env_with_promise_results, AccountId, PromiseOrValue}; - use near_sdk::{test_vm_config, testing_env, PromiseResult, RuntimeFeesConfig}; + use near_sdk::test_utils::accounts; + use near_sdk::PromiseOrValue; + use near_sdk::PromiseResult; use rstest::rstest; - use templar_common::asset::{BorrowAsset, FungibleAsset}; + use templar_common::vault::Error; - use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; - use test_utils::vault_configuration; + use templar_common::vault::OpState; + #[test] fn after_supply_1_check_allocating_not_allocating() { @@ -834,7 +834,7 @@ mod tests { OpState::Payout { amount, .. } => { assert_eq!(*amount, 70, "Payout amount must match collected + credited"); } - other => panic!("Unexpected state after read: {:?}", other), + other => panic!("Unexpected state after read: {other:?}"), } } @@ -952,9 +952,9 @@ mod tests { match &c.op_state { OpState::Payout { amount, .. } => { - assert_eq!(*amount, collected, "Payout amount must equal collected") + assert_eq!(*amount, collected, "Payout amount must equal collected"); } - other => panic!("Unexpected state: {:?}", other), + other => panic!("Unexpected state: {other:?}"), } } @@ -1009,9 +1009,9 @@ mod tests { match &c.op_state { OpState::Payout { amount, .. } => { - assert_eq!(*amount, collected, "Payout amount must equal collected") + assert_eq!(*amount, collected, "Payout amount must equal collected"); } - other => panic!("Unexpected state: {:?}", other), + other => panic!("Unexpected state: {other:?}"), } } @@ -1046,24 +1046,18 @@ mod tests { let call_idx = if pass_index { real_idx } else { real_idx + 1 }; let r = c.after_exec_withdraw_read(Ok(None), call_op, call_idx, U128(10), U128(1)); - match (pass_op, pass_index) { - (true, true) => { - assert!( - !matches!(c.op_state, OpState::Idle), - "Valid callback should not immediately stop" - ); - } - _ => { - // Any mismatch should stop and go Idle - match r { - PromiseOrValue::Value(()) => {} - _ => {} - } - assert!( - matches!(c.op_state, OpState::Idle), - "Mismatched callback must stop and go Idle" - ); - } + if let (true, true) = (pass_op, pass_index) { + assert!( + !matches!(c.op_state, OpState::Idle), + "Valid callback should not immediately stop" + ); + } else { + // Any mismatch should stop and go Idle + if let PromiseOrValue::Value(()) = r {} + assert!( + matches!(c.op_state, OpState::Idle), + "Mismatched callback must stop and go Idle" + ); } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 34c953c8..0ff74d7c 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -4,7 +4,7 @@ use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, json_types::U128, - near, require, serde_json, + near, serde_json, store::{IterableMap, LookupMap, Vector}, AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, }; @@ -146,7 +146,7 @@ impl Contract { /// - `fee_recipient`: account to receive performance fees. /// - `skim_recipient`: account to receive skimmed tokens. /// - `name`/`symbol`/`decimals`: metadata for the share token. - pub fn new(configuration: VaultConfiguration) -> Self { + #[must_use] pub fn new(configuration: VaultConfiguration) -> Self { let VaultConfiguration { owner, curator, @@ -1054,9 +1054,9 @@ impl Contract { .emit(); } - /// Computes fee-aware effective totals for conversions, mimicking MetaMorpho: + /// 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. + /// - Apply virtual offsets: +`virtual_shares` to supply and +`virtual_assets` to assets. fn effective_totals_fee_aware(&self) -> (u128, u128) { let cur = self.get_total_assets().0; let ts = self.total_supply(); diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 4ddf5328..ab66d5e2 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -1,5 +1,4 @@ use crate::PendingWithdrawal; -use near_sdk::borsh::{self, BorshSerialize}; use near_sdk::{env, AccountId}; use std::collections::HashSet; use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; @@ -12,52 +11,52 @@ use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; pub const MAP_ENTRY_OVERHEAD: u64 = 64; pub const VEC_ITEM_OVERHEAD: u64 = 16; -pub fn storage_bytes_for_queue_account_id() -> u64 { +#[must_use] pub fn storage_bytes_for_queue_account_id() -> u64 { VEC_ITEM_OVERHEAD + storage_bytes_for_account_id() } -pub fn storage_bytes_for_config_entry() -> u64 { +#[must_use] pub fn storage_bytes_for_config_entry() -> u64 { let key = storage_bytes_for_account_id(); MAP_ENTRY_OVERHEAD + key + MarketConfiguration::encoded_size() as u64 } -pub fn storage_bytes_for_market_supply_entry() -> u64 { +#[must_use] pub fn storage_bytes_for_market_supply_entry() -> u64 { let key = storage_bytes_for_account_id(); // u128 principal let val = 16u64; MAP_ENTRY_OVERHEAD + key + val } -pub fn storage_bytes_for_pending_cap_entry() -> u64 { +#[must_use] pub fn storage_bytes_for_pending_cap_entry() -> u64 { let key = storage_bytes_for_account_id(); // PendingValue { value: u128, valid_at: u64 } let val = 16u64 + 8u64; MAP_ENTRY_OVERHEAD + key + val } -pub fn storage_bytes_for_pending_withdrawal() -> u64 { +#[must_use] pub fn storage_bytes_for_pending_withdrawal() -> u64 { // Key is u64 id -> 8 bytes let key = 8u64; let val = PendingWithdrawal::encoded_size(); MAP_ENTRY_OVERHEAD + key + val } -pub fn yocto_for_bytes(bytes: u64) -> u128 { +#[must_use] pub fn yocto_for_bytes(bytes: u64) -> u128 { let price = env::storage_byte_cost().as_yoctonear(); u128::from(bytes).saturating_mul(price) } -pub fn yocto_for_new_market() -> u128 { +#[must_use] pub fn yocto_for_new_market() -> u128 { yocto_for_bytes( storage_bytes_for_config_entry().saturating_add(storage_bytes_for_market_supply_entry()), ) } -pub fn yocto_for_pending_cap() -> u128 { +#[must_use] pub fn yocto_for_pending_cap() -> u128 { yocto_for_bytes(storage_bytes_for_pending_cap_entry()) } -pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { +#[must_use] pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { new.iter().fold(0u128, |acc, id| { if current.contains(id) { acc @@ -67,24 +66,21 @@ pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId] }) } -pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { +#[must_use] pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { let attached = env::attached_deposit().as_yoctonear(); assert!( attached >= required_yocto, - "Insufficient storage deposit for {}: required {}, attached {}", - ctx, - required_yocto, - attached + "Insufficient storage deposit for {ctx}: required {required_yocto}, attached {attached}" ); required_yocto } -pub fn require_attached_for_bytes(bytes: u64, ctx: &str) -> u128 { +#[must_use] pub fn require_attached_for_bytes(bytes: u64, ctx: &str) -> u128 { let req = yocto_for_bytes(bytes); require_attached_at_least(req, ctx) } -pub fn require_attached_for_pending_withdrawal() -> u128 { +#[must_use] pub fn require_attached_for_pending_withdrawal() -> u128 { let bytes = storage_bytes_for_pending_withdrawal(); require_attached_for_bytes(bytes, "withdrawal request") } diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index e2f6c5ca..4fe7f0c4 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -5,8 +5,6 @@ pub use near_sdk::{ test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, }; use near_sdk_contract_tools::ft::Nep141Controller as _; -use rstest::rstest; -use templar_common::vault::{AllocationMode, OpState, VaultConfiguration}; use test_utils::vault_configuration; pub fn mk(n: u32) -> AccountId { diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 6de488f5..a37b9acb 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,5 +1,3 @@ -use crate::storage_management; -use crate::storage_management::storage_bytes_for_pending_cap_entry; use crate::storage_management::storage_bytes_for_queue_account_id; use crate::storage_management::yocto_for_bytes; use crate::storage_management::yocto_for_new_market; @@ -7,15 +5,12 @@ use crate::test_utils::*; use crate::Contract; use near_sdk::env; use near_sdk::test_utils::accounts; -use near_sdk::{json_types::U128, AccountId, RuntimeFeesConfig}; -use near_sdk::{test_vm_config, testing_env}; +use near_sdk::{json_types::U128, AccountId}; use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::owner::OwnerExternal; use rstest::rstest; -use templar_common::asset::{BorrowAsset, FungibleAsset}; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; -use templar_common::vault::{AllocationMode, VaultConfiguration}; #[rstest(len => [2usize, 3, 5])] #[should_panic = "Duplicate market"] @@ -142,8 +137,8 @@ fn execute_next_withdrawal_request_skips_holes() { let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - println!("vault_id: {}", vault_id); - println!("owner: {}", owner); + println!("vault_id: {vault_id}"); + println!("owner: {owner}"); // Bob gets 20 shares c.deposit_unchecked(&owner, 20).unwrap(); @@ -722,7 +717,7 @@ fn clamp_allocation_total_matches_min_bounds_cases( c.supply_queue.push(m.clone()); c.idle_balance = idle; - let room = if cap > cur { cap - cur } else { 0 }; + let room = cap.saturating_sub(cur); let requested = req.unwrap_or(c.idle_balance); let expect = requested.min(c.idle_balance).min(room); diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index e4edf1ef..485aba17 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -6,14 +6,14 @@ pub const WAD: u128 = 1e18 as u128; /// Multiplies two WAD-scaled values and floors the result: floor(x * y / WAD). #[inline] -pub fn mul_wad_floor(x: u128, y: u128) -> u128 { +#[must_use] pub fn mul_wad_floor(x: u128, y: u128) -> u128 { mul_div_floor(x, y, WAD) } /// Multiplies and divides with flooring: floor(x * y / denom). /// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. #[inline] -pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { +#[must_use] pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { if denom == 0 { return 0; } @@ -25,7 +25,7 @@ pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { /// Multiplies and divides with ceiling: ceil(x * y / denom). /// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. #[inline] -pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { +#[must_use] pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { if denom == 0 { return 0; } @@ -43,7 +43,7 @@ pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { /// /// Floors intermediate divisions; returns 0 when no profit, zero fee, or zero supply. #[inline] -pub fn compute_fee_shares( +#[must_use] pub fn compute_fee_shares( cur_total_assets: u128, last_total_assets: u128, performance_fee: u128, diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 4740108f..012730f2 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -1,7 +1,7 @@ -use near_sdk::{json_types::U128, AccountId}; +use near_sdk::json_types::U128; use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; use test_utils::{ - controller::vault::UnifiedVaultController, setup_test, ContractController, MarketController, + controller::vault::UnifiedVaultController, setup_test, ContractController, UnifiedMarketController, }; diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index f9bcf6b9..0dce844b 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -1,8 +1,5 @@ -use near_sdk::{json_types::U128, AccountId}; -use templar_common::{interest_rate_strategy::InterestRateStrategy, number::Decimal}; use test_utils::{ - controller::vault::UnifiedVaultController, setup_test, ContractController, MarketController, - UnifiedMarketController, + setup_test, ContractController, }; // TODO(unit?): on allocation-failure, reconcile to idle From b4235f18fe62d08fd2e555d2a767c21c47e20de4 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 15:38:56 +0100 Subject: [PATCH 036/121] ci: gas report for vault --- script/ci/gas-report.sh | 1 + script/prebuild-test-contracts.sh | 3 +++ 2 files changed, 4 insertions(+) diff --git a/script/ci/gas-report.sh b/script/ci/gas-report.sh index 8cc42282..f0f43212 100755 --- a/script/ci/gas-report.sh +++ b/script/ci/gas-report.sh @@ -5,3 +5,4 @@ SCRIPT_DIR=$(dirname "$(readlink -f ${BASH_SOURCE[0]})") source "$SCRIPT_DIR/../prebuild-test-contracts.sh" cargo run --package templar-market-contract --example gas_report +cargo run --package templar-vault-contract --example gas_report diff --git a/script/prebuild-test-contracts.sh b/script/prebuild-test-contracts.sh index 891d2e25..ab3ccaa5 100755 --- a/script/prebuild-test-contracts.sh +++ b/script/prebuild-test-contracts.sh @@ -24,5 +24,8 @@ cargo near build non-reproducible-wasm 1>&2 cd "$ROOT_DIR/contract/universal-account" cargo near build non-reproducible-wasm 1>&2 +cd "$ROOT_DIR/contract/vault" +cargo near build non-reproducible-wasm 1>&2 + cd "$ROOT_DIR" export TEST_CONTRACTS_PREBUILT=1 From 95b4fe8372a53781acaed9a498bdf3215586a400 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 15 Oct 2025 15:40:13 +0100 Subject: [PATCH 037/121] fix: fmt --- contract/vault/src/aux.rs | 5 ++-- contract/vault/src/impl_callbacks.rs | 10 ++----- contract/vault/src/lib.rs | 3 +- contract/vault/src/storage_management.rs | 36 ++++++++++++++++-------- contract/vault/src/wad.rs | 12 +++++--- contract/vault/tests/invariants.rs | 4 +-- test-utils/src/controller/mod.rs | 2 +- 7 files changed, 42 insertions(+), 30 deletions(-) diff --git a/contract/vault/src/aux.rs b/contract/vault/src/aux.rs index ea1cf9e2..f855fc17 100644 --- a/contract/vault/src/aux.rs +++ b/contract/vault/src/aux.rs @@ -1,4 +1,4 @@ -use crate::{AccountId, Contract, Nep145Controller, Nep145ForceUnregister, env, near, serde_json}; +use crate::{env, near, serde_json, AccountId, Contract, Nep145Controller, Nep145ForceUnregister}; impl Contract { /* ----- Storage ----- */ @@ -33,7 +33,8 @@ pub enum ReturnStyle { // TODO: use this impl ReturnStyle { - #[must_use] pub fn serialize( + #[must_use] + pub fn serialize( &self, amount: templar_common::asset::FungibleAssetAmount, ) -> serde_json::Value { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index e89edc59..696f3c82 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -2,10 +2,7 @@ use std::fmt::Display; use crate::{ext_self, near, Contract, ContractExt, Error, Nep141Controller, OpState}; use near_contract_standards::fungible_token::core::ext_ft_core; -use near_sdk::{ - env, json_types::U128, AccountId, NearToken, PromiseError, - PromiseOrValue, -}; +use near_sdk::{env, json_types::U128, AccountId, NearToken, PromiseError, PromiseOrValue}; use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; use templar_common::{ market::ext_market, @@ -670,16 +667,15 @@ mod tests { use std::u128; use crate::test_utils::*; - + use near_sdk::json_types::U128; use near_sdk::test_utils::accounts; use near_sdk::PromiseOrValue; use near_sdk::PromiseResult; use rstest::rstest; - + use templar_common::vault::Error; use templar_common::vault::OpState; - #[test] fn after_supply_1_check_allocating_not_allocating() { diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 0ff74d7c..1ba93efb 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -146,7 +146,8 @@ impl Contract { /// - `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 { + #[must_use] + pub fn new(configuration: VaultConfiguration) -> Self { let VaultConfiguration { owner, curator, diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index ab66d5e2..23d743b5 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -11,52 +11,61 @@ use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; pub const MAP_ENTRY_OVERHEAD: u64 = 64; pub const VEC_ITEM_OVERHEAD: u64 = 16; -#[must_use] pub fn storage_bytes_for_queue_account_id() -> u64 { +#[must_use] +pub fn storage_bytes_for_queue_account_id() -> u64 { VEC_ITEM_OVERHEAD + storage_bytes_for_account_id() } -#[must_use] pub fn storage_bytes_for_config_entry() -> u64 { +#[must_use] +pub fn storage_bytes_for_config_entry() -> u64 { let key = storage_bytes_for_account_id(); MAP_ENTRY_OVERHEAD + key + MarketConfiguration::encoded_size() as u64 } -#[must_use] pub fn storage_bytes_for_market_supply_entry() -> u64 { +#[must_use] +pub fn storage_bytes_for_market_supply_entry() -> u64 { let key = storage_bytes_for_account_id(); // u128 principal let val = 16u64; MAP_ENTRY_OVERHEAD + key + val } -#[must_use] pub fn storage_bytes_for_pending_cap_entry() -> u64 { +#[must_use] +pub fn storage_bytes_for_pending_cap_entry() -> u64 { let key = storage_bytes_for_account_id(); // PendingValue { value: u128, valid_at: u64 } let val = 16u64 + 8u64; MAP_ENTRY_OVERHEAD + key + val } -#[must_use] pub fn storage_bytes_for_pending_withdrawal() -> u64 { +#[must_use] +pub fn storage_bytes_for_pending_withdrawal() -> u64 { // Key is u64 id -> 8 bytes let key = 8u64; let val = PendingWithdrawal::encoded_size(); MAP_ENTRY_OVERHEAD + key + val } -#[must_use] pub fn yocto_for_bytes(bytes: u64) -> u128 { +#[must_use] +pub fn yocto_for_bytes(bytes: u64) -> u128 { let price = env::storage_byte_cost().as_yoctonear(); u128::from(bytes).saturating_mul(price) } -#[must_use] pub fn yocto_for_new_market() -> u128 { +#[must_use] +pub fn yocto_for_new_market() -> u128 { yocto_for_bytes( storage_bytes_for_config_entry().saturating_add(storage_bytes_for_market_supply_entry()), ) } -#[must_use] pub fn yocto_for_pending_cap() -> u128 { +#[must_use] +pub fn yocto_for_pending_cap() -> u128 { yocto_for_bytes(storage_bytes_for_pending_cap_entry()) } -#[must_use] pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { +#[must_use] +pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { new.iter().fold(0u128, |acc, id| { if current.contains(id) { acc @@ -66,7 +75,8 @@ pub const VEC_ITEM_OVERHEAD: u64 = 16; }) } -#[must_use] pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { +#[must_use] +pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { let attached = env::attached_deposit().as_yoctonear(); assert!( attached >= required_yocto, @@ -75,12 +85,14 @@ pub const VEC_ITEM_OVERHEAD: u64 = 16; required_yocto } -#[must_use] pub fn require_attached_for_bytes(bytes: u64, ctx: &str) -> u128 { +#[must_use] +pub fn require_attached_for_bytes(bytes: u64, ctx: &str) -> u128 { let req = yocto_for_bytes(bytes); require_attached_at_least(req, ctx) } -#[must_use] pub fn require_attached_for_pending_withdrawal() -> u128 { +#[must_use] +pub fn require_attached_for_pending_withdrawal() -> u128 { let bytes = storage_bytes_for_pending_withdrawal(); require_attached_for_bytes(bytes, "withdrawal request") } diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index 485aba17..dd6d9003 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -6,14 +6,16 @@ pub const WAD: u128 = 1e18 as u128; /// Multiplies two WAD-scaled values and floors the result: floor(x * y / WAD). #[inline] -#[must_use] pub fn mul_wad_floor(x: u128, y: u128) -> u128 { +#[must_use] +pub fn mul_wad_floor(x: u128, y: u128) -> u128 { mul_div_floor(x, y, WAD) } /// Multiplies and divides with flooring: floor(x * y / denom). /// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. #[inline] -#[must_use] pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { +#[must_use] +pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { if denom == 0 { return 0; } @@ -25,7 +27,8 @@ pub const WAD: u128 = 1e18 as u128; /// Multiplies and divides with ceiling: ceil(x * y / denom). /// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. #[inline] -#[must_use] pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { +#[must_use] +pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { if denom == 0 { return 0; } @@ -43,7 +46,8 @@ pub const WAD: u128 = 1e18 as u128; /// /// Floors intermediate divisions; returns 0 when no profit, zero fee, or zero supply. #[inline] -#[must_use] pub fn compute_fee_shares( +#[must_use] +pub fn compute_fee_shares( cur_total_assets: u128, last_total_assets: u128, performance_fee: u128, diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 0dce844b..43ed2929 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -1,6 +1,4 @@ -use test_utils::{ - setup_test, ContractController, -}; +use test_utils::{setup_test, ContractController}; // TODO(unit?): on allocation-failure, reconcile to idle diff --git a/test-utils/src/controller/mod.rs b/test-utils/src/controller/mod.rs index d260af10..8d64354f 100644 --- a/test-utils/src/controller/mod.rs +++ b/test-utils/src/controller/mod.rs @@ -14,8 +14,8 @@ pub mod oracle; pub mod registry; pub mod storage_management; pub mod token; -pub mod vault; pub mod universal_account; +pub mod vault; pub trait ContractController { fn contract(&self) -> &Contract; From cee6514af6db46054662d208829decd3917e9dbd Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 10:34:14 +0100 Subject: [PATCH 038/121] chore: misnamed package --- Cargo.toml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dc9d5b58..7afca926 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,14 @@ [workspace] resolver = "2" -members = ["bots", "common", "contract/*", "mock/*", "service/*", "test-utils", "universal-account"] +members = [ + "bots", + "common", + "contract/*", + "mock/*", + "service/*", + "test-utils", + "universal-account", +] [workspace.package] license = "MIT" @@ -33,7 +41,7 @@ rstest = { version = "0.24" } schemars = { version = "0.8" } templar-common = { path = "./common" } templar-universal-account = { path = "./universal-account" } -template-vault-contract = { path = "./contract/vault" } +templar-vault-contract = { path = "./contract/vault" } test-utils = { path = "./test-utils" } thiserror = "2.0.11" tokio = { version = "1.30.0", features = ["full"] } From bc21635b783ae67a10155daa091aa8b0b7f391d5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 10:35:20 +0100 Subject: [PATCH 039/121] chore: pr comments - docstrings, requires & panics --- common/src/vault.rs | 42 +++++---- contract/vault/src/aux.rs | 4 +- contract/vault/src/impl_callbacks.rs | 38 ++++---- contract/vault/src/lib.rs | 108 +++++++++++------------ contract/vault/src/storage_management.rs | 4 +- contract/vault/src/test_utils.rs | 11 +-- contract/vault/src/tests.rs | 22 ++--- 7 files changed, 114 insertions(+), 115 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 67b8ba0c..bfcafcf6 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -18,28 +18,26 @@ 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, - }, + /// 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, } diff --git a/contract/vault/src/aux.rs b/contract/vault/src/aux.rs index f855fc17..73125957 100644 --- a/contract/vault/src/aux.rs +++ b/contract/vault/src/aux.rs @@ -1,7 +1,7 @@ use crate::{env, near, serde_json, AccountId, Contract, Nep145Controller, Nep145ForceUnregister}; impl Contract { - /* ----- Storage ----- */ + /// ----- Storage ----- fn charge_for_storage(&mut self, account_id: &AccountId, storage_consumption: u64) { // Invariant: Storage charging saturates and panics on failure to avoid negative balances. self.lock_storage( @@ -31,7 +31,7 @@ pub enum ReturnStyle { Nep245MtTransferCall, } -// TODO: use this +/// TODO: use this impl ReturnStyle { #[must_use] pub fn serialize( diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 696f3c82..d0c22613 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -393,12 +393,12 @@ impl Contract { Self::compute_escrow_settlement(escrow_shares, burn_shares); if to_burn > 0 { self.withdraw_unchecked(&env::current_account_id(), to_burn) - .expect("Failed to burn escrowed shares"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); } if refund_shares > 0 { #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&env::current_account_id(), &owner, refund_shares) - .expect("Failed to refund remaining escrowed shares"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); } self.op_state = OpState::Idle; true @@ -406,7 +406,7 @@ impl Contract { // Invariant: On payout failure, refund full escrow to owner and leave idle_balance unchanged #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&env::current_account_id(), &owner, escrow_shares) - .expect("Failed to release escrowed shares"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); self.op_state = OpState::Idle; false } @@ -517,7 +517,7 @@ impl Contract { let self_id = env::current_account_id(); #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&self_id, &owner_acc, escrow) - .expect("Failed to release escrowed shares"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); } } self.op_state = OpState::Idle; @@ -555,7 +555,7 @@ impl Contract { let self_id = env::current_account_id(); #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&self_id, &owner_acc, escrow) - .expect("Failed to release escrowed shares"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); } } self.op_state = OpState::Idle; @@ -579,7 +579,7 @@ impl Contract { } PromiseOrValue::Value(()) } - // Validate current op is Allocating and return (index, remaining) + /// Validate current op is Allocating and return (index, remaining) pub(crate) fn ctx_allocating(&self, op_id: u64) -> Result<(u32, u128), Error> { match &self.op_state { OpState::Allocating { @@ -591,7 +591,7 @@ impl Contract { } } - // Validate current op is Withdrawing and return context tuple + /// Validate current op is Withdrawing and return context tuple pub(crate) fn ctx_withdrawing( &self, op_id: u64, @@ -617,7 +617,7 @@ impl Contract { } } - // Resolve a market for allocation by plan (if present) or supply_queue + /// Resolve a market for allocation by plan (if present) or supply_queue pub(crate) fn resolve_supply_market(&self, market_index: u32) -> Result { if let Some(plan) = &self.plan { if let Some((m, _)) = plan.get(market_index as usize) { @@ -631,7 +631,7 @@ impl Contract { .ok_or(Error::MissingMarket(market_index)) } - // Resolve a market for withdraw by withdraw_queue + /// Resolve a market for withdraw by withdraw_queue pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result { self.withdraw_queue .get(market_index) @@ -639,7 +639,7 @@ impl Contract { .ok_or(Error::MissingMarket(market_index)) } - // Pure reconciliation for withdraw read outcome to enable unit tests + /// Pure reconciliation for withdraw read outcome to enable unit tests pub(crate) fn reconcile_withdraw_outcome( &self, before_principal: u128, @@ -684,7 +684,7 @@ mod tests { &vault_id, &vault_id, vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap(), + near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), )], ); @@ -707,7 +707,7 @@ mod tests { &vault_id, &vault_id, vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap(), + near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), )], ); @@ -735,7 +735,7 @@ mod tests { &vault_id, &vault_id, vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap(), + near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), )], ); @@ -865,7 +865,7 @@ mod tests { } } - // Property: Payout failure keeps idle_balance unchanged and does not burn escrow + /// Property: Payout failure keeps idle_balance unchanged and does not burn escrow #[rstest( idle => [0u128, 1, 100], escrow => [0u128, 1, 50], @@ -883,7 +883,7 @@ mod tests { use near_sdk_contract_tools::ft::Nep141Controller as _; c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) - .expect("seed escrow into vault"); + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); } c.idle_balance = idle; @@ -914,7 +914,7 @@ mod tests { ); } - // Property: Create-withdraw failure skips to next market and if collected>0 ends in Payout + /// Property: Create-withdraw failure skips to next market and if collected>0 ends in Payout #[rstest( collected => [1u128, 10u128], need => [1u128, 5u128] @@ -954,7 +954,7 @@ mod tests { } } - // Property: Exec-withdraw read failure assumes unchanged principal and does not credit idle + /// Property: Exec-withdraw read failure assumes unchanged principal and does not credit idle #[rstest( before => [0u128, 1u128, 100u128], need => [0u128, 1u128, 50u128], @@ -1011,7 +1011,7 @@ mod tests { } } - // Property: Callbacks must match current op_id or index; otherwise stop and go Idle + /// Property: Callbacks must match current op_id or index; otherwise stop and go Idle #[rstest( pass_op => [false, true], pass_index => [false, true] @@ -1068,7 +1068,7 @@ mod tests { // Seed escrowed shares into the vault's own account let owner = accounts(1); c.deposit_unchecked(&near_sdk::env::current_account_id(), 10) - .expect("seed escrow into vault"); + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); // Single-market withdraw queue (not used functionally here, just to satisfy path) let market = mk(12); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 1ba93efb..28919c9c 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -74,7 +74,7 @@ pub enum Role { } #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] -// FIXME: #[nep145(force_unregister_hook = "Self")] +/// FIXME: #[nep145(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] #[near(contract_state)] /// Vault contract that issues shares over an underlying fungible asset and allocates liquidity @@ -88,7 +88,7 @@ pub struct Contract { /// configuration per market (market ID -> MarketConfig) config: IterableMap, - // TODO: decimal offset for virtual shares + /// TODO: decimal offset for virtual shares /// Performance fee (as WAD fraction) performance_fee: wad::WADFraction, fee_recipient: AccountId, @@ -109,24 +109,24 @@ pub struct Contract { /// Current timelock duration for governance actions (ns) timelock_ns: TimestampNs, - // Ordered list of market IDs for deposit allocation + /// Ordered list of market IDs for deposit allocation supply_queue: Vector, - // Ordered list of market IDs for withdrawal prioritytr + /// Ordered list of market IDs for withdrawal prioritytr withdraw_queue: Vector, - // vault's supplied principal per market (borrow-asset units) + /// vault's supplied principal per market (borrow-asset units) market_supply: LookupMap, - // underlying held by vault + /// underlying held by vault idle_balance: u128, op_state: OpState, next_op_id: u64, - // Storage usage + /// Storage usage storage_usage_supply: u64, storage_usage_role: u64, - // Pending withdrawals queue (vault-level, FIFO by id) + /// Pending withdrawals queue (vault-level, FIFO by id) pending_withdrawals: IterableMap, next_withdraw_id: u64, next_withdraw_to_execute: u64, @@ -163,7 +163,7 @@ impl Contract { } = configuration; let timelock_ns = u64::from(initial_timelock_sec) * 1_000_000_000; - assert!( + near_sdk::require!( (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&timelock_ns), "timelock bounds" ); @@ -231,11 +231,11 @@ impl Contract { pub fn set_curator(&mut self, account: AccountId) { Self::require_owner(); Self::with_members_of(&Role::Curator, |members| { - assert!( + near_sdk::require!( members.len() < 2, "Invariant violation: Cannot Have more than 1 Curator" ); - assert!( + near_sdk::require!( !members.contains(&account), "Curator already set to this account" ); @@ -274,14 +274,14 @@ impl Contract { let mut guardian_occupied = false; Self::with_members_of(&Role::Guardian, |members| { - assert!( + near_sdk::require!( members.len() < 2, "Invariant violation: Cannot Have more than 1 Guardian" ); - assert!(!members.contains(&new_g), "Already set to this address"); + near_sdk::require!(!members.contains(&new_g), "Already set to this address"); guardian_occupied = !members.is_empty(); }); - assert!( + near_sdk::require!( self.pending_guardian.is_none(), "Guardian change already pending" ); @@ -303,7 +303,7 @@ impl Contract { let p = self.pending_guardian.clone(); if let Some(p) = &p { - assert!(env::block_timestamp() >= p.valid_at, "not yet"); + near_sdk::require!(env::block_timestamp() >= p.valid_at, "not yet"); Self::with_members_of(&Role::Guardian, |members| { members.iter().for_each(|m| { self.remove_role(&m, &Role::Guardian); @@ -323,7 +323,7 @@ impl Contract { /// Sets the recipient account for skimmed tokens. pub fn set_skim_recipient(&mut self, account: AccountId) { Self::require_owner(); - assert!( + near_sdk::require!( account != self.skim_recipient, "Already set to this address" ); @@ -337,7 +337,7 @@ impl Contract { /// Sets the performance fee recipient. Accrues pending fees with the current recipient first. pub fn set_fee_recipient(&mut self, account: AccountId) { Self::require_owner(); - assert!(account != self.fee_recipient, "Already set to this address"); + near_sdk::require!(account != self.fee_recipient, "Already set to this address"); if self.performance_fee != 0 { // Accrue any pending fees to current recipient before changing (so current recipient gets up to now) @@ -356,9 +356,9 @@ impl Contract { let fee: u128 = fee.into(); - assert!(fee != self.performance_fee, "Fee already set to this value"); + near_sdk::require!(fee != self.performance_fee, "Fee already set to this value"); // FIXME: dynamic based on underlying - assert!(fee <= (wad::WAD / 10), "fee too high"); + near_sdk::require!(fee <= (wad::WAD / 10), "fee too high"); // Accrue any pending fees with old rate before changing self.internal_accrue_fee(); @@ -373,12 +373,12 @@ impl Contract { Self::require_owner(); let as_nanos = u64::from(new_timelock_secs) * 1_000_000_000; - assert!(as_nanos != self.timelock_ns, "Already set to this value"); - assert!( + near_sdk::require!(as_nanos != self.timelock_ns, "Already set to this value"); + near_sdk::require!( self.pending_timelock.is_none(), "Timelock change already pending" ); - assert!( + near_sdk::require!( (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&as_nanos), "Timelock out of bounds" ); @@ -406,7 +406,7 @@ impl Contract { pub fn accept_timelock(&mut self) { Self::require_owner(); if let Some(p) = &self.pending_timelock { - assert!( + near_sdk::require!( env::block_timestamp() >= p.valid_at, "Timelock not elapsed yet" ); @@ -453,21 +453,21 @@ impl Contract { // Pre-allocate a market_supply record (principal=0) so allocations don't create storage later self.market_supply.insert(market.clone(), 0); #[allow(clippy::unwrap_used, reason = "No side effects")] - self.config.get_mut(&market).unwrap() + self.config.get_mut(&market).unwrap_or_else(|| env::panic_str(&"Config not found after insert".to_string())) } Some(config) => config, }; - assert!( - self.pending_cap.get(&market).is_none(), + near_sdk::require!( + self.pending_cap.get(&market).is.none(), "Invariant violation: A cap change is already pending for this market" ); - assert!( + near_sdk::require!( config.removable_at == 0, "Market removal pending, cannot change cap" ); let new_cap = new_cap.0; - assert!(new_cap != config.cap, "New cap is same as current"); + near_sdk::require!(new_cap != config.cap, "New cap is same as current"); if new_cap < config.cap { // If lowering the cap, we can apply the delta immediately @@ -496,13 +496,13 @@ impl Contract { Self::assert_curator_or_owner(); self.ensure_idle(); if let Some(pending) = self.pending_cap.get(&market) { - assert!( + near_sdk::require!( env::block_timestamp() >= pending.valid_at, "Timelock not elapsed for cap change" ); #[allow(clippy::expect_used, reason = "No side effects")] - let cfg = self.config.get_mut(&market).expect("Market not found"); + let cfg = self.config.get_mut(&market).unwrap_or_else(|| env::panic_str(&"Market not found".to_string())); cfg.cap = pending.value; if pending.value > 0 { @@ -563,11 +563,11 @@ impl Contract { } } - // To remove a market entirely, the curator: - //- first sets its cap to 0 (disabling new deposits) - //- then calls submit_market_removal. - // > This starts a timelock (using the vault’s timelock) - // - after which the market can be removed from the withdraw_queue (assuming any funds have been withdrawn) + /// To remove a market entirely, the curator: + ///- first sets its cap to 0 (disabling new deposits) + ///- then calls submit_market_removal. + /// > This starts a timelock (using the vault’s timelock) + /// - after which the market can be removed from the withdraw_queue (assuming any funds have been withdrawn) /// Begins the process to remove `market` from the withdraw queue. /// Requires cap == 0 and no pending cap changes; starts a timelock. pub fn submit_market_removal(&mut self, market: AccountId) { @@ -611,7 +611,7 @@ impl Contract { pub fn set_supply_queue(&mut self, markets: Vec) { Self::assert_allocator(); self.ensure_idle(); - assert!(markets.len() <= MAX_QUEUE_LEN, "too long"); + near_sdk::require!(markets.len() <= MAX_QUEUE_LEN, "too long"); // Invariant: supply_queue has no duplicates; allocation order remains meaningful let mut seen = std::collections::HashSet::new(); @@ -623,7 +623,7 @@ impl Contract { // Validate all markets are authorized (cap > 0) before charging storage for m in &markets { let cap = self.config.get(m).map_or(0, |c| c.cap); - assert!(cap > 0, "unauthorized market"); + near_sdk::require!(cap > 0, "unauthorized market"); } // Compute and require storage for additions (no refunds for removals in this pass) @@ -651,7 +651,7 @@ impl Contract { pub fn set_withdraw_queue(&mut self, queue: Vec) { Self::assert_allocator(); self.ensure_idle(); - assert!( + near_sdk::require!( queue.len() <= MAX_QUEUE_LEN, "Withdraw queue length exceeds max" ); @@ -668,7 +668,7 @@ impl Contract { self.withdraw_queue.iter().cloned().collect(); for id in &queue { - assert!( + near_sdk::require!( self.config.get(id).is_some(), "Invariant violation: Unknown market in new queue" ); @@ -684,20 +684,20 @@ impl Contract { if (cfg.enabled || has_supply) && !seen.contains(id) { if current.contains(id) { // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. - assert!( + near_sdk::require!( cfg.cap == 0, "Invariant violation: Cannot remove market with non-zero cap" ); - assert!( + near_sdk::require!( self.pending_cap.get(id).is_none(), "Invariant violation: Cannot remove market with pending cap change" ); if has_supply { - assert!( + near_sdk::require!( cfg.removable_at > 0, "Invariant violation: Market still has supply but no removal scheduled" ); - assert!( + near_sdk::require!( env::block_timestamp() >= cfg.removable_at, "Invariant violation: Removal timelock not elapsed for market" ); @@ -750,7 +750,7 @@ impl Contract { // Move shares into escrow #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&sender, &env::current_account_id(), shares) - .expect("Redeem failed to move shares into escrow"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); self.internal_accrue_fee(); @@ -897,16 +897,16 @@ impl Contract { pub fn get_configuration(&self) -> VaultConfiguration { let timelock_sec = self.timelock_ns / 1_000_000_000; VaultConfiguration { - owner: self.own_get_owner().expect("Owner not set"), + owner: self.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), curator: Self::with_members_of(&Role::Curator, |members| { - assert!( + near_sdk::require!( members.len() == 1, "Invariant violation: Cannot Have more than 1 Curator" ); members.iter().next().expect("Curator not set").clone() }), guardian: Self::with_members_of(&Role::Guardian, |members| { - assert!( + near_sdk::require!( members.len() == 1, "Invariant violation: Cannot Have more than 1 Guardian" ); @@ -1124,7 +1124,7 @@ impl Contract { } #[allow(clippy::expect_used, reason = "No side effects")] self.deposit_unchecked(to, amount) - .expect("Failed to mint shares"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); } pub fn internal_accrue_fee(&mut self) { @@ -1179,7 +1179,7 @@ impl Contract { } self.ensure_idle(); - assert!( + near_sdk::require!( amount <= self.idle_balance, "Invariant Violation: reserve amount must be <= idle_balance" ); @@ -1281,7 +1281,7 @@ impl Contract { Some( #[allow(clippy::expect_used, reason = "Infallible")] serde_json::to_string(&templar_common::market::DepositMsg::Supply) - .expect("Infallible serialisation of supply enum") + .unwrap_or_else(|e| env::panic_str(&e.to_string())) .as_str(), ), ) @@ -1341,7 +1341,7 @@ impl Contract { Some( #[allow(clippy::expect_used, reason = "Infallible")] serde_json::to_string(&templar_common::market::DepositMsg::Supply) - .expect("Infallible serialisation of supply enum") + .unwrap_or_else(|e| env::panic_str(&e.to_string())) .as_str(), ), ) @@ -1464,9 +1464,9 @@ impl Contract { } } - // If we collected something, pay it out now and burn proportional shares or pay directly from idle balance - // TODO: should directly check idle balance first? - // TODO: unit test me + /// If we collected something, pay it out now and burn proportional shares or pay directly from idle balance + /// TODO: should directly check idle balance first? + /// TODO: unit test me fn pay_collected( &mut self, op_id: u64, diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 23d743b5..39bb7d46 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -7,7 +7,7 @@ use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; /// We do not implement refunds for storage management ops, to avoid any potential issues with /// accounting. -// Conservative per-entry overheads to cover collection metadata, prefixes, etc. +/// Conservative per-entry overheads to cover collection metadata, prefixes, etc. pub const MAP_ENTRY_OVERHEAD: u64 = 64; pub const VEC_ITEM_OVERHEAD: u64 = 16; @@ -78,7 +78,7 @@ pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId] #[must_use] pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { let attached = env::attached_deposit().as_yoctonear(); - assert!( + near_sdk::require!( attached >= required_yocto, "Insufficient storage deposit for {ctx}: required {required_yocto}, attached {attached}" ); diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 4fe7f0c4..7b9400b4 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -1,5 +1,6 @@ use crate::Contract; use near_sdk::NearToken; +use near_sdk::env; pub use near_sdk::{ test_utils::{accounts, VMContextBuilder}, test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, @@ -8,7 +9,7 @@ use near_sdk_contract_tools::ft::Nep141Controller as _; use test_utils::vault_configuration; pub fn mk(n: u32) -> AccountId { - format!("acc{n}.testnet").parse().expect("valid account id") + format!("acc{n}.testnet").parse().unwrap_or_else(|e| env::panic_str(&e.to_string())) } pub fn setup_env( @@ -51,7 +52,7 @@ pub fn new_test_contract(vault_id: &AccountId) -> Contract { Contract::new(cfg) } -// Set the block timestamp and keep caller/predecessor consistent for tests +/// Set the block timestamp and keep caller/predecessor consistent for tests pub fn set_block_ts(vault_id: &AccountId, signer: &AccountId, ts: u64) { set_ctx(vault_id, signer, Some(ts), None); } @@ -70,7 +71,7 @@ pub fn set_ctx(vault_id: &AccountId, signer: &AccountId, ts: Option, deposi testing_env!(ctx.build()); } -// Ensure a market exists with given configuration and optionally adds to queues and supply +/// Ensure a market exists with given configuration and optionally adds to queues and supply pub fn ensure_market( c: &mut crate::Contract, id: AccountId, @@ -97,9 +98,9 @@ pub fn ensure_market( } } -// Seed shares into the vault's own account (used for escrow/burn tests) +/// Seed shares into the vault's own account (used for escrow/burn tests) pub fn seed_vault_shares(c: &mut crate::Contract, shares: u128) { #[allow(clippy::expect_used, reason = "test helper")] c.deposit_unchecked(&near_sdk::env::current_account_id(), shares) - .expect("seed escrow into vault"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index a37b9acb..070bb0d9 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -66,7 +66,7 @@ fn fee_accrues_only_on_growth_unit() { // Seed total supply so fees can mint let user = accounts(1); - c.deposit_unchecked(&user, 1_000).expect("seed shares"); + c.deposit_unchecked(&user, 1_000).unwrap_or_else(|e| env::panic_str(&e.to_string())); c.idle_balance = 1_000; // Set fee to 10% @@ -105,7 +105,7 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { // Seed escrow into vault account (shares held by vault) c.deposit_unchecked(&near_sdk::env::current_account_id(), 100) - .expect("seed escrow"); + .unwrap_or_else(|e| env::panic_str(&e.to_string())); // Seed idle to cover payout c.idle_balance = 1_000; @@ -134,17 +134,17 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { fn execute_next_withdrawal_request_skips_holes() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); - let owner = c.own_get_owner().unwrap(); + let owner = c.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); setup_env(&vault_id, &owner, vec![]); println!("vault_id: {vault_id}"); println!("owner: {owner}"); // Bob gets 20 shares - c.deposit_unchecked(&owner, 20).unwrap(); + c.deposit_unchecked(&owner, 20).unwrap_or_else(|e| env::panic_str(&e.to_string())); // We fake by adding idle to the vault - c.transfer_unchecked(&owner, &vault_id, 10).unwrap(); - c.transfer_unchecked(&owner, &vault_id, 10).unwrap(); + c.transfer_unchecked(&owner, &vault_id, 10).unwrap_or_else(|e| env::panic_str(&e.to_string())); + c.transfer_unchecked(&owner, &vault_id, 10).unwrap_or_else(|e| env::panic_str(&e.to_string())); // Vault now has 20 assert_eq!(c.balance_of(&vault_id), 20); @@ -232,7 +232,7 @@ fn execute_supply_wrong_token_refunds_full() { fn set_withdraw_queue_must_include_all_enabled() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); - setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + setup_env(&vault_id, &c.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), vec![]); let m1 = mk(101); let m2 = mk(102); @@ -304,7 +304,7 @@ fn start_allocation_reserves_only_amount() { 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(), vec![]); + setup_env(&vault_id, &c.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), vec![]); // Supply queue has m1; stale plan points to m2 let m1 = mk(3001); @@ -337,7 +337,7 @@ fn queue_allocation_ignores_stale_plan() { fn set_withdraw_queue_disallow_nonzero_position_removal() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); - setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + setup_env(&vault_id, &c.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), vec![]); let m1 = mk(4001); @@ -520,7 +520,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { fn set_withdraw_queue_disallow_nonzero_cap_removal() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); - setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + setup_env(&vault_id, &c.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), vec![]); let m = mk(5000); let mut cfg = MarketConfiguration::default(); @@ -589,7 +589,7 @@ fn set_withdraw_queue_disallow_timelock_not_elapsed() { fn set_withdraw_queue_allows_zero_supply_removal() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); - setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); + setup_env(&vault_id, &c.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), vec![]); let m = mk(5003); let mut cfg = MarketConfiguration::default(); From 35bb60fc39568f4112fe2f701dfda517dafe065c Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 10:40:44 +0100 Subject: [PATCH 040/121] fix: account id has u32 prefix --- common/src/vault.rs | 64 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index bfcafcf6..1591a2c5 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -253,19 +253,19 @@ pub struct PendingWithdrawal { } impl PendingWithdrawal { - pub const fn encoded_size() -> u64 { - storage_bytes_for_account_id() - + storage_bytes_for_account_id() + pub const fn encoded_size() -> usize { + storage_bytes_for_account_id() as usize + + storage_bytes_for_account_id() as usize + 16 // escrow_shares: u128 + 16 // expected_assets: u128 - + 8 // requested_at: u64 - + 16 // deposit_yocto: u128 + + 8 // requested_at: u64 } } // Worst case size encoded for AccountId pub const fn storage_bytes_for_account_id() -> u64 { - AccountId::MAX_LEN as u64 + // 4 bytes for length prefix + worst case size encoded for AccountId + 4 + AccountId::MAX_LEN as u64 } #[near(event_json(standard = "templar-vault"))] @@ -484,3 +484,55 @@ pub enum Event { #[event_version("1.0.0")] DepositRejectedZeroAmount { sender: AccountId }, } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use borsh::BorshDeserialize; + use near_sdk::test_utils::accounts; + + use super::*; + + // Compile time checks + const _: [(); MarketConfiguration::encoded_size()] = [(); 25]; + const EXPECTED_FROM_TYPES: usize = + core::mem::size_of::() + core::mem::size_of::() + core::mem::size_of::(); + const _: [(); MarketConfiguration::encoded_size()] = [(); EXPECTED_FROM_TYPES]; + + #[test] + fn encoded_size_is_25() { + assert_eq!(MarketConfiguration::encoded_size(), 25); + } + + #[test] + fn encoded_size_market_matches_field_sizes() { + assert_eq!( + MarketConfiguration::encoded_size(), + borsh::to_vec(&MarketConfiguration::default()) + .unwrap() + .len(), + ); + } + + #[test] + fn encoded_size_pending_withdrawal_matches_field_sizes() { + // let 64 byte account id + let s = "abc1abc2abc3abc4abc5abc6abc7abc8abc9abc0abc1abc2abc3abc4abc5abc6"; + assert_eq!(s.len(), 64); + let account = AccountId::from_str(s).unwrap(); + assert_eq!(account.len(), 64); + assert_eq!( + borsh::to_vec(&PendingWithdrawal { + owner: account.clone(), + receiver: account.clone(), + escrow_shares: 3, + expected_assets: 4, + requested_at: 5 + }) + .unwrap() + .len(), + PendingWithdrawal::encoded_size() + ); + } +} From 153a9ac8a17f02416a3e0e6fa653c24cffdd8f75 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 11:00:37 +0100 Subject: [PATCH 041/121] chore: rename preconditions to policies --- contract/vault/src/lib.rs | 33 +++++++++++++++++---------------- contract/vault/src/tests.rs | 8 ++++---- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 28919c9c..1a000630 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -46,7 +46,7 @@ pub mod wad; mod test_utils; #[derive(Debug, Clone)] -#[near(serializers = [json, borsh])] +#[near(serializers = [borsh])] #[derive(BorshStorageKey)] /// Internal storage keys used by persistent collections. pub enum StorageKey { @@ -135,7 +135,6 @@ pub struct Contract { #[near] impl Contract { #[allow(clippy::unwrap_used, reason = "Infallible")] - #[allow(clippy::too_many_arguments, reason = "Constructor")] #[init] /// Initializes a new vault. /// - `owner_id`: account that controls Owner-only actions. @@ -453,14 +452,16 @@ impl Contract { // Pre-allocate a market_supply record (principal=0) so allocations don't create storage later self.market_supply.insert(market.clone(), 0); #[allow(clippy::unwrap_used, reason = "No side effects")] - self.config.get_mut(&market).unwrap_or_else(|| env::panic_str(&"Config not found after insert".to_string())) + self.config + .get_mut(&market) + .unwrap_or_else(|| env::panic_str(&"Config not found after insert".to_string())) } Some(config) => config, }; near_sdk::require!( self.pending_cap.get(&market).is.none(), - "Invariant violation: A cap change is already pending for this market" + "Policy violation: A cap change is already pending for this market" ); near_sdk::require!( config.removable_at == 0, @@ -502,7 +503,10 @@ impl Contract { ); #[allow(clippy::expect_used, reason = "No side effects")] - let cfg = self.config.get_mut(&market).unwrap_or_else(|| env::panic_str(&"Market not found".to_string())); + let cfg = self + .config + .get_mut(&market) + .unwrap_or_else(|| env::panic_str(&"Market not found".to_string())); cfg.cap = pending.value; if pending.value > 0 { @@ -670,17 +674,12 @@ impl Contract { for id in &queue { near_sdk::require!( self.config.get(id).is_some(), - "Invariant violation: Unknown market in new queue" + "Policy violation: Unknown market in new queue" ); } for (id, cfg) in self.config.iter() { let has_supply = *self.market_supply.get(id).unwrap_or(&0) > 0; - println!( - "ID: {}, Enabled: {}, Has Supply: {}, Removable At: {}", - id, cfg.enabled, has_supply, cfg.removable_at - ); - if (cfg.enabled || has_supply) && !seen.contains(id) { if current.contains(id) { // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. @@ -690,16 +689,16 @@ impl Contract { ); near_sdk::require!( self.pending_cap.get(id).is_none(), - "Invariant violation: Cannot remove market with pending cap change" + "Policy violation: Cannot remove market with pending cap change" ); if has_supply { near_sdk::require!( cfg.removable_at > 0, - "Invariant violation: Market still has supply but no removal scheduled" + "Policy violation: Market still has supply but no removal scheduled" ); near_sdk::require!( env::block_timestamp() >= cfg.removable_at, - "Invariant violation: Removal timelock not elapsed for market" + "Policy violation: Removal timelock not elapsed for market" ); } } else { @@ -897,7 +896,9 @@ impl Contract { pub fn get_configuration(&self) -> VaultConfiguration { let timelock_sec = self.timelock_ns / 1_000_000_000; VaultConfiguration { - owner: self.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + owner: self + .own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), curator: Self::with_members_of(&Role::Curator, |members| { near_sdk::require!( members.len() == 1, @@ -1181,7 +1182,7 @@ impl Contract { near_sdk::require!( amount <= self.idle_balance, - "Invariant Violation: reserve amount must be <= idle_balance" + "Policy violation: reserve amount must be <= idle_balance" ); self.idle_balance -= amount; diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 070bb0d9..cab7d94f 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -516,7 +516,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { } #[test] -#[should_panic = "Invariant violation: Cannot remove market with non-zero cap"] +#[should_panic = "Policy violation: Cannot remove market with non-zero cap"] fn set_withdraw_queue_disallow_nonzero_cap_removal() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); @@ -534,7 +534,7 @@ fn set_withdraw_queue_disallow_nonzero_cap_removal() { } #[test] -#[should_panic = "Invariant violation: Cannot remove market with pending cap change"] +#[should_panic = "Policy violation: Cannot remove market with pending cap change"] fn set_withdraw_queue_disallow_pending_cap_removal() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); @@ -562,7 +562,7 @@ fn set_withdraw_queue_disallow_pending_cap_removal() { } #[test] -#[should_panic = "Invariant violation: Removal timelock not elapsed for market"] +#[should_panic = "Policy violation: Removal timelock not elapsed for market"] fn set_withdraw_queue_disallow_timelock_not_elapsed() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); @@ -615,7 +615,7 @@ fn set_withdraw_queue_allows_zero_supply_removal() { } #[test] -#[should_panic = "Invariant violation: Unknown market in new queue"] +#[should_panic = "Policy violation: Unknown market in new queue"] fn set_withdraw_queue_rejects_unknown_market() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); From 5d0f40a9271d702a0cb38c08a8da24974273113e Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 11:59:36 +0100 Subject: [PATCH 042/121] chore: requires --- common/src/vault.rs | 7 +- contract/vault/src/lib.rs | 82 ++++++++++++------------ contract/vault/src/storage_management.rs | 2 +- contract/vault/src/test_utils.rs | 4 +- 4 files changed, 45 insertions(+), 50 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 1591a2c5..e43733cb 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -279,14 +279,9 @@ pub enum Event { #[event_version("1.0.0")] AllocationRequestedQueue { op_id: u64, total: U128 }, #[event_version("1.0.0")] - AllocationRequestedWeighted { - op_id: u64, - total: U128, - weights: Vec<(AccountId, U128)>, - }, - #[event_version("1.0.0")] AllocationPlanSet { op_id: u64, + total: U128, plan: Vec<(AccountId, U128)>, }, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 1a000630..6565578a 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1,10 +1,12 @@ #![allow(clippy::needless_pass_by_value)] +use std::collections::{HashMap, HashSet}; + use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, json_types::U128, - near, serde_json, + near, require, serde_json, store::{IterableMap, LookupMap, Vector}, AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, }; @@ -162,7 +164,7 @@ impl Contract { } = configuration; let timelock_ns = u64::from(initial_timelock_sec) * 1_000_000_000; - near_sdk::require!( + require!( (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&timelock_ns), "timelock bounds" ); @@ -230,11 +232,11 @@ impl Contract { pub fn set_curator(&mut self, account: AccountId) { Self::require_owner(); Self::with_members_of(&Role::Curator, |members| { - near_sdk::require!( + require!( members.len() < 2, "Invariant violation: Cannot Have more than 1 Curator" ); - near_sdk::require!( + require!( !members.contains(&account), "Curator already set to this account" ); @@ -273,14 +275,14 @@ impl Contract { let mut guardian_occupied = false; Self::with_members_of(&Role::Guardian, |members| { - near_sdk::require!( + require!( members.len() < 2, "Invariant violation: Cannot Have more than 1 Guardian" ); - near_sdk::require!(!members.contains(&new_g), "Already set to this address"); + require!(!members.contains(&new_g), "Already set to this address"); guardian_occupied = !members.is_empty(); }); - near_sdk::require!( + require!( self.pending_guardian.is_none(), "Guardian change already pending" ); @@ -302,7 +304,7 @@ impl Contract { let p = self.pending_guardian.clone(); if let Some(p) = &p { - near_sdk::require!(env::block_timestamp() >= p.valid_at, "not yet"); + require!(env::block_timestamp() >= p.valid_at, "not yet"); Self::with_members_of(&Role::Guardian, |members| { members.iter().for_each(|m| { self.remove_role(&m, &Role::Guardian); @@ -322,7 +324,7 @@ impl Contract { /// Sets the recipient account for skimmed tokens. pub fn set_skim_recipient(&mut self, account: AccountId) { Self::require_owner(); - near_sdk::require!( + require!( account != self.skim_recipient, "Already set to this address" ); @@ -336,7 +338,7 @@ impl Contract { /// Sets the performance fee recipient. Accrues pending fees with the current recipient first. pub fn set_fee_recipient(&mut self, account: AccountId) { Self::require_owner(); - near_sdk::require!(account != self.fee_recipient, "Already set to this address"); + require!(account != self.fee_recipient, "Already set to this address"); if self.performance_fee != 0 { // Accrue any pending fees to current recipient before changing (so current recipient gets up to now) @@ -355,9 +357,9 @@ impl Contract { let fee: u128 = fee.into(); - near_sdk::require!(fee != self.performance_fee, "Fee already set to this value"); + require!(fee != self.performance_fee, "Fee already set to this value"); // FIXME: dynamic based on underlying - near_sdk::require!(fee <= (wad::WAD / 10), "fee too high"); + require!(fee <= (wad::WAD / 10), "fee too high"); // Accrue any pending fees with old rate before changing self.internal_accrue_fee(); @@ -372,12 +374,12 @@ impl Contract { Self::require_owner(); let as_nanos = u64::from(new_timelock_secs) * 1_000_000_000; - near_sdk::require!(as_nanos != self.timelock_ns, "Already set to this value"); - near_sdk::require!( + require!(as_nanos != self.timelock_ns, "Already set to this value"); + require!( self.pending_timelock.is_none(), "Timelock change already pending" ); - near_sdk::require!( + require!( (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&as_nanos), "Timelock out of bounds" ); @@ -405,7 +407,7 @@ impl Contract { pub fn accept_timelock(&mut self) { Self::require_owner(); if let Some(p) = &self.pending_timelock { - near_sdk::require!( + require!( env::block_timestamp() >= p.valid_at, "Timelock not elapsed yet" ); @@ -459,16 +461,16 @@ impl Contract { Some(config) => config, }; - near_sdk::require!( - self.pending_cap.get(&market).is.none(), + require!( + self.pending_cap.get(&market).is_none(), "Policy violation: A cap change is already pending for this market" ); - near_sdk::require!( + require!( config.removable_at == 0, "Market removal pending, cannot change cap" ); let new_cap = new_cap.0; - near_sdk::require!(new_cap != config.cap, "New cap is same as current"); + require!(new_cap != config.cap, "New cap is same as current"); if new_cap < config.cap { // If lowering the cap, we can apply the delta immediately @@ -497,7 +499,7 @@ impl Contract { Self::assert_curator_or_owner(); self.ensure_idle(); if let Some(pending) = self.pending_cap.get(&market) { - near_sdk::require!( + require!( env::block_timestamp() >= pending.valid_at, "Timelock not elapsed for cap change" ); @@ -584,11 +586,11 @@ impl Contract { cfg.removable_at == 0, "Removal already pending for this market" ); - assert!( + require!( cfg.cap == 0, "Cannot remove market with non-zero cap (disable deposits first)" ); - assert!(cfg.enabled, "Market not enabled or already removed"); + require!(cfg.enabled, "Market not enabled or already removed"); assert!( self.pending_cap.get(&market).is_none(), "Cap change pending for this market" @@ -615,10 +617,10 @@ impl Contract { pub fn set_supply_queue(&mut self, markets: Vec) { Self::assert_allocator(); self.ensure_idle(); - near_sdk::require!(markets.len() <= MAX_QUEUE_LEN, "too long"); + require!(markets.len() <= MAX_QUEUE_LEN, "too long"); // Invariant: supply_queue has no duplicates; allocation order remains meaningful - let mut seen = std::collections::HashSet::new(); + let mut seen = HashSet::new(); for m in &markets { if !seen.insert(m.clone()) { env::panic_str(&format!("Duplicate market {m}")); @@ -627,12 +629,11 @@ impl Contract { // Validate all markets are authorized (cap > 0) before charging storage for m in &markets { let cap = self.config.get(m).map_or(0, |c| c.cap); - near_sdk::require!(cap > 0, "unauthorized market"); + require!(cap > 0, "unauthorized market"); } // Compute and require storage for additions (no refunds for removals in this pass) - let current: std::collections::HashSet = - self.supply_queue.iter().cloned().collect(); + let current: HashSet = self.supply_queue.iter().cloned().collect(); let required_yocto = storage_management::yocto_for_queue_additions(¤t, &markets); require_attached_at_least(required_yocto, "supply queue update"); @@ -655,12 +656,12 @@ impl Contract { pub fn set_withdraw_queue(&mut self, queue: Vec) { Self::assert_allocator(); self.ensure_idle(); - near_sdk::require!( + require!( queue.len() <= MAX_QUEUE_LEN, "Withdraw queue length exceeds max" ); - let mut seen = std::collections::HashSet::new(); + let mut seen = HashSet::new(); for id in &queue { if !seen.insert(id.clone()) { env::panic_str(&format!("Duplicate market {id}")); @@ -668,11 +669,10 @@ impl Contract { } // Snapshot current withdraw queue into a set for membership checks - let current: std::collections::HashSet = - self.withdraw_queue.iter().cloned().collect(); + let current: HashSet = self.withdraw_queue.iter().cloned().collect(); for id in &queue { - near_sdk::require!( + require!( self.config.get(id).is_some(), "Policy violation: Unknown market in new queue" ); @@ -683,20 +683,20 @@ impl Contract { if (cfg.enabled || has_supply) && !seen.contains(id) { if current.contains(id) { // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. - near_sdk::require!( + require!( cfg.cap == 0, - "Invariant violation: Cannot remove market with non-zero cap" + "Policy violation: Cannot remove market with non-zero cap" ); - near_sdk::require!( + require!( self.pending_cap.get(id).is_none(), "Policy violation: Cannot remove market with pending cap change" ); if has_supply { - near_sdk::require!( + require!( cfg.removable_at > 0, "Policy violation: Market still has supply but no removal scheduled" ); - near_sdk::require!( + require!( env::block_timestamp() >= cfg.removable_at, "Policy violation: Removal timelock not elapsed for market" ); @@ -900,14 +900,14 @@ impl Contract { .own_get_owner() .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), curator: Self::with_members_of(&Role::Curator, |members| { - near_sdk::require!( + require!( members.len() == 1, "Invariant violation: Cannot Have more than 1 Curator" ); members.iter().next().expect("Curator not set").clone() }), guardian: Self::with_members_of(&Role::Guardian, |members| { - near_sdk::require!( + require!( members.len() == 1, "Invariant violation: Cannot Have more than 1 Guardian" ); @@ -1180,7 +1180,7 @@ impl Contract { } self.ensure_idle(); - near_sdk::require!( + require!( amount <= self.idle_balance, "Policy violation: reserve amount must be <= idle_balance" ); diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 39bb7d46..873e0baf 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -42,7 +42,7 @@ pub fn storage_bytes_for_pending_cap_entry() -> u64 { pub fn storage_bytes_for_pending_withdrawal() -> u64 { // Key is u64 id -> 8 bytes let key = 8u64; - let val = PendingWithdrawal::encoded_size(); + let val = PendingWithdrawal::encoded_size() as u64; MAP_ENTRY_OVERHEAD + key + val } diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 7b9400b4..83277c24 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -1,6 +1,6 @@ use crate::Contract; -use near_sdk::NearToken; use near_sdk::env; +use near_sdk::NearToken; pub use near_sdk::{ test_utils::{accounts, VMContextBuilder}, test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, @@ -9,7 +9,7 @@ use near_sdk_contract_tools::ft::Nep141Controller as _; use test_utils::vault_configuration; pub fn mk(n: u32) -> AccountId { - format!("acc{n}.testnet").parse().unwrap_or_else(|e| env::panic_str(&e.to_string())) + format!("acc{n}.testnet").parse().unwrap() } pub fn setup_env( From e0517a9ffeb9d496b3b0293cfd38cafd9d7f5a61 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 11:59:48 +0100 Subject: [PATCH 043/121] fix: gas --- contract/vault/src/storage_management.rs | 6 +++--- test-utils/src/controller/vault.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 873e0baf..4b4d6eac 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -1,5 +1,5 @@ use crate::PendingWithdrawal; -use near_sdk::{env, AccountId}; +use near_sdk::{env, require, AccountId}; use std::collections::HashSet; use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; @@ -78,9 +78,9 @@ pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId] #[must_use] pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { let attached = env::attached_deposit().as_yoctonear(); - near_sdk::require!( + require!( attached >= required_yocto, - "Insufficient storage deposit for {ctx}: required {required_yocto}, attached {attached}" + format!("Insufficient storage deposit for {ctx}: required {required_yocto}, attached {attached}") ); required_yocto } diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 1b7aad0e..c662d941 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -98,7 +98,7 @@ impl VaultController { #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(4650000000000000000000)))] pub fn submit_cap(market: AccountId, new_cap: U128); - #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(800000000000000000000)))] + #[call(exec, tgas(5), deposit(NearToken::from_yoctonear(840000000000000000000)))] pub fn accept_cap(market: AccountId); #[call(exec, tgas(5))] @@ -143,7 +143,7 @@ impl VaultController { #[call(exec, tgas(50))] pub fn revoke_pending_timelock(); - #[call(exec, tgas(50), deposit(NearToken::from_yoctonear(800000000000000000000)))] + #[call(exec, tgas(50), deposit(NearToken::from_yoctonear(840000000000000000000)))] pub fn set_supply_queue(markets: Vec); #[call(exec, tgas(50))] From bf29918f6373bae5cd3f25af3ecebf2e22f7c62c Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 11:59:56 +0100 Subject: [PATCH 044/121] chore: golf --- contract/vault/src/lib.rs | 88 ++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 53 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 6565578a..a7aeeb26 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -358,7 +358,6 @@ impl Contract { let fee: u128 = fee.into(); require!(fee != self.performance_fee, "Fee already set to this value"); - // FIXME: dynamic based on underlying require!(fee <= (wad::WAD / 10), "fee too high"); // Accrue any pending fees with old rate before changing @@ -582,7 +581,7 @@ impl Contract { .config .get_mut(&market) .unwrap_or_else(|| env::panic_str("unknown market")); - assert!( + require!( cfg.removable_at == 0, "Removal already pending for this market" ); @@ -591,7 +590,7 @@ impl Contract { "Cannot remove market with non-zero cap (disable deposits first)" ); require!(cfg.enabled, "Market not enabled or already removed"); - assert!( + require!( self.pending_cap.get(&market).is_none(), "Cap change pending for this market" ); @@ -803,6 +802,9 @@ impl Contract { ) } + /// Allocates assets across markets according to the provided weights. + /// If `amount` is provided, it is used as the target amount for each market. + /// Otherwise, the vault will attempt to allocate as much as possible. #[payable] pub fn allocate( &mut self, @@ -813,20 +815,20 @@ impl Contract { Self::assert_allocator(); self.ensure_idle(); - // Require storage deposit up-front for any markets that may be added to withdraw_queue - let existing: std::collections::HashSet = - self.withdraw_queue.iter().cloned().collect(); + let existing: HashSet = self.withdraw_queue.iter().cloned().collect(); + let candidates: Vec = if weights.is_empty() { self.supply_queue.iter().cloned().collect() } else { weights.iter().map(|(m, _)| m.clone()).collect() }; + let required_yocto = storage_management::yocto_for_queue_additions(&existing, &candidates); require_attached_at_least(required_yocto, "potential queue additions"); - // If no weights provided, use queue order; clamp total and emit request event. + let total = self.clamp_allocation_total(amount.map(|x| x.0)); + if weights.is_empty() { - let total = self.clamp_allocation_total(amount.map(|x| x.0)); if total == 0 { return self.stop_and_exit(Some(&Error::ZeroAmount)); } @@ -837,55 +839,35 @@ impl Contract { } .emit(); self.plan = None; - self.start_allocation(total) - } else { - // Validate unique markets and accumulate weight sum - let mut seen = std::collections::HashSet::new(); - let mut sum_w: u128 = 0; - - for (m, w) in &weights { - if !seen.insert(m.clone()) { - env::panic_str(&format!("Duplicate market in weights: {m}")); - } - sum_w = sum_w.saturating_add(u128::from(*w)); - } - if sum_w == 0 { - env::panic_str("Sum of weights is zero"); - } - - // Clamp total allocation by idle balance and aggregate room - let total = self.clamp_allocation_total(amount.map(|x| x.0)); - if total == 0 { - env::panic_str("No funds to allocate"); - } + return self.start_allocation(total); + } - // Emit request and plan events - let op_id = self.next_op_id; - let weights_for_event: Vec<(AccountId, U128)> = weights - .iter() - .map(|(m, w)| (m.clone(), U128((*w).into()))) - .collect(); - Event::AllocationRequestedWeighted { - op_id, - total: U128(total), - weights: weights_for_event.clone(), - } - .emit(); - Event::AllocationPlanSet { - op_id, - plan: weights_for_event, - } - .emit(); + // Non-empty weights: validate and build plan. + let weights = weights + .into_iter() + .map(|(m, w)| (m, u128::from(w))) + .collect::>(); - // Store an ephemeral plan of (market, weight) to drive weighted allocation. - let plan: AllocationPlan = 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"); + } - self.plan = Some(plan); - self.start_allocation(total) + 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, + total: U128(total), + plan: weights_for_event, } + .emit(); + self.plan = Some(weights.into_iter().collect()); + + self.start_allocation(total) } } From 3211abe074750ac55e6b6eb9153df42a8ac15279 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 12:03:27 +0100 Subject: [PATCH 045/121] chore: docstring --- common/src/vault.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index e43733cb..2a45650a 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -50,37 +50,50 @@ pub enum DepositMsg { Supply, } +/// Confrete configuration for a market. #[derive(Clone, Default)] #[near] pub struct MarketConfiguration { - // Supply cap for this market (in underlying asset units) + /// Supply cap for this market (in underlying asset units) pub cap: u128, - // Whether market is enabled for deposits/withdrawals + /// Whether market is enabled for deposits/withdrawals pub enabled: bool, - // Timestamp (ns) after which market can be removed (if pending removal) + /// Timestamp (ns) after which market can be removed (if pending removal) pub removable_at: TimestampNs, } impl MarketConfiguration { + /// Size of the market configuration in borsh encoded bytes. pub const fn encoded_size() -> usize { 16 + 1 + 8 } } +/// Configuration for the setup of a metavault. #[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]. pub curator: AccountId, + /// The account that can set guardianship. See [AllocationMode]. pub guardian: AccountId, + /// The underlying asset for this vault. pub underlying_token: FungibleAsset, + /// The initial timelock for this vault used for modifying the configuration. pub initial_timelock_sec: u32, + /// The account that receives fees for this vault. pub fee_recipient: AccountId, + /// The skim account that can unorphan any assets erroneously sent to this vault. pub skim_recipient: AccountId, + /// The name of the share token. pub name: String, + /// The symbol of the share token. pub symbol: String, - // TODO: decide if should assert decimals as underlying + /// The number of decimals for the share token, usually would be the same as the underlying asset. pub decimals: u8, } From f74a4d567adb9937b5635373e593739c11333c12 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 12:19:52 +0100 Subject: [PATCH 046/121] chore: json types for cap --- common/src/vault.rs | 8 ++- contract/vault/src/impl_token_receiver.rs | 4 +- contract/vault/src/lib.rs | 34 +++++----- contract/vault/src/test_utils.rs | 3 +- contract/vault/src/tests.rs | 77 ++++++++++++++++------- test-utils/src/lib.rs | 4 +- 6 files changed, 83 insertions(+), 47 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 2a45650a..80fc383b 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -1,3 +1,5 @@ +use std::num::NonZeroU8; + use near_sdk::{env, json_types::U128, near, require, AccountId, Gas, Promise, PromiseOrValue}; use crate::asset::{BorrowAsset, FungibleAsset}; @@ -37,7 +39,7 @@ pub enum AllocationMode { /// 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 }, + Eager { min_batch: U128 }, #[default] Lazy, } @@ -55,7 +57,7 @@ pub enum DepositMsg { #[near] pub struct MarketConfiguration { /// Supply cap for this market (in underlying asset units) - pub cap: u128, + pub cap: U128, /// Whether market is enabled for deposits/withdrawals pub enabled: bool, /// Timestamp (ns) after which market can be removed (if pending removal) @@ -94,7 +96,7 @@ pub struct VaultConfiguration { /// The symbol of the share token. pub symbol: String, /// The number of decimals for the share token, usually would be the same as the underlying asset. - pub decimals: u8, + pub decimals: NonZeroU8, } #[near_sdk::ext_contract(ext_vault)] diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 7bbc904b..f4a95d64 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -125,13 +125,13 @@ impl Contract { 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 { + 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, idle_balance: U128(self.idle_balance), - min_batch: U128(min_batch), + min_batch, deposit_accepted: U128(accept), } .emit(); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index a7aeeb26..5b06201f 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1,6 +1,9 @@ #![allow(clippy::needless_pass_by_value)] -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + num::NonZeroU8, +}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ @@ -219,7 +222,7 @@ impl Contract { next_withdraw_id: 0, next_withdraw_to_execute: 0, }; - contract.set_metadata(&ContractMetadata::new(name, symbol, decimals)); + contract.set_metadata(&ContractMetadata::new(name, symbol, decimals.into())); Owner::init(&mut contract, &owner); Rbac::add_role(&mut contract, &curator, &Role::Curator); Rbac::add_role(&mut contract, &curator, &Role::Allocator); @@ -436,7 +439,7 @@ impl Contract { if self.config.get(&market).is_none() { required_deposit = required_deposit.saturating_add(yocto_for_new_market()); } - let current_cap = self.config.get(&market).map_or(0, |c| c.cap); + let current_cap = self.config.get(&market).map_or(0, |c| c.cap.0); if new_cap.0 > current_cap { required_deposit = required_deposit.saturating_add(yocto_for_pending_cap()); } @@ -468,7 +471,6 @@ impl Contract { config.removable_at == 0, "Market removal pending, cannot change cap" ); - let new_cap = new_cap.0; require!(new_cap != config.cap, "New cap is same as current"); if new_cap < config.cap { @@ -479,13 +481,13 @@ impl Contract { self.pending_cap.insert( market.clone(), PendingValue { - value: new_cap, + value: new_cap.0, valid_at, }, ); Event::SupplyCapRaiseSubmitted { market: market.clone(), - new_cap: U128(new_cap), + new_cap: new_cap, valid_at, } .emit(); @@ -509,7 +511,7 @@ impl Contract { .get_mut(&market) .unwrap_or_else(|| env::panic_str(&"Market not found".to_string())); - cfg.cap = pending.value; + cfg.cap = pending.value.into(); if pending.value > 0 { // If enabling or raising cap above 0, mark enabled and add to withdraw_queue if not already present if !cfg.enabled { @@ -586,7 +588,7 @@ impl Contract { "Removal already pending for this market" ); require!( - cfg.cap == 0, + cfg.cap.0 == 0, "Cannot remove market with non-zero cap (disable deposits first)" ); require!(cfg.enabled, "Market not enabled or already removed"); @@ -627,7 +629,7 @@ impl Contract { } // Validate all markets are authorized (cap > 0) before charging storage for m in &markets { - let cap = self.config.get(m).map_or(0, |c| c.cap); + let cap = self.config.get(m).map_or(0, |c| c.cap.into()); require!(cap > 0, "unauthorized market"); } @@ -683,7 +685,7 @@ impl Contract { if current.contains(id) { // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. require!( - cfg.cap == 0, + cfg.cap.0 == 0, "Policy violation: Cannot remove market with non-zero cap" ); require!( @@ -901,7 +903,7 @@ impl Contract { skim_recipient: self.skim_recipient.clone(), name: self.get_metadata().name, symbol: self.get_metadata().symbol, - decimals: self.get_metadata().decimals, + decimals: NonZeroU8::new(self.get_metadata().decimals).unwrap(), mode: self.mode.clone(), } } @@ -926,10 +928,10 @@ impl Contract { let mut total = 0u128; self.supply_queue.iter().for_each(|m| { if let Some(cfg) = self.config.get(m) { - if cfg.cap > 0 { + if cfg.cap.0 > 0 { let cur = self.market_supply.get(m).unwrap_or(&0); - if cfg.cap > *cur { - total += cfg.cap - cur; + if cfg.cap.0 > *cur { + total += cfg.cap.0 - cur; } } } @@ -1216,7 +1218,7 @@ impl Contract { mul_div_floor(remaining, *weight, sum_w) }; - let cap = self.config.get(&market_id).map_or(0, |c| c.cap); + let cap = self.config.get(&market_id).map_or(0, |c| c.cap.0); let cur = *self.market_supply.get(&market_id).unwrap_or(&0); let room = cap.saturating_sub(cur); let to_supply = room.min(target); @@ -1281,7 +1283,7 @@ impl Contract { } if let Some(market) = self.supply_queue.get(index) { - let cap = self.config.get(market).map_or(0, |c| c.cap); + let cap = self.config.get(market).map_or(0, |c| c.cap.0); let cur = self.market_supply.get(market).unwrap_or(&0); let room = cap.saturating_sub(*cur); let to_supply = room.min(remaining); diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 83277c24..52ff34b6 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -6,6 +6,7 @@ pub use near_sdk::{ test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, }; use near_sdk_contract_tools::ft::Nep141Controller as _; +use templar_common::primitive_types::U128; use test_utils::vault_configuration; pub fn mk(n: u32) -> AccountId { @@ -83,7 +84,7 @@ pub fn ensure_market( removable_at: u64, ) { let mut cfg = templar_common::vault::MarketConfiguration::default(); - cfg.cap = cap; + cfg.cap = near_sdk::json_types::U128(cap); cfg.enabled = enabled; cfg.removable_at = removable_at; c.config.insert(id.clone(), cfg); diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index cab7d94f..89a077e8 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -66,7 +66,8 @@ fn fee_accrues_only_on_growth_unit() { // Seed total supply so fees can mint let user = accounts(1); - c.deposit_unchecked(&user, 1_000).unwrap_or_else(|e| env::panic_str(&e.to_string())); + c.deposit_unchecked(&user, 1_000) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); c.idle_balance = 1_000; // Set fee to 10% @@ -134,17 +135,22 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { fn execute_next_withdrawal_request_skips_holes() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); - let owner = c.own_get_owner().unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + let owner = c + .own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); setup_env(&vault_id, &owner, vec![]); println!("vault_id: {vault_id}"); println!("owner: {owner}"); // Bob gets 20 shares - c.deposit_unchecked(&owner, 20).unwrap_or_else(|e| env::panic_str(&e.to_string())); + c.deposit_unchecked(&owner, 20) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); // We fake by adding idle to the vault - c.transfer_unchecked(&owner, &vault_id, 10).unwrap_or_else(|e| env::panic_str(&e.to_string())); - c.transfer_unchecked(&owner, &vault_id, 10).unwrap_or_else(|e| env::panic_str(&e.to_string())); + c.transfer_unchecked(&owner, &vault_id, 10) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + c.transfer_unchecked(&owner, &vault_id, 10) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); // Vault now has 20 assert_eq!(c.balance_of(&vault_id), 20); @@ -232,7 +238,12 @@ fn execute_supply_wrong_token_refunds_full() { fn set_withdraw_queue_must_include_all_enabled() { 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".to_string())), vec![]); + setup_env( + &vault_id, + &c.own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + vec![], + ); let m1 = mk(101); let m2 = mk(102); @@ -256,7 +267,7 @@ fn start_allocation_reserves_only_amount() { // Configure a single market with cap = 80 in the supply queue let m1 = mk(2000); let mut cfg = MarketConfiguration::default(); - cfg.cap = 80; + cfg.cap = U128(80); cfg.enabled = true; c.config.insert(m1.clone(), cfg); c.supply_queue.push(m1.clone()); @@ -304,14 +315,19 @@ fn start_allocation_reserves_only_amount() { 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".to_string())), vec![]); + setup_env( + &vault_id, + &c.own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + vec![], + ); // Supply queue has m1; stale plan points to m2 let m1 = mk(3001); let m2 = mk(3002); let mut cfg1 = MarketConfiguration::default(); - cfg1.cap = 10; + cfg1.cap = U128(10); cfg1.enabled = true; c.config.insert(m1.clone(), cfg1); c.withdraw_queue.push(m1.clone()); @@ -337,12 +353,17 @@ fn queue_allocation_ignores_stale_plan() { fn set_withdraw_queue_disallow_nonzero_position_removal() { 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".to_string())), vec![]); + setup_env( + &vault_id, + &c.own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + vec![], + ); let m1 = mk(4001); let mut cfg = MarketConfiguration::default(); - cfg.cap = 0; // required precondition to attempt removal + cfg.cap = U128(0); // required precondition to attempt removal cfg.enabled = true; c.config.insert(m1.clone(), cfg); @@ -413,7 +434,7 @@ fn removing_holding_market_hides_assets_and_leaves_orphan_supply() { // Market is known, holding > 0, with cap=0 and removal already scheduled. // This satisfies current preconditions in set_withdraw_queue for omission. let mut cfg = MarketConfiguration::default(); - cfg.cap = 0; + cfg.cap = U128(0); cfg.enabled = true; cfg.removable_at = 1; // scheduled in the past relative to the block timestamp we set below c.config.insert(m.clone(), cfg); @@ -456,14 +477,14 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { // Seed a known, enabled market with cap > 0 let mut cfg = MarketConfiguration::default(); - cfg.cap = 10; + cfg.cap = U128(10); cfg.enabled = true; c.config.insert(m.clone(), cfg); // Lower cap to zero: should NOT disable the market anymore c.submit_cap(m.clone(), U128(0)); let cfg_after = c.config.get(&m).expect("market must exist"); - assert_eq!(cfg_after.cap, 0, "cap must be updated to 0"); + assert_eq!(cfg_after.cap.0, 0, "cap must be updated to 0"); assert!(cfg_after.enabled, "enabled must remain true when cap is 0"); set_block_ts(&vault_id, &owner, 2); @@ -501,7 +522,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { c.accept_cap(m.clone()); let cfg1 = c.config.get(&m).unwrap(); - assert_eq!(cfg1.cap, raise); + assert_eq!(cfg1.cap.0, raise); assert!(cfg1.enabled, "market should be enabled after raise"); assert!( c.withdraw_queue.iter().any(|x| x == &m), @@ -511,7 +532,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { // Now lower back to 0 (immediate path) and ensure enabled stays true c.submit_cap(m.clone(), U128(0)); let cfg2 = c.config.get(&m).unwrap(); - assert_eq!(cfg2.cap, 0); + assert_eq!(cfg2.cap.0, 0); assert!(cfg2.enabled, "enabled must remain true on cap=0"); } @@ -520,11 +541,16 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { fn set_withdraw_queue_disallow_nonzero_cap_removal() { 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".to_string())), vec![]); + setup_env( + &vault_id, + &c.own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + vec![], + ); let m = mk(5000); let mut cfg = MarketConfiguration::default(); - cfg.cap = 1; // non-zero cap + cfg.cap = U128(1); // non-zero cap cfg.enabled = true; // must be enabled or holding to trigger invariant c.config.insert(m.clone(), cfg); c.withdraw_queue.push(m.clone()); @@ -543,7 +569,7 @@ fn set_withdraw_queue_disallow_pending_cap_removal() { let m = mk(5001); let mut cfg = MarketConfiguration::default(); - cfg.cap = 0; + cfg.cap = U128(0); cfg.enabled = true; c.config.insert(m.clone(), cfg); c.withdraw_queue.push(m.clone()); @@ -571,7 +597,7 @@ fn set_withdraw_queue_disallow_timelock_not_elapsed() { let m = mk(5002); let mut cfg = MarketConfiguration::default(); - cfg.cap = 0; + cfg.cap = U128(0); cfg.enabled = true; cfg.removable_at = 10; // in the future relative to block timestamp we set below c.config.insert(m.clone(), cfg); @@ -589,11 +615,16 @@ fn set_withdraw_queue_disallow_timelock_not_elapsed() { fn set_withdraw_queue_allows_zero_supply_removal() { 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".to_string())), vec![]); + setup_env( + &vault_id, + &c.own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + vec![], + ); let m = mk(5003); let mut cfg = MarketConfiguration::default(); - cfg.cap = 0; + cfg.cap = U128(0); cfg.enabled = true; // removable_at irrelevant when supply is zero c.config.insert(m.clone(), cfg); @@ -710,7 +741,7 @@ fn clamp_allocation_total_matches_min_bounds_cases( let m = mk(1); let mut cfg = MarketConfiguration::default(); - cfg.cap = cap; + cfg.cap = U128(cap); cfg.enabled = cap > 0; c.config.insert(m.clone(), cfg); c.market_supply.insert(m.clone(), cur); diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index dfc588ae..aaa49665 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -1,4 +1,4 @@ -use std::{path::Path, str::FromStr}; +use std::{num::NonZero, path::Path, str::FromStr}; use crate::controller::vault::{UnifiedVaultController, VaultController}; pub use controller::{ @@ -184,7 +184,7 @@ pub fn vault_configuration( skim_recipient: skim_recipient_id, name: "Vault".to_string(), symbol: "VAULT".to_string(), - decimals: 24, + decimals: NonZero::new(24).unwrap(), mode: templar_common::vault::AllocationMode::Lazy, } } From 9aad95a948f029d79edbb2922e8e7686945a6a2a Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 17 Oct 2025 12:31:18 +0100 Subject: [PATCH 047/121] chore: type for escrowsettlement --- contract/vault/src/impl_callbacks.rs | 19 ++++++++++++------- contract/vault/src/lib.rs | 9 +++++++-- contract/vault/src/tests.rs | 12 +++++++++--- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index d0c22613..b221490d 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -1,6 +1,8 @@ use std::fmt::Display; -use crate::{ext_self, near, Contract, ContractExt, Error, Nep141Controller, OpState}; +use crate::{ + ext_self, near, Contract, ContractExt, Error, EscrowSettlement, Nep141Controller, OpState, +}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{env, json_types::U128, AccountId, NearToken, PromiseError, PromiseOrValue}; use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; @@ -389,15 +391,15 @@ impl Contract { // Invariant: On payout success, idle_balance -= payout_amount. // Burn only the proportional shares and refund the remainder to the owner. self.idle_balance = self.idle_balance.saturating_sub(payout_amount); - let (to_burn, refund_shares) = + let EscrowSettlement { to_burn, refund } = Self::compute_escrow_settlement(escrow_shares, burn_shares); if to_burn > 0 { self.withdraw_unchecked(&env::current_account_id(), to_burn) .unwrap_or_else(|e| env::panic_str(&e.to_string())); } - if refund_shares > 0 { + if refund > 0 { #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&env::current_account_id(), &owner, refund_shares) + self.transfer_unchecked(&env::current_account_id(), &owner, refund) .unwrap_or_else(|e| env::panic_str(&e.to_string())); } self.op_state = OpState::Idle; @@ -684,7 +686,8 @@ mod tests { &vault_id, &vault_id, vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + near_sdk::serde_json::to_vec(&U128(u128::MAX)) + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), )], ); @@ -707,7 +710,8 @@ mod tests { &vault_id, &vault_id, vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + near_sdk::serde_json::to_vec(&U128(u128::MAX)) + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), )], ); @@ -735,7 +739,8 @@ mod tests { &vault_id, &vault_id, vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)).unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + near_sdk::serde_json::to_vec(&U128(u128::MAX)) + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), )], ); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 5b06201f..d2b046d9 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1004,6 +1004,11 @@ impl Contract { } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct EscrowSettlement { + pub to_burn: u128, + pub refund: u128, +} /* ----- Private Helpers ----- */ impl Contract { /// Enqueue a vault-level pending withdrawal request (escrow already taken). @@ -1096,10 +1101,10 @@ impl Contract { pub(crate) fn compute_escrow_settlement( escrow_shares: u128, burn_shares: u128, - ) -> (u128 /* to_burn */, u128 /* refund */) { + ) -> EscrowSettlement { let to_burn = burn_shares.min(escrow_shares); let refund = escrow_shares.saturating_sub(to_burn); - (to_burn, refund) + EscrowSettlement { to_burn, refund } } /* ----- Internal: fee, shares ----- */ diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 89a077e8..7f3209ac 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -412,15 +412,21 @@ fn compute_effective_totals_fee_share_and_virtuals() { assert_eq!(nta, cur + va); } +#[test] #[test] fn compute_escrow_settlement_burns_min_and_refunds_rest() { let vault_id = accounts(0); setup_env(&vault_id, &vault_id, vec![]); let c = new_test_contract(&vault_id); - assert_eq!(Contract::compute_escrow_settlement(100, 40), (40, 60)); - assert_eq!(Contract::compute_escrow_settlement(100, 200), (100, 0)); - assert_eq!(Contract::compute_escrow_settlement(0, 50), (0, 0)); + let s1: (u128, u128) = Contract::compute_escrow_settlement(100, 40).into(); + assert_eq!(s1, (40u128, 60u128)); + + let s2: (u128, u128) = Contract::compute_escrow_settlement(100, 200).into(); + assert_eq!(s2, (100u128, 0u128)); + + let s3: (u128, u128) = Contract::compute_escrow_settlement(0, 50).into(); + assert_eq!(s3, (0u128, 0u128)); } #[test] From ce3ba6792f463e065c1ae83c61f90bf70b04692b Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Oct 2025 10:38:10 +0100 Subject: [PATCH 048/121] refactor: move returnstyle to lib --- common/src/asset.rs | 16 ++++++++++++++++ contract/market/src/impl_helper.rs | 1 + contract/market/src/impl_token_receiver.rs | 4 ++-- contract/market/src/lib.rs | 19 ------------------- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/common/src/asset.rs b/common/src/asset.rs index d6c70453..6442905e 100644 --- a/common/src/asset.rs +++ b/common/src/asset.rs @@ -538,3 +538,19 @@ mod tests { assert_eq!(deserialized, amount); } } + +#[derive(Clone, Debug)] +#[near(serializers = [json])] +pub enum ReturnStyle { + Nep141FtTransferCall, + Nep245MtTransferCall, +} + +impl ReturnStyle { + pub fn serialize(&self, amount: FungibleAssetAmount) -> serde_json::Value { + match self { + Self::Nep141FtTransferCall => serde_json::json!(amount), + Self::Nep245MtTransferCall => serde_json::json!([amount]), + } + } +} diff --git a/contract/market/src/impl_helper.rs b/contract/market/src/impl_helper.rs index c3622d82..ae032064 100644 --- a/contract/market/src/impl_helper.rs +++ b/contract/market/src/impl_helper.rs @@ -2,6 +2,7 @@ use near_sdk::{env, near, require, serde_json, AccountId, Gas, Promise, PromiseR use templar_common::{ asset::{ BorrowAsset, BorrowAssetAmount, CollateralAsset, CollateralAssetAmount, FungibleAsset, + ReturnStyle, }, borrow::{InitialBorrow, InitialLiquidation}, market::{LiquidateMsg, Withdrawal}, diff --git a/contract/market/src/impl_token_receiver.rs b/contract/market/src/impl_token_receiver.rs index 453ecaf8..e86349c3 100644 --- a/contract/market/src/impl_token_receiver.rs +++ b/contract/market/src/impl_token_receiver.rs @@ -3,12 +3,12 @@ use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; use templar_common::{ - asset::{BorrowAssetAmount, CollateralAssetAmount}, + asset::{BorrowAssetAmount, CollateralAssetAmount, ReturnStyle}, market::DepositMsg, self_ext, }; -use crate::{Contract, ContractExt, ReturnStyle}; +use crate::{Contract, ContractExt}; #[near] impl FungibleTokenReceiver for Contract { diff --git a/contract/market/src/lib.rs b/contract/market/src/lib.rs index 625b648e..5b718124 100644 --- a/contract/market/src/lib.rs +++ b/contract/market/src/lib.rs @@ -114,25 +114,6 @@ mod impl_helper; mod impl_market_external; mod impl_token_receiver; -#[derive(Clone, Debug)] -#[near(serializers = [json])] -pub enum ReturnStyle { - Nep141FtTransferCall, - Nep245MtTransferCall, -} - -impl ReturnStyle { - pub fn serialize( - &self, - amount: templar_common::asset::FungibleAssetAmount, - ) -> serde_json::Value { - match self { - Self::Nep141FtTransferCall => serde_json::json!(amount), - Self::Nep245MtTransferCall => serde_json::json!([amount]), - } - } -} - #[cfg(target_arch = "wasm32")] mod custom_getrandom { #![allow(clippy::no_mangle_with_rust_abi)] From 1e07b7b299a83611a8051dc6f0ec7456aca6447d Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Oct 2025 10:38:53 +0100 Subject: [PATCH 049/121] chore: remove clean errors due to json types --- common/src/vault.rs | 12 +++++------- contract/market/src/impl_helper.rs | 2 +- contract/vault/src/impl_callbacks.rs | 4 ++-- contract/vault/src/lib.rs | 23 +++++++++++------------ test-utils/src/controller/vault.rs | 1 - 5 files changed, 19 insertions(+), 23 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 80fc383b..bc968e4c 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -6,8 +6,6 @@ use crate::asset::{BorrowAsset, FungibleAsset}; pub type TimestampNs = u64; -pub const ONE_YOCTO: u128 = 1; - pub const MIN_TIMELOCK_NS: u64 = 0; pub const MAX_TIMELOCK_NS: u64 = 30 * 86_400_000_000_000; // 30 days pub const MAX_QUEUE_LEN: usize = 64; @@ -206,7 +204,7 @@ pub struct PendingValue { } #[derive(Debug, Clone, PartialEq, Eq)] -#[near(serializers = [json, borsh])] +#[near(serializers = [borsh])] /// Operation state machine for asynchronous allocation, withdrawal, and payout flows. pub enum OpState { Idle, @@ -241,12 +239,12 @@ pub enum Error { IndexDrifted(ExpectedIdx, ActualIdx), // Invariant: Attempting to work on a market that is missing from the withdraw queue MissingMarket(u32), - NotWithdrawing(OpState), - NotAllocating(OpState), + NotWithdrawing, + NotAllocating, MarketTransferFailed, MissingSupplyPosition, PositionReadFailed, - // Invariant: Insufficient liquidity across all markets to satisfy withdrawal + // Insufficient liquidity across all markets to satisfy withdrawal InsufficientLiquidity, ZeroAmount, } @@ -258,7 +256,7 @@ impl std::fmt::Display for Error { } #[derive(Clone, Debug)] -#[near(serializers = [json, borsh])] +#[near(serializers = [borsh])] pub struct PendingWithdrawal { pub owner: AccountId, pub receiver: AccountId, diff --git a/contract/market/src/impl_helper.rs b/contract/market/src/impl_helper.rs index ae032064..3c69783a 100644 --- a/contract/market/src/impl_helper.rs +++ b/contract/market/src/impl_helper.rs @@ -12,7 +12,7 @@ use templar_common::{ withdrawal_queue::WithdrawalQueueExecutionResult, }; -use crate::{Contract, ContractExt, ReturnStyle}; +use crate::{Contract, ContractExt}; /// Internal helpers. impl Contract { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index b221490d..3d8664d5 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -589,7 +589,7 @@ impl Contract { index, remaining, } if *cur == op_id => Ok((*index, *remaining)), - _ => Err(Error::NotAllocating(self.op_state.clone())), + _ => Err(Error::NotAllocating), } } @@ -615,7 +615,7 @@ impl Contract { owner.clone(), *escrow_shares, )), - _ => Err(Error::NotWithdrawing(self.op_state.clone())), + _ => Err(Error::NotWithdrawing), } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index d2b046d9..8cb519c4 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1197,7 +1197,7 @@ impl Contract { index, remaining, } => (*op_id, *index, *remaining), - _ => return self.stop_and_exit(Some(&Error::NotAllocating(self.op_state.clone()))), + _ => return self.stop_and_exit(Some(&Error::NotAllocating)), }; if remaining == 0 { @@ -1379,12 +1379,11 @@ impl Contract { } fn step_withdraw(&mut self) -> PromiseOrValue<()> { - let (op_id, index, remaining, receiver, collected, owner, escrow_shares) = match &self - .op_state - { - OpState::Withdrawing { - op_id, - index, + let (op_id, index, remaining, receiver, collected, owner, escrow_shares) = + match &self.op_state { + OpState::Withdrawing { + op_id, + index, remaining, receiver, collected, @@ -1396,11 +1395,11 @@ impl Contract { *remaining, receiver.clone(), *collected, - owner.clone(), - *escrow_shares, - ), - _ => return self.stop_and_exit(Some(&Error::NotWithdrawing(self.op_state.clone()))), - }; + owner.clone(), + *escrow_shares, + ), + _ => return self.stop_and_exit(Some(&Error::NotWithdrawing)), + }; if remaining == 0 { self.op_state = OpState::Payout { op_id, diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index c662d941..bd4bc0dc 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -62,7 +62,6 @@ impl VaultController { #[view] pub fn get_total_supply() -> U128; #[view] pub fn get_max_deposit() -> U128; #[view] pub fn get_idle_balance() -> U128; - #[view] pub fn get_op_state() -> OpState; #[view] pub fn list_supply_queue(offset: Option, count: Option) -> Vec; #[view] pub fn list_withdraw_queue(offset: Option, count: Option) -> Vec; #[view] pub fn get_market_supply(market: &AccountId) -> U128; From 613785698209f6199927e22bad582aa56fe4492f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Oct 2025 10:40:42 +0100 Subject: [PATCH 050/121] refactor: ensure wad is wide enough for future changes to u256 --- contract/vault/src/aux.rs | 53 ---------------- contract/vault/src/impl_token_receiver.rs | 7 ++- contract/vault/src/lib.rs | 73 +++++++++++------------ contract/vault/src/wad.rs | 48 +++++++++------ 4 files changed, 71 insertions(+), 110 deletions(-) delete mode 100644 contract/vault/src/aux.rs diff --git a/contract/vault/src/aux.rs b/contract/vault/src/aux.rs deleted file mode 100644 index 73125957..00000000 --- a/contract/vault/src/aux.rs +++ /dev/null @@ -1,53 +0,0 @@ -use crate::{env, near, serde_json, AccountId, Contract, Nep145Controller, Nep145ForceUnregister}; - -impl Contract { - /// ----- Storage ----- - fn charge_for_storage(&mut self, account_id: &AccountId, storage_consumption: u64) { - // Invariant: Storage charging saturates and panics on failure to avoid negative balances. - self.lock_storage( - account_id, - env::storage_byte_cost().saturating_mul(u128::from(storage_consumption)), - ) - .unwrap_or_else(|e| env::panic_str(&format!("Storage error: {e}"))); - } - - fn refund_for_storage(&mut self, account_id: &AccountId, storage_consumption: u64) { - // Invariant: Storage refunds saturate and panic on failure to preserve accounting integrity. - self.unlock_storage( - account_id, - env::storage_byte_cost().saturating_mul(u128::from(storage_consumption)), - ) - .unwrap_or_else(|e| env::panic_str(&format!("Storage error: {e}"))); - } -} - -#[derive(Clone, Debug)] -#[near(serializers = [json])] -/// Indicates the JSON return payload shape expected by token receiver callbacks. -pub enum ReturnStyle { - /// Return payload shape for NEP-141 `ft_transfer_call` (a bare amount). - Nep141FtTransferCall, - /// Return payload shape for NEP-245 `mt_transfer_call` (a single-element array). - Nep245MtTransferCall, -} - -/// TODO: use this -impl ReturnStyle { - #[must_use] - pub fn serialize( - &self, - amount: templar_common::asset::FungibleAssetAmount, - ) -> serde_json::Value { - match self { - Self::Nep141FtTransferCall => serde_json::json!(amount), - Self::Nep245MtTransferCall => serde_json::json!([amount]), - } - } -} - -impl near_sdk_contract_tools::hook::Hook> for Contract { - fn hook(_: &mut Self, _: &Nep145ForceUnregister, _: impl FnOnce(&mut Self) -> R) -> R { - // Invariant: Force unregister must fail to preserve FT ledger integrity. - env::panic_str("force unregistration is not supported") - } -} diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index f4a95d64..28b3729b 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -1,7 +1,10 @@ -use crate::{aux::ReturnStyle, Contract, ContractExt, OpState}; +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 templar_common::vault::{require_at_least, AllocationMode, DepositMsg, Event, SUPPLY_GAS}; +use templar_common::{ + asset::ReturnStyle, + vault::{require_at_least, AllocationMode, DepositMsg, Event, 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 8cb519c4..4e4131ad 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -5,6 +5,11 @@ use std::{ num::NonZeroU8, }; +use crate::storage_management::{ + require_attached_at_least, require_attached_for_pending_withdrawal, + storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, + yocto_for_pending_cap, +}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, @@ -18,7 +23,6 @@ use near_sdk_contract_tools::{ nep141::GAS_FOR_FT_TRANSFER_CALL, ContractMetadata, FungibleToken, Nep141Controller, Nep148Controller, }, - standard::nep145::{Nep145Controller, Nep145ForceUnregister}, Owner, Rbac, }; use near_sdk_contract_tools::{owner::Owner, rbac}; @@ -35,13 +39,6 @@ use templar_common::{ }; pub use wad::*; -use crate::storage_management::{ - require_attached_at_least, require_attached_for_pending_withdrawal, - storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, - yocto_for_pending_cap, -}; - -pub mod aux; pub mod impl_callbacks; pub mod impl_token_receiver; pub mod storage_management; @@ -185,11 +182,6 @@ impl Contract { }; } - // TODO: this but with roles and other storage we set - // let storage_usage_1 = env::storage_usage(); - // market.finalized_snapshots.flush(); - // let storage_usage_2 = env::storage_usage(); - // let storage_usage_snapshot = storage_usage_2.saturating_sub(storage_usage_1); let storage_usage_supply = env::storage_usage(); let storage_usage_role = env::storage_usage(); @@ -354,7 +346,7 @@ impl Contract { self.fee_recipient = account; } - /// Sets the performance fee as a WAD fraction (1e18 = 100%). Accrues fees at the old rate first. + /// Sets the performance fee as a WAD fraction (1e24 = 100%). Accrues fees at the old rate first. pub fn set_performance_fee(&mut self, fee: U128) { Self::require_owner(); @@ -924,23 +916,21 @@ impl Contract { /// Returns the maximum additional amount that can be deposited across all markets given current caps. pub fn get_max_deposit(&self) -> U128 { - // TODO: join - let mut total = 0u128; - self.supply_queue.iter().for_each(|m| { - if let Some(cfg) = self.config.get(m) { - if cfg.cap.0 > 0 { - let cur = self.market_supply.get(m).unwrap_or(&0); - if cfg.cap.0 > *cur { - total += cfg.cap.0 - cur; - } + let total = self + .supply_queue + .iter() + .fold(0u128, |acc, m| match self.config.get(m) { + Some(cfg) if cfg.cap.0 > 0 => { + let cur = *self.market_supply.get(m).unwrap_or(&0); + acc + cfg.cap.0.saturating_sub(cur) } - } - }); + _ => acc, + }); U128(total) } /// Converts an amount of underlying assets to shares, flooring the result. - /// Uses virtual offsets and fee-aware totals (pre-accrual simulation) like MetaMorpho. + /// Uses virtual offsets and fee-aware totals (pre-accrual simulation). pub fn convert_to_shares(&self, assets: U128) -> U128 { let a: u128 = assets.0; if a == 0 { @@ -951,7 +941,7 @@ impl Contract { } /// Converts an amount of shares to underlying assets, flooring the result. - /// Uses virtual offsets and fee-aware totals (pre-accrual simulation) like MetaMorpho. + /// Uses virtual offsets and fee-aware totals (pre-accrual simulation). pub fn convert_to_assets(&self, shares: U128) -> U128 { let s: u128 = shares.0; if s == 0 { @@ -1009,6 +999,13 @@ 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 { /// Enqueue a vault-level pending withdrawal request (escrow already taken). @@ -1384,17 +1381,17 @@ impl Contract { OpState::Withdrawing { op_id, index, - remaining, - receiver, - collected, - owner, - escrow_shares, - } => ( - *op_id, - *index, - *remaining, - receiver.clone(), - *collected, + remaining, + receiver, + collected, + owner, + escrow_shares, + } => ( + *op_id, + *index, + *remaining, + receiver.clone(), + *collected, owner.clone(), *escrow_shares, ), diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index dd6d9003..f4f3add9 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -1,10 +1,14 @@ -/// Fixed-point helpers and fee-accrual math using 18-decimal WAD precision. -use templar_common::primitive_types::U256; +/// Fixed-point helpers and fee-accrual math using 24-decimal WAD precision. +use templar_common::primitive_types::{U256, U512}; + +// TODO: possibly change to u256 for more precision +pub const WAD: u128 = 1_000_000_000_000_000_000_000_000u128; pub type WADFraction = u128; -pub const WAD: u128 = 1e18 as u128; +pub type WIDE = U512; -/// Multiplies two WAD-scaled values and floors the result: floor(x * y / WAD). +/// Multiplies x by y/WAD and floors: floor(x * y / WAD). +/// Typically, y is a WAD-scaled fraction (1e24 = 100%), and x is an unscaled amount. #[inline] #[must_use] pub fn mul_wad_floor(x: u128, y: u128) -> u128 { @@ -12,39 +16,43 @@ pub fn mul_wad_floor(x: u128, y: u128) -> u128 { } /// Multiplies and divides with flooring: floor(x * y / denom). -/// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. +/// Uses 512-bit intermediate (U512) to avoid overflow; returns 0 if denom is 0. #[inline] #[must_use] pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { if denom == 0 { return 0; } - let num = U256::from(x) * U256::from(y); - let q = num / U256::from(denom); + let num = WIDE::from(x) * WIDE::from(y); + let q = num / WIDE::from(denom); q.as_u128() } /// Multiplies and divides with ceiling: ceil(x * y / denom). -/// Uses 256-bit intermediate to avoid overflow; returns 0 if denom is 0. +/// Uses 512-bit intermediate (U512) to avoid overflow; returns 0 if denom is 0. +/// Implemented via quotient/remainder to avoid relying on addition overflow behavior. #[inline] #[must_use] pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { if denom == 0 { return 0; } - let num = U256::from(x) * U256::from(y); - let d = U256::from(denom); - let q = (num + d - U256::from(1)) / d; - q.as_u128() + let num = WIDE::from(x) * WIDE::from(y); + let d = WIDE::from(denom); + let q = num / d; + let r = num % d; + let base = q.as_u128(); + base.saturating_add((!r.is_zero()) as u128) } /// Computes fee shares to mint given: /// - `cur_total_assets`: current total assets under management /// - `last_total_assets`: previous total assets snapshot -/// - `performance_fee`: WAD fraction (1e18 = 100%) +/// - `performance_fee`: WAD fraction (1e24 = 100%) /// - `total_supply`: current total share supply /// -/// Floors intermediate divisions; returns 0 when no profit, zero fee, or zero supply. +/// Floors intermediate divisions; returns 0 when no profit, zero fee, zero supply, +/// or when the fee consumes all assets (cur_total_assets == fee_assets). #[inline] #[must_use] pub fn compute_fee_shares( @@ -58,12 +66,18 @@ pub fn compute_fee_shares( } let profit = cur_total_assets - last_total_assets; let fee_assets = mul_wad_floor(profit, performance_fee); + let denom = cur_total_assets.saturating_sub(fee_assets); + + if denom == 0 { + return 0; + } + if fee_assets == 0 { return 0; } + // ERC-4626-like: mint shares so that fee_shares / (total_supply + fee_shares) = fee_assets / cur_total_assets // Rearranged and floored: - let denom = cur_total_assets.saturating_sub(fee_assets).max(1); mul_div_floor(fee_assets, total_supply, denom) } @@ -78,7 +92,7 @@ mod tests { // 0.3333... * 0.3333... ~= 0.1111... let third = W / 3; let res = mul_wad_floor(third, third); - // floor(1/9 * W) = floor(0.111... * 1e18) + // floor(1/9 * W) = floor(0.111... * 1e24) assert!(res <= W / 9); assert_eq!(res, (W / 9) - 1); // typical floor loss } @@ -122,7 +136,7 @@ mod tests { #[test] fn compute_fee_shares_handles_extreme_fee() { - // 100% fee on positive profit: fee_assets=profit; denom=max(1) => finite result + // 100% fee on positive profit: fee_assets=profit; denom=cur_total_assets - fee_assets let minted = compute_fee_shares(2_000, 1_000, W, 1_000); // fee_assets=1000; denom=1_000 (2_000 - 1_000) => floor(1_000*1_000/1_000)=1_000 assert_eq!(minted, 1_000); From 9281756d0a5dd95fb753f45ef4e4f0365e56f681 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Oct 2025 10:46:54 +0100 Subject: [PATCH 051/121] chore: json types --- common/src/vault.rs | 59 +++++++++++------------ contract/vault/src/impl_callbacks.rs | 20 ++++---- contract/vault/src/impl_token_receiver.rs | 2 +- contract/vault/src/lib.rs | 22 ++++----- 4 files changed, 51 insertions(+), 52 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index bc968e4c..4d87cc69 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -1,6 +1,10 @@ use std::num::NonZeroU8; -use near_sdk::{env, json_types::U128, near, require, AccountId, Gas, Promise, PromiseOrValue}; +use near_sdk::{ + env, + json_types::{U128, U64}, + near, require, AccountId, Gas, Promise, PromiseOrValue, +}; use crate::asset::{BorrowAsset, FungibleAsset}; @@ -286,14 +290,14 @@ pub enum Event { #[event_version("1.0.0")] MintedShares { amount: U128, receiver: AccountId }, #[event_version("1.0.0")] - AllocationStarted { op_id: u64, remaining: U128 }, + AllocationStarted { op_id: U64, remaining: U128 }, // Allocation lifecycle (plan/request) #[event_version("1.0.0")] - AllocationRequestedQueue { op_id: u64, total: U128 }, + AllocationRequestedQueue { op_id: U64, total: U128 }, #[event_version("1.0.0")] AllocationPlanSet { - op_id: u64, + op_id: U64, total: U128, plan: Vec<(AccountId, U128)>, }, @@ -301,7 +305,7 @@ pub enum Event { // Per-step planning and outcomes #[event_version("1.0.0")] AllocationStepPlanned { - op_id: u64, + op_id: U64, index: u32, market: AccountId, target: U128, @@ -312,7 +316,7 @@ pub enum Event { }, #[event_version("1.0.0")] AllocationStepSkipped { - op_id: u64, + op_id: U64, index: u32, market: AccountId, reason: String, @@ -320,14 +324,14 @@ pub enum Event { }, #[event_version("1.0.0")] AllocationTransferFailed { - op_id: u64, + op_id: U64, index: u32, market: AccountId, attempted: U128, }, #[event_version("1.0.0")] AllocationStepSettled { - op_id: u64, + op_id: U64, index: u32, market: AccountId, before: U128, @@ -343,7 +347,7 @@ pub enum Event { AllocationCompleted { op_id: u64 }, #[event_version("1.0.0")] AllocationStopped { - op_id: u64, + op_id: U64, index: u32, remaining: U128, reason: Option, @@ -352,7 +356,7 @@ pub enum Event { // Eager #[event_version("1.0.0")] AllocationEagerTriggered { - op_id: u64, + op_id: U64, idle_balance: U128, min_batch: U128, deposit_accepted: U128, @@ -373,9 +377,9 @@ pub enum Event { #[event_version("1.0.0")] TimelockSet { seconds: u32 }, #[event_version("1.0.0")] - TimelockChangeSubmitted { new_seconds: u32, valid_at: u64 }, + TimelockChangeSubmitted { new_seconds: u32, valid_at: U64 }, #[event_version("1.0.0")] - PendingTimelockRevoked {}, + PendingTimelockRevoked, // Market and queue management #[event_version("1.0.0")] @@ -397,7 +401,7 @@ pub enum Event { #[event_version("1.0.0")] MarketRemovalSubmitted { market: AccountId, - removable_at: u64, + removable_at: U64, }, #[event_version("1.0.0")] MarketRemovalRevoked { market: AccountId }, @@ -412,18 +416,18 @@ pub enum Event { }, #[event_version("1.0.0")] WithdrawalQueued { - id: u64, + id: U64, owner: AccountId, receiver: AccountId, escrow_shares: U128, expected_assets: U128, - requested_at: u64, + requested_at: U64, }, // Allocation read/settlement diagnostics #[event_version("1.0.0")] AllocationPositionMissing { - op_id: u64, + op_id: U64, index: u32, market: AccountId, attempted: U128, @@ -431,7 +435,7 @@ pub enum Event { }, #[event_version("1.0.0")] AllocationPositionReadFailed { - op_id: u64, + op_id: U64, index: u32, market: AccountId, attempted: U128, @@ -441,7 +445,7 @@ pub enum Event { // Withdrawal read diagnostics #[event_version("1.0.0")] WithdrawalPositionMissing { - op_id: u64, + op_id: U64, market: AccountId, index: u32, before: U128, @@ -449,7 +453,7 @@ pub enum Event { }, #[event_version("1.0.0")] WithdrawalPositionReadFailed { - op_id: u64, + op_id: U64, market: AccountId, index: u32, before: U128, @@ -459,13 +463,13 @@ pub enum Event { // Payout and stop diagnostics #[event_version("1.0.0")] PayoutUnexpectedState { - op_id: u64, + op_id: U64, receiver: AccountId, amount: U128, }, #[event_version("1.0.0")] WithdrawalStopped { - op_id: u64, + op_id: U64, index: u32, remaining: U128, collected: U128, @@ -473,7 +477,7 @@ pub enum Event { }, #[event_version("1.0.0")] PayoutStopped { - op_id: u64, + op_id: U64, receiver: AccountId, amount: U128, reason: Option, @@ -495,18 +499,13 @@ pub enum Event { #[cfg(test)] mod tests { - use std::str::FromStr; - - use borsh::BorshDeserialize; - use near_sdk::test_utils::accounts; - use super::*; + use std::str::FromStr; - // Compile time checks const _: [(); MarketConfiguration::encoded_size()] = [(); 25]; - const EXPECTED_FROM_TYPES: usize = + const _EXPECTED_FROM_TYPES: usize = core::mem::size_of::() + core::mem::size_of::() + core::mem::size_of::(); - const _: [(); MarketConfiguration::encoded_size()] = [(); EXPECTED_FROM_TYPES]; + const _: [(); MarketConfiguration::encoded_size()] = [(); _EXPECTED_FROM_TYPES]; #[test] fn encoded_size_is_25() { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 3d8664d5..e37db8c0 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -42,7 +42,7 @@ impl Contract { // If the transfer failed, do not attempt to reconcile; stop and leave remaining untouched if accepted.is_err() { Event::AllocationTransferFailed { - op_id, + op_id: op_id.into(), index: market_index, market: market.clone(), attempted, @@ -107,7 +107,7 @@ impl Contract { } Ok(None) => { Event::AllocationPositionMissing { - op_id, + op_id: op_id.into(), index: market_index, market: market.clone(), attempted, @@ -118,7 +118,7 @@ impl Contract { } Err(_) => { Event::AllocationPositionReadFailed { - op_id, + op_id: op_id.into(), index: market_index, market: market.clone(), attempted, @@ -131,7 +131,7 @@ impl Contract { let refunded = attempted.0.saturating_sub(accepted_event); Event::AllocationStepSettled { - op_id, + op_id: op_id.into(), index: market_index, market: market.clone(), before, @@ -284,7 +284,7 @@ impl Contract { // No position => treat as principal = 0 // NOTE: this is a successful withdraw Event::WithdrawalPositionMissing { - op_id, + op_id: op_id.into(), market: market.clone(), index: market_index, before: U128(before_principal), @@ -295,7 +295,7 @@ impl Contract { } Err(_) => { Event::WithdrawalPositionReadFailed { - op_id, + op_id: op_id.into(), market: market.clone(), index: market_index, before: U128(before_principal), @@ -378,7 +378,7 @@ impl Contract { } _ => { Event::PayoutUnexpectedState { - op_id, + op_id: op_id.into(), receiver: receiver.clone(), amount, } @@ -464,7 +464,7 @@ impl Contract { } Some(m) => { Event::AllocationStopped { - op_id: *op_id, + op_id: (*op_id).into(), index: *index, remaining: U128(*remaining), reason: Some(m.to_string()), @@ -498,7 +498,7 @@ impl Contract { _ => (0, 0, 0, 0), }; Event::WithdrawalStopped { - op_id, + op_id: op_id.into(), index, remaining: U128(remaining), collected: U128(collected), @@ -536,7 +536,7 @@ impl Contract { } = &self.op_state { Event::PayoutStopped { - op_id: *op_id, + op_id: (*op_id).into(), receiver: receiver.clone(), amount: U128(*amount), reason: msg.map(std::string::ToString::to_string), diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 28b3729b..9d948d45 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -132,7 +132,7 @@ impl Contract { // Invariant: no overlapping operations let op_id = self.next_op_id; Event::AllocationEagerTriggered { - op_id, + op_id: op_id.into(), idle_balance: U128(self.idle_balance), min_batch, deposit_accepted: U128(accept), diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 4e4131ad..e1c51b58 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -391,7 +391,7 @@ impl Contract { }); Event::TimelockChangeSubmitted { new_seconds: new_timelock_secs, - valid_at, + valid_at: valid_at.into(), } .emit(); } @@ -591,7 +591,7 @@ impl Contract { cfg.removable_at = env::block_timestamp() + self.timelock_ns; Event::MarketRemovalSubmitted { market: market.clone(), - removable_at: cfg.removable_at, + removable_at: cfg.removable_at.into(), } .emit(); } @@ -828,7 +828,7 @@ impl Contract { } let op_id = self.next_op_id; Event::AllocationRequestedQueue { - op_id, + op_id: op_id.into(), total: U128(total), } .emit(); @@ -854,7 +854,7 @@ impl Contract { let weights_for_event: Vec<(AccountId, U128)> = weights.iter().map(|(m, w)| (m.clone(), U128(*w))).collect(); Event::AllocationPlanSet { - op_id, + op_id: op_id.into(), total: U128(total), plan: weights_for_event, } @@ -1032,12 +1032,12 @@ impl Contract { ); Event::WithdrawalQueued { - id, + id: id.into(), owner: owner.clone(), receiver: receiver.clone(), escrow_shares: U128(escrow_shares), expected_assets: U128(expected_assets), - requested_at, + requested_at: requested_at.into(), } .emit(); } @@ -1180,7 +1180,7 @@ impl Contract { remaining: amount, }; Event::AllocationStarted { - op_id, + op_id: op_id.into(), remaining: U128(amount), } .emit(); @@ -1227,7 +1227,7 @@ impl Contract { // Emit planned step event Event::AllocationStepPlanned { - op_id, + op_id: op_id.into(), index, market: market_id.clone(), target: U128(target), @@ -1240,7 +1240,7 @@ impl Contract { if to_supply == 0 { Event::AllocationStepSkipped { - op_id, + op_id: op_id.into(), index, market: market_id.clone(), reason: if room == 0 { @@ -1292,7 +1292,7 @@ impl Contract { // Emit planned step event (queue-based) Event::AllocationStepPlanned { - op_id, + op_id: op_id.into(), index, market: market.clone(), target: U128(remaining), @@ -1305,7 +1305,7 @@ impl Contract { if to_supply == 0 { Event::AllocationStepSkipped { - op_id, + op_id: op_id.into(), index, market: market.clone(), reason: "no-room".to_string(), From 6b39381b53ecd2070c77b698bfdfd08fdfb4e833 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Oct 2025 11:05:35 +0100 Subject: [PATCH 052/121] chore: use fixture to clean up macro --- contract/vault/examples/gas_report.rs | 2 + contract/vault/src/test_utils.rs | 1 - contract/vault/tests/happy_path.rs | 8 +- contract/vault/tests/invariants.rs | 141 +++----------------------- test-utils/src/lib.rs | 34 +------ 5 files changed, 26 insertions(+), 160 deletions(-) diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index 36c970b2..72030c1f 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -7,8 +7,10 @@ use test_utils::*; #[tokio::main] async fn main() { const ITERATIONS: usize = 128; + let worker = near_workspaces::sandbox().await.unwrap(); setup_test!( + worker extract(vault, c, vault_curator) accounts(user1, user2, user3) ); diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 52ff34b6..99a48668 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -6,7 +6,6 @@ pub use near_sdk::{ test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, }; use near_sdk_contract_tools::ft::Nep141Controller as _; -use templar_common::primitive_types::U128; use test_utils::vault_configuration; pub fn mk(n: u32) -> AccountId { diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 012730f2..a33b6db4 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -1,13 +1,17 @@ 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 test_utils::{ - controller::vault::UnifiedVaultController, setup_test, ContractController, + controller::vault::UnifiedVaultController, setup_test, worker, ContractController, UnifiedMarketController, }; +#[rstest] #[tokio::test] -async fn happy() { +async fn happy(#[future(awt)] worker: Worker) { setup_test!( + worker extract(vault, c, vault_curator) accounts(supply_user, borrow_user) config(|c| { diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index 43ed2929..c4cbaa56 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -1,36 +1,13 @@ -use test_utils::{setup_test, ContractController}; - -// TODO(unit?): on allocation-failure, reconcile to idle - -// TODO(prop): every callback must be for the current op and market index -// TODO(prop): allocation accounting: Accepted amount = new_principal - before &never more than attempted -// TODO(prop): allocation attempts: any market that is enabled (new_principal > 0) must be in the withdraw queue -// TODO(prop): enabling a market (cap > 0) must add it to the withdraw queue -// TODO(prop): stop and exit: must never mutiny funds or escrow - -// Withdraws -// TODO(integration): try withdraw & idle first: idle balance can be utilised on a first-come-first-serve basis => it -// is **not** deducted until payout succeeds -// TODO(integration): create withdraw: if create withdraw fails, skip to next market -// TODO(integration): execute withdraw: if executing a withdrawal fails, assume nothing changed -// TODO(integration): withdrawn(execute > read): withdrawn credits must increase idle balance - -// TODO: Skim: is no-op when balance is 0 - -// Payouts -// TODO(integration): payout success: idle balance must decrease & burn escrowed shares -// TODO(integration): payout failure: idle doesnt change & refund escrowed shares to original owner - -// TODO(integration): single-op state machine, all mutators must be idle - -// Note: happy path?: credit principal only after proper supply to marfket - -// TODO: Withdraw read only credits idle +use near_workspaces::{network::Sandbox, Worker}; +use rstest::rstest; +use test_utils::{setup_test, worker, ContractController as _}; +#[rstest] #[tokio::test] #[should_panic = "Duplicate market"] -async fn supply_queue_mustnt_have_duplicates() { +async fn supply_queue_mustnt_have_duplicates(#[future(awt)] worker: Worker) { setup_test!( + worker extract(vault, c, vault_curator) accounts(supply_user, borrow_user) ); @@ -40,10 +17,12 @@ async fn supply_queue_mustnt_have_duplicates() { vault.set_supply_queue(&vault_curator, &queue).await; } +#[rstest] #[tokio::test] #[should_panic = "Duplicate market"] -async fn withdraw_queue_mustnt_have_duplicates() { +async fn withdraw_queue_mustnt_have_duplicates(#[future(awt)] worker: Worker) { setup_test!( + worker extract(vault, c, vault_curator) accounts(supply_user, borrow_user) ); @@ -53,10 +32,14 @@ async fn withdraw_queue_mustnt_have_duplicates() { vault.set_withdraw_queue(&vault_curator, &queue).await; } +#[rstest] #[tokio::test] #[should_panic = "Invariant: Only one op in flight"] -async fn state_machine_is_locked_when_another_op_is_running() { +async fn state_machine_is_locked_when_another_op_is_running( + #[future(awt)] worker: Worker, +) { setup_test!( + worker extract(vault, c, vault_curator) accounts(supply_user, borrow_user) ); @@ -73,99 +56,3 @@ async fn state_machine_is_locked_when_another_op_is_running() { vault.allocate(&vault_curator, vec![], Some(amount.into())), ); } - -// #[tokio::test] -// async fn happy() { -// setup_test!( -// extract(vault, c, vault_curator) -// accounts(supply_user, borrow_user) -// config(|c| { -// c.borrow_interest_rate_strategy = -// InterestRateStrategy::linear(Decimal::ZERO, Decimal::ZERO).unwrap(); -// }) -// ); -// vault.init_account(&supply_user).await; -// -// let v = vault.contract().id(); -// let amount: U128 = 1000.into(); -// -// vault.supply(&supply_user, amount.0).await; -// c.collateralize(&borrow_user, 2000).await; -// -// let weights = vec![(c.market.contract().id().clone(), U128(1))]; -// vault -// .allocate(&vault_curator, weights.clone(), Some(amount)) -// .await; -// -// assert_eq!( -// c.borrow_asset.balance_of(vault.contract().id()).await, -// 0, -// "Vault should not have any assets leftover after rebalancing 100%" -// ); -// assert_eq!( -// vault.get_total_supply().await, -// amount, -// "Vault should have issued shares to the supplier" -// ); -// assert_eq!( -// vault.get_total_assets().await, -// amount, -// "Vault should appropriately track assets" -// ); -// assert_eq!( -// c.get_supply_position(v) -// .await -// .unwrap() -// .get_deposit() -// .total(), -// amount.into(), -// "Supply position should match amount of tokens supplied to contract", -// ); -// -// harvest(&c, &vault).await; -// -// let supply_position = c.get_supply_position(v).await.unwrap(); -// -// assert_eq!( -// u128::from(supply_position.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; -// -// vault.withdraw(&supply_user, amount, None).await; -// -// assert_eq!( -// c.borrow_asset.balance_of(supply_user.id()).await, -// amount.0 + user_balance, -// "Supply user should have received their tokens back" -// ); -// -// let supply_position = c.get_supply_position(v).await; -// assert!( -// supply_position.is_none(), -// "Supply position should be closed" -// ); -// -// c.storage_deposits(vault.contract().as_account()).await; -// -// // 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; -// harvest(&c, &vault).await; -// -// println!( -// "Balance of the market for the collateral asset: {}", -// c.borrow_asset.balance_of(c.market.contract().id()).await -// ); -// -// let borrowed = amount.0 / 2; -// -// c.borrow(&borrow_user, borrowed).await; -// -// vault -// .withdraw(&supply_user, (amount.0 - borrowed).into(), None) -// .await; -// } diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index aaa49665..0934aa2e 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -83,14 +83,6 @@ macro_rules! accounts { #[macro_export] macro_rules! setup_test { - ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { - $crate::accounts!($w, $($n),*); - let s = $crate::setup_everything(&$w, $f, |_| {}).await; - ::tokio::join!( - $(s.vault.init_account(&$n)),* - ); - let $crate::SetupEverything { $($e,)* .. } = s; - }; ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr) vconfig($v:expr)) => { $crate::accounts!($w, $($n),*); let s = $crate::setup_everything(&$w, $f, $v).await; @@ -99,29 +91,11 @@ macro_rules! setup_test { ); let $crate::SetupEverything { $($e,)* .. } = s; }; - ($w:ident extract($($e:ident),*) accounts($($n:ident),*)) => { - $crate::setup_test!($w extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})) - }; - (extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { - let worker = near_workspaces::sandbox().await.unwrap(); - $crate::accounts!(worker, $($n),*); - let s = $crate::setup_everything(&worker, $f, |_| {}).await; - ::tokio::join!( - $(s.vault.init_account(&$n)),* - ); - let $crate::SetupEverything { $($e,)* .. } = s; - }; - (extract($($e:ident),*) accounts($($n:ident),*) config($f:expr) vconfig($v:expr)) => { - let worker = near_workspaces::sandbox().await.unwrap(); - $crate::accounts!(worker, $($n),*); - let s = $crate::setup_everything(&worker, $f, $v).await; - ::tokio::join!( - $(s.vault.init_account(&$n)),* - ); - let $crate::SetupEverything { $($e,)* .. } = s; + ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { + $crate::setup_test!($w extract($($e),*) accounts($($n),*) config($f) vconfig(|_| {})); }; - (extract($($e:ident),*) accounts($($n:ident),*)) => { - $crate::setup_test!(extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})) + ($w:ident extract($($e:ident),*) accounts($($n:ident),*)) => { + $crate::setup_test!($w extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})); }; } From 23e92ef80237c18b6dc39166f0e0fdd3e49cdd7e Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Oct 2025 11:32:19 +0100 Subject: [PATCH 053/121] test: internal fee accrual --- common/src/vault.rs | 4 +- contract/vault/src/impl_callbacks.rs | 1 + contract/vault/src/lib.rs | 28 ++-- contract/vault/src/tests.rs | 225 +++++++++++++++++++++++++++ contract/vault/tests/conversions.rs | 2 +- 5 files changed, 244 insertions(+), 16 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 4d87cc69..2d58e1e6 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -166,12 +166,12 @@ pub const fn buffer(size: usize) -> Gas { .saturating_div(5) } -// NOTE: these are taken after running the contract with the gas report +// NOTE: these are taken after running the contract with the gas report and cieled to next whole TGAS. pub const SUPPLY_GAS: Gas = buffer(8); pub const ALLOCATE_GAS: Gas = buffer(21); pub const WITHDRAW_GAS: Gas = buffer(4); pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); -pub const SUBMIT_CAP_GAS: Gas = buffer(4); +pub const SUBMIT_CAP_GAS: Gas = buffer(3); pub fn require_at_least(needed: Gas) { let gas = env::prepaid_gas(); diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index e37db8c0..2cd9598d 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -581,6 +581,7 @@ impl Contract { } PromiseOrValue::Value(()) } + /// Validate current op is Allocating and return (index, remaining) pub(crate) fn ctx_allocating(&self, op_id: u64) -> Result<(u32, u128), Error> { match &self.op_state { diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index e1c51b58..28547a58 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -5,10 +5,13 @@ use std::{ num::NonZeroU8, }; -use crate::storage_management::{ - require_attached_at_least, require_attached_for_pending_withdrawal, - storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, - yocto_for_pending_cap, +use crate::{ + storage_management::{ + require_attached_at_least, require_attached_for_pending_withdrawal, + storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, + yocto_for_pending_cap, + }, + wad::compute_fee_shares, }; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ @@ -799,6 +802,10 @@ impl Contract { /// Allocates assets across markets according to the provided weights. /// If `amount` is provided, it is used as the target amount for each market. /// Otherwise, the vault will attempt to allocate as much as possible. + /// + /// NOTE: Each allocation takes roughly [common::vault::ALLOCATE_GAS] gas. (~21 TGAS) + /// So in one allocation cycle, we should do at most ~12 market allocations. + /// This is a conservative estimate, and may need to be tweaked. #[payable] pub fn allocate( &mut self, @@ -818,7 +825,7 @@ impl Contract { }; let required_yocto = storage_management::yocto_for_queue_additions(&existing, &candidates); - require_attached_at_least(required_yocto, "potential queue additions"); + let _ = require_attached_at_least(required_yocto, "potential queue additions"); let total = self.clamp_allocation_total(amount.map(|x| x.0)); @@ -1076,12 +1083,8 @@ impl Contract { virtual_shares: u128, virtual_assets: u128, ) -> (u128, u128) { - let fee_shares = crate::wad::compute_fee_shares( - cur_assets, - last_total_assets, - performance_fee, - total_supply, - ); + let fee_shares = + compute_fee_shares(cur_assets, last_total_assets, performance_fee, total_supply); let new_total_supply = total_supply .saturating_add(fee_shares) .saturating_add(virtual_shares); @@ -1117,7 +1120,7 @@ impl Contract { pub fn internal_accrue_fee(&mut self) { // Invariant: Fees are minted only when total_assets() > last_total_assets (no fees on losses/flat). let cur = self.get_total_assets().0; - let fee_shares = crate::wad::compute_fee_shares( + let fee_shares = compute_fee_shares( cur, self.last_total_assets, self.performance_fee, @@ -1225,7 +1228,6 @@ impl Contract { let room = cap.saturating_sub(cur); let to_supply = room.min(target); - // Emit planned step event Event::AllocationStepPlanned { op_id: op_id.into(), index, diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 7f3209ac..3fc1a2a9 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -783,3 +783,228 @@ fn total_assets_ignores_offqueue_cases(principal: u128, idle: u128) { assert_eq!(c.get_total_assets().0, idle); } + +#[test] +fn set_fee_recipient_accrues_before_switch() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = accounts(1); + setup_env(&vault_id, &owner, vec![]); + + // 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; + c.performance_fee = crate::wad::WAD / 10; + + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + let expect = crate::wad::compute_fee_shares(cur, 1_000, c.performance_fee, ts_before); + + let old_recipient = c.fee_recipient.clone(); + let old_balance = c.balance_of(&old_recipient); + + // Switch fee recipient; should accrue to old recipient first + let new_recipient = accounts(3); + c.set_fee_recipient(new_recipient.clone()); + + assert_eq!( + c.balance_of(&old_recipient), + old_balance + expect, + "fees must accrue to the old recipient before switching" + ); + assert_eq!( + c.total_supply(), + ts_before + expect, + "total supply must increase by minted fee shares" + ); + assert_eq!( + c.fee_recipient, new_recipient, + "recipient should be updated" + ); + assert_eq!( + c.last_total_assets, cur, + "last_total_assets must update to current after accrual" + ); +} + +#[test] +fn set_fee_recipient_accrues_before_switch_variant() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = accounts(1); + setup_env(&vault_id, &owner, vec![]); + + // 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 = crate::wad::WAD / 20; // 5% + + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + let expect = crate::wad::compute_fee_shares(cur, 2_000, c.performance_fee, ts_before); + + let old_recipient = c.fee_recipient.clone(); + let old_balance = c.balance_of(&old_recipient); + + // Switch fee recipient; should accrue to old recipient first + let new_recipient = accounts(3); + c.set_fee_recipient(new_recipient.clone()); + + assert_eq!( + c.balance_of(&old_recipient), + old_balance + expect, + "fees must accrue to the old recipient before switching" + ); + assert_eq!( + c.total_supply(), + ts_before + expect, + "total supply must increase by minted fee shares" + ); + assert_eq!( + c.fee_recipient, new_recipient, + "recipient should be updated" + ); + assert_eq!( + c.last_total_assets, cur, + "last_total_assets must update to current after accrual" + ); +} + +#[test] +fn set_performance_fee_accrues_with_old_rate_then_updates() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c + .own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + setup_env(&vault_id, &owner, vec![]); + + // 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; + + // Old rate = 10%, new rate = 1% + c.performance_fee = crate::wad::WAD / 10; + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + let expect_old = crate::wad::compute_fee_shares(cur, 1_000, c.performance_fee, ts_before); + + let recipient = c.fee_recipient.clone(); + let bal_before = c.balance_of(&recipient); + + c.set_performance_fee(U128(crate::wad::WAD / 100)); + + assert_eq!( + c.balance_of(&recipient), + bal_before + expect_old, + "accrual must use the old fee rate before updating" + ); + assert_eq!( + c.total_supply(), + ts_before + expect_old, + "total supply must reflect fee shares minted at old rate" + ); + assert_eq!( + c.performance_fee, + crate::wad::WAD / 100, + "performance fee must be updated to the new rate" + ); + assert_eq!( + c.last_total_assets, cur, + "last_total_assets must update to current after accrual" + ); +} + +#[test] +fn set_performance_fee_accrues_with_old_rate_then_updates_variant() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = c + .own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + setup_env(&vault_id, &owner, vec![]); + + // 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 = crate::wad::WAD / 20; // 5% + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + let expect_old = crate::wad::compute_fee_shares(cur, 2_000, c.performance_fee, ts_before); + + let recipient = c.fee_recipient.clone(); + let bal_before = c.balance_of(&recipient); + + c.set_performance_fee(U128(crate::wad::WAD / 200)); // 0.5% + + assert_eq!( + c.balance_of(&recipient), + bal_before + expect_old, + "accrual must use the old fee rate before updating" + ); + assert_eq!( + c.total_supply(), + ts_before + expect_old, + "total supply must reflect fee shares minted at old rate" + ); + assert_eq!( + c.performance_fee, + crate::wad::WAD / 200, + "performance fee must be updated to the new rate" + ); + assert_eq!( + c.last_total_assets, cur, + "last_total_assets must update to current after accrual" + ); +} + +#[test] +fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // 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; + c.performance_fee = crate::wad::WAD / 10; + + let ts_before = c.total_supply(); + let fr = c.fee_recipient.clone(); + let bal_before = c.balance_of(&fr); + let cur = c.get_total_assets().0; + + c.internal_accrue_fee(); + + assert_eq!( + c.total_supply(), + ts_before, + "no shares should be minted when cur < last_total_assets" + ); + assert_eq!( + c.balance_of(&fr), + bal_before, + "fee recipient balance must remain unchanged on loss" + ); + assert_eq!( + c.last_total_assets, cur, + "last_total_assets must update to current even on loss" + ); +} diff --git a/contract/vault/tests/conversions.rs b/contract/vault/tests/conversions.rs index d8090c65..8dd66ff9 100644 --- a/contract/vault/tests/conversions.rs +++ b/contract/vault/tests/conversions.rs @@ -1,5 +1,5 @@ use rstest::rstest; -use templar_vault_contract::*; +use templar_vault_contract::{wad::compute_fee_shares, *}; const W: u128 = WAD; From a5b6df9381cbb49692ea3e97fcbdd8c571be199c Mon Sep 17 00:00:00 2001 From: peer2 Date: Mon, 20 Oct 2025 13:08:14 +0100 Subject: [PATCH 054/121] chore: comments from call --- common/src/vault.rs | 1 + contract/vault/src/lib.rs | 2 ++ contract/vault/src/wad.rs | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 2d58e1e6..eccdc635 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -213,6 +213,7 @@ pub struct PendingValue { pub enum OpState { Idle, Allocating { + // FIXME: docs pls op_id: u64, index: u32, remaining: u128, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 28547a58..079b0bf9 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -806,6 +806,8 @@ impl Contract { /// NOTE: Each allocation takes roughly [common::vault::ALLOCATE_GAS] gas. (~21 TGAS) /// So in one allocation cycle, we should do at most ~12 market allocations. /// This is a conservative estimate, and may need to be tweaked. + /// + /// NOTE: here we should use a delta based approach for allocation plans when we rewrite this for `reallocate` #[payable] pub fn allocate( &mut self, diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index f4f3add9..163ece93 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -1,9 +1,9 @@ /// Fixed-point helpers and fee-accrual math using 24-decimal WAD precision. use templar_common::primitive_types::{U256, U512}; -// TODO: possibly change to u256 for more precision pub const WAD: u128 = 1_000_000_000_000_000_000_000_000u128; +// ! FIXME: wrap this in a newtype so we don't mix them around WadFraction(U256) pub type WADFraction = u128; pub type WIDE = U512; From 4f329220397a8d98020a1ceaf63140cf46bda204 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 20 Oct 2025 11:57:55 +0100 Subject: [PATCH 055/121] wip: refactor numbers and generic style changes for quality tests --- contract/vault/src/impl_callbacks.rs | 9 +- contract/vault/src/impl_token_receiver.rs | 68 +- contract/vault/src/lib.rs | 397 ++++++----- contract/vault/src/tests.rs | 787 +++++++++++++++++++++- contract/vault/src/wad.rs | 425 ++++++++++-- contract/vault/tests/conversions.rs | 131 ++-- 6 files changed, 1458 insertions(+), 359 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 2cd9598d..97ae6beb 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -146,13 +146,8 @@ impl Contract { self.market_supply.insert(market.clone(), new_principal); // Invariant: withdraw_queue gains any market with new_principal > 0 - if new_principal > 0 && !self.withdraw_queue.iter().any(|m| m == &market) { - // If the market had pre-existing principal but wasn't in the withdraw_queue, - // bump last_total_assets by that pre-existing amount to avoid fee accrual on re-inclusion. - if before.0 > 0 { - self.last_total_assets = self.last_total_assets.saturating_add(before.0); - } - self.withdraw_queue.push(market.clone()); + if new_principal > 0 { + self.add_market_to_withdraw_queue(&market, before.0); } self.op_state = OpState::Allocating { diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 9d948d45..13f7d841 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -9,6 +9,31 @@ use templar_common::{ #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; +// Parses JSON-encoded DepositMsg or panics with a consistent message. +fn parse_deposit_msg(msg: &str) -> DepositMsg { + near_sdk::serde_json::from_str(msg).unwrap_or_else(|_| env::panic_str("Invalid deposit msg")) +} + +// Validates NEP-245 transfer inputs and returns (depositor, token_id, amount). +fn validate_single_mt_input<'a>( + previous_owner_ids: &'a [AccountId], + token_ids: &'a [TokenId], + amounts: &'a [U128], +) -> (AccountId, &'a TokenId, U128) { + require!( + token_ids.len() == 1, + "This contract only accepts one token at a time." + ); + require!( + previous_owner_ids.len() == 1 && amounts.len() == 1, + "Invalid input length" + ); + let depositor = previous_owner_ids[0].clone(); + let token_id = &token_ids[0]; + let amount = amounts[0]; + (depositor, token_id, amount) +} + #[near] impl FungibleTokenReceiver for Contract { /// NEP-141 token receiver for deposits. @@ -20,10 +45,7 @@ impl FungibleTokenReceiver for Contract { amount: U128, msg: String, ) -> PromiseOrValue { - const RETURN_STYLE: ReturnStyle = ReturnStyle::Nep141FtTransferCall; - - let msg = near_sdk::serde_json::from_str::(&msg) - .unwrap_or_else(|_| env::panic_str("Invalid deposit msg")); + let msg = parse_deposit_msg(&msg); let asset_id = env::predecessor_account_id(); @@ -44,41 +66,31 @@ impl Nep245Receiver for Contract { /// Returns a one-element vector with the unused amount to refund to the sender. fn mt_on_transfer( &mut self, - sender_id: AccountId, + _sender_id: AccountId, previous_owner_ids: Vec, token_ids: Vec, amounts: Vec, msg: String, ) -> PromiseOrValue> { - const RETURN_STYLE: ReturnStyle = ReturnStyle::Nep245MtTransferCall; - - let msg = near_sdk::serde_json::from_str::(&msg) - .unwrap_or_else(|_| env::panic_str("Invalid deposit msg")); - - require!( - token_ids.len() == 1, - "This contract only accepts one token at a time." - ); - require!( - previous_owner_ids.len() == 1 && amounts.len() == 1, - "Invalid input length" - ); + let msg = parse_deposit_msg(&msg); - let token_id = &token_ids[0]; - let sender_id = previous_owner_ids[0].clone(); - let amount = amounts[0]; + let (depositor, token_id, amount) = + validate_single_mt_input(&previous_owner_ids, &token_ids, &amounts); match msg { DepositMsg::Supply => { require_at_least(SUPPLY_GAS); - let mt = env::predecessor_account_id(); + let token_contract = env::predecessor_account_id(); - if !self.underlying_asset.is_nep245(&mt, token_id) { - Event::DepositRejectedWrongAsset { token: mt }.emit(); + if !self.underlying_asset.is_nep245(&token_contract, token_id) { + Event::DepositRejectedWrongAsset { + token: token_contract, + } + .emit(); return PromiseOrValue::Value(vec![amount]); } - let refund = self.execute_supply(sender_id.clone(), mt, amount.into()); + let refund = self.execute_supply(depositor.clone(), token_contract, amount.into()); PromiseOrValue::Value(vec![U128(refund)]) } @@ -90,13 +102,13 @@ impl Contract { pub(crate) fn execute_supply( &mut self, sender_id: AccountId, - token_id: AccountId, + asset_id: AccountId, deposit: u128, ) -> u128 { // Invariant: Only the underlying token is accepted; others are fully refunded - if token_id != self.underlying_asset.contract_id() { + if asset_id != self.underlying_asset.contract_id() { Event::DepositRejectedWrongAsset { - token: token_id.clone(), + token: asset_id.clone(), } .emit(); return deposit; diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 079b0bf9..c8dd45b0 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -5,13 +5,10 @@ use std::{ num::NonZeroU8, }; -use crate::{ - storage_management::{ - require_attached_at_least, require_attached_for_pending_withdrawal, - storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, - yocto_for_pending_cap, - }, - wad::compute_fee_shares, +use crate::storage_management::{ + require_attached_at_least, require_attached_for_pending_withdrawal, + storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, + yocto_for_pending_cap, }; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ @@ -95,7 +92,7 @@ pub struct Contract { /// TODO: decimal offset for virtual shares /// Performance fee (as WAD fraction) - performance_fee: wad::WADFraction, + performance_fee: wad::Wad, fee_recipient: AccountId, skim_recipient: AccountId, /// Last recorded total assets (for fee accrual) @@ -338,7 +335,7 @@ impl Contract { Self::require_owner(); require!(account != self.fee_recipient, "Already set to this address"); - if self.performance_fee != 0 { + if self.performance_fee != wad::Wad::zero() { // Accrue any pending fees to current recipient before changing (so current recipient gets up to now) self.internal_accrue_fee(); } @@ -353,15 +350,21 @@ impl Contract { pub fn set_performance_fee(&mut self, fee: U128) { Self::require_owner(); - let fee: u128 = fee.into(); + let fee_wad = wad::Wad::from(fee.0); - require!(fee != self.performance_fee, "Fee already set to this value"); - require!(fee <= (wad::WAD / 10), "fee too high"); + require!( + fee_wad != self.performance_fee, + "Fee already set to this value" + ); + require!(fee_wad <= (wad::Wad::one() / 10), "fee too high"); // Accrue any pending fees with old rate before changing self.internal_accrue_fee(); - self.performance_fee = fee; - Event::PerformanceFeeSet { fee: U128(fee) }.emit(); + self.performance_fee = fee_wad; + Event::PerformanceFeeSet { + fee: U128(u128::from(fee_wad)), + } + .emit(); } /* ----- Timelocks / Pending ----- */ @@ -440,6 +443,11 @@ impl Contract { } require_attached_at_least(required_deposit, "submit_cap"); + require!( + self.pending_cap.get(&market).is_none(), + "Policy violation: A cap change is already pending for this market" + ); + let config = match self.config.get_mut(&market) { None => { self.config @@ -450,18 +458,11 @@ impl Contract { .emit(); // Pre-allocate a market_supply record (principal=0) so allocations don't create storage later self.market_supply.insert(market.clone(), 0); - #[allow(clippy::unwrap_used, reason = "No side effects")] - self.config - .get_mut(&market) - .unwrap_or_else(|| env::panic_str(&"Config not found after insert".to_string())) + self.cfg_mut(&market) } Some(config) => config, }; - require!( - self.pending_cap.get(&market).is_none(), - "Policy violation: A cap change is already pending for this market" - ); require!( config.removable_at == 0, "Market removal pending, cannot change cap" @@ -494,67 +495,59 @@ impl Contract { pub fn accept_cap(&mut self, market: AccountId) { Self::assert_curator_or_owner(); self.ensure_idle(); - if let Some(pending) = self.pending_cap.get(&market) { - require!( - env::block_timestamp() >= pending.valid_at, - "Timelock not elapsed for cap change" - ); - #[allow(clippy::expect_used, reason = "No side effects")] - let cfg = self - .config - .get_mut(&market) - .unwrap_or_else(|| env::panic_str(&"Market not found".to_string())); - - cfg.cap = pending.value.into(); - if pending.value > 0 { - // If enabling or raising cap above 0, mark enabled and add to withdraw_queue if not already present - if !cfg.enabled { - cfg.enabled = true; - let mut added = false; - if self.withdraw_queue.iter().any(|m| m == &market) { - Event::MarketEnabled { - market: market.clone(), - } - .emit(); - Event::MarketAlreadyInWithdrawQueue { - market: market.clone(), - } - .emit(); - } else { - require_attached_at_least( - yocto_for_bytes(storage_bytes_for_queue_account_id()), - "withdraw queue entry", - ); - self.withdraw_queue.push(market.clone()); - Event::MarketEnabled { - market: market.clone(), - } - .emit(); - Event::WithdrawQueueMarketAdded { - market: market.clone(), - } - .emit(); - added = true; - } + let (pending_value, pending_valid_at) = match self.pending_cap.get(&market) { + Some(p) => (p.value, p.valid_at), + None => env::panic_str("No pending cap change for this market"), + }; - // Only adjust last_total_assets if we just re-added the market to the withdraw queue - if added { - let current = self.market_supply.get(&market).unwrap_or(&0); - self.last_total_assets = self.last_total_assets.saturating_add(*current); - } - } - cfg.removable_at = 0; + require!( + env::block_timestamp() >= pending_valid_at, + "Timelock not elapsed for cap change" + ); + + let was_enabled = self.cfg(&market).enabled; + let in_queue = self.in_withdraw_queue(&market); + let before_principal = self.principal_of(&market); + + let cfg = self.cfg_mut(&market); + cfg.cap = pending_value.into(); + if pending_value > 0 { + if !cfg.enabled { + cfg.enabled = true; } - Event::SupplyCapSet { + cfg.removable_at = 0; + } + + // If we just enabled the market, ensure it's in the withdraw queue + if pending_value > 0 && !was_enabled { + Event::MarketEnabled { market: market.clone(), - new_cap: U128(pending.value), } .emit(); - self.pending_cap.remove(&market); - } else { - env::panic_str("No pending cap change for this market"); + + if in_queue { + Event::MarketAlreadyInWithdrawQueue { + market: market.clone(), + } + .emit(); + } else { + let _ = require_attached_at_least( + yocto_for_bytes(storage_bytes_for_queue_account_id()), + "withdraw queue entry", + ); + self.add_market_to_withdraw_queue(&market, before_principal); + } + } + + Event::SupplyCapSet { + market: market.clone(), + new_cap: U128(pending_value), } + .emit(); + + // Finally, clear the pending cap record + self.pending_cap.remove(&market); } /// Revokes any pending cap change for `market`. @@ -807,7 +800,8 @@ impl Contract { /// So in one allocation cycle, we should do at most ~12 market allocations. /// This is a conservative estimate, and may need to be tweaked. /// - /// NOTE: here we should use a delta based approach for allocation plans when we rewrite this for `reallocate` + /// + /// NOTE: When we rewrite this we should use a delta based approach #[payable] pub fn allocate( &mut self, @@ -914,7 +908,7 @@ impl Contract { // TODO: join let mut sum = self.idle_balance; self.withdraw_queue.iter().for_each(|m| { - sum += self.market_supply.get(m).unwrap_or(&0); + sum = sum.saturating_add(self.principal_of(m)); }); U128(sum) } @@ -946,7 +940,7 @@ impl Contract { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); - U128(mul_div_floor(a, new_total_supply, new_total_assets)) + U128(mul_div_floor(a.into(), new_total_supply.into(), new_total_assets.into()).into()) } /// Converts an amount of shares to underlying assets, flooring the result. @@ -957,7 +951,7 @@ impl Contract { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); - U128(mul_div_floor(s, new_total_assets, new_total_supply)) + U128(mul_div_floor(s.into(), new_total_assets.into(), new_total_supply.into()).into()) } /// Preview the number of shares minted for a deposit of `assets` (floored). @@ -974,11 +968,7 @@ impl Contract { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); - U128(crate::wad::mul_div_ceil( - s, - new_total_assets, - new_total_supply, - )) + U128(mul_div_ceil(s.into(), new_total_assets.into(), new_total_supply.into()).into()) } /// Preview the number of shares required to withdraw `assets` (ceiled). @@ -989,11 +979,7 @@ impl Contract { return U128(0); } let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); - U128(crate::wad::mul_div_ceil( - a, - new_total_supply, - new_total_assets, - )) + U128(mul_div_ceil(a.into(), new_total_supply.into(), new_total_assets.into()).into()) } /// Preview the amount of assets received by redeeming `shares` (floored). @@ -1017,6 +1003,63 @@ impl From for (u128, u128) { /* ----- Private Helpers ----- */ impl Contract { + fn cfg_mut(&mut self, id: &AccountId) -> &mut MarketConfiguration { + self.config + .get_mut(id) + .unwrap_or_else(|| env::panic_str("Config not found")) + } + + // Read-only config accessor with consistent panic + fn cfg(&self, id: &AccountId) -> &MarketConfiguration { + self.config + .get(id) + .unwrap_or_else(|| env::panic_str("Config not found")) + } + + // Principal (vault-supplied) units currently recorded for a market + fn principal_of(&self, market: &AccountId) -> u128 { + *self.market_supply.get(market).unwrap_or(&0) + } + + // Current cap value for a market (0 if unknown) + fn cap_of(&self, market: &AccountId) -> u128 { + self.config.get(market).map_or(0, |c| c.cap.0) + } + + // Remaining room until cap for a market + fn room_of(&self, market: &AccountId) -> u128 { + self.cap_of(market) + .saturating_sub(self.principal_of(market)) + } + + // Membership check: is market in withdraw_queue? + fn in_withdraw_queue(&self, market: &AccountId) -> bool { + self.withdraw_queue.iter().any(|m| m == market) + } + + // Add market to withdraw_queue and adjust last_total_assets if re-adding with existing principal + pub(crate) fn add_market_to_withdraw_queue( + &mut self, + market: &AccountId, + before_principal: u128, + ) { + if self.in_withdraw_queue(market) { + Event::MarketAlreadyInWithdrawQueue { + market: market.clone(), + } + .emit(); + return; + } + self.withdraw_queue.push(market.clone()); + Event::WithdrawQueueMarketAdded { + market: market.clone(), + } + .emit(); + if before_principal > 0 { + self.last_total_assets = self.last_total_assets.saturating_add(before_principal); + } + } + /// Enqueue a vault-level pending withdrawal request (escrow already taken). fn enqueue_pending_withdrawal( &mut self, @@ -1057,14 +1100,15 @@ impl Contract { fn effective_totals_fee_aware(&self) -> (u128, u128) { let cur = self.get_total_assets().0; let ts = self.total_supply(); - Self::compute_effective_totals( - cur, - self.last_total_assets, + let (new_total_supply, new_total_assets) = Self::compute_effective_totals( + cur.into(), + self.last_total_assets.into(), self.performance_fee, - ts, - self.virtual_shares, - self.virtual_assets, - ) + ts.into(), + self.virtual_shares.into(), + self.virtual_assets.into(), + ); + (new_total_supply.into(), new_total_assets.into()) } // Pure helper to compute how many escrowed shares to burn on partial payout @@ -1074,17 +1118,22 @@ impl Contract { collected: u128, requested_total: u128, ) -> u128 { - mul_div_floor(escrow_shares, collected, requested_total.max(1)) + mul_div_floor( + escrow_shares.into(), + collected.into(), + requested_total.max(1).into(), + ) + .into() } pub(crate) fn compute_effective_totals( - cur_assets: u128, - last_total_assets: u128, - performance_fee: u128, - total_supply: u128, - virtual_shares: u128, - virtual_assets: u128, - ) -> (u128, u128) { + cur_assets: Number, + last_total_assets: Number, + performance_fee: wad::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); let new_total_supply = total_supply @@ -1123,13 +1172,13 @@ impl Contract { // Invariant: Fees are minted only when total_assets() > last_total_assets (no fees on losses/flat). let cur = self.get_total_assets().0; let fee_shares = compute_fee_shares( - cur, - self.last_total_assets, + cur.into(), + self.last_total_assets.into(), self.performance_fee, - self.total_supply(), + self.total_supply().into(), ); - if fee_shares > 0 { - self.mint_shares(&self.fee_recipient.clone(), fee_shares); + if fee_shares > Number::zero() { + self.mint_shares(&self.fee_recipient.clone(), fee_shares.into()); } self.last_total_assets = cur; } @@ -1192,21 +1241,34 @@ impl Contract { self.step_allocation() } - fn step_allocation(&mut self) -> PromiseOrValue<()> { - let (op_id, index, remaining) = match &self.op_state { - OpState::Allocating { - 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); - } + // Helper: build a supply transfer_call and chain after_supply_1_check + fn supply_and_then(&self, market: &AccountId, amount: u128, op_id: u64, index: u32) -> Promise { + self.underlying_asset + .transfer_call( + market, + U128(amount).into(), + Some( + #[allow(clippy::expect_used, reason = "Infallible")] + serde_json::to_string(&templar_common::market::DepositMsg::Supply) + .unwrap_or_else(|e| env::panic_str(&e.to_string())) + .as_str(), + ), + ) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(AFTER_SUPPLY_ENSURE_GAS) + .with_unused_gas_weight(0) + .after_supply_1_check(op_id, index, U128(amount)), + ) + } - // If a per-op allocation plan exists, use it as weighted priority; otherwise, fall back to supply_queue order. + // 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) { @@ -1222,12 +1284,10 @@ impl Contract { let target = if sum_w == 0 || idx + 1 == plan.len() { remaining } else { - mul_div_floor(remaining, *weight, sum_w) + mul_div_floor(remaining.into(), (*weight).into(), sum_w.into()).into() }; - let cap = self.config.get(&market_id).map_or(0, |c| c.cap.0); - let cur = *self.market_supply.get(&market_id).unwrap_or(&0); - let room = cap.saturating_sub(cur); + let room = self.room_of(&market_id); let to_supply = room.min(target); Event::AllocationStepPlanned { @@ -1264,34 +1324,25 @@ impl Contract { return self.step_allocation(); } - return PromiseOrValue::Promise( - self.underlying_asset - .transfer_call( - &market_id, - U128(to_supply).into(), - Some( - #[allow(clippy::expect_used, reason = "Infallible")] - serde_json::to_string(&templar_common::market::DepositMsg::Supply) - .unwrap_or_else(|e| env::panic_str(&e.to_string())) - .as_str(), - ), - ) - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_SUPPLY_ENSURE_GAS) - .with_unused_gas_weight(0) - .after_supply_1_check(op_id, index, U128(to_supply)), - ), - ); + PromiseOrValue::Promise(self.supply_and_then(&market_id, to_supply, op_id, index)) + } else { + // Plan exhausted; stop and reconcile remaining in stop_and_exit + self.stop_and_exit::(None) } - // Plan exhausted; stop and reconcile remaining in stop_and_exit - return 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.get(index) { - let cap = self.config.get(market).map_or(0, |c| c.cap.0); - let cur = self.market_supply.get(market).unwrap_or(&0); - let room = cap.saturating_sub(*cur); + let room = self.room_of(market); let to_supply = room.min(remaining); // Emit planned step event (queue-based) @@ -1324,30 +1375,34 @@ impl Contract { }; return self.step_allocation(); } - PromiseOrValue::Promise( - self.underlying_asset - .transfer_call( - market, - U128(to_supply).into(), - Some( - #[allow(clippy::expect_used, reason = "Infallible")] - serde_json::to_string(&templar_common::market::DepositMsg::Supply) - .unwrap_or_else(|e| env::panic_str(&e.to_string())) - .as_str(), - ), - ) - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_SUPPLY_ENSURE_GAS) - .with_unused_gas_weight(0) - .after_supply_1_check(op_id, index, U128(to_supply)), - ), - ) + + PromiseOrValue::Promise(self.supply_and_then(&market, to_supply, op_id, index)) } else { self.stop_and_exit(Some("Market not found")) } } + fn step_allocation(&mut self) -> PromiseOrValue<()> { + let (op_id, index, remaining) = match &self.op_state { + OpState::Allocating { + 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 3fc1a2a9..b1041575 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,16 +1,25 @@ +use std::u64; + use crate::storage_management::storage_bytes_for_queue_account_id; use crate::storage_management::yocto_for_bytes; use crate::storage_management::yocto_for_new_market; +use crate::storage_management::yocto_for_pending_cap; use crate::test_utils::*; +use crate::wad::compute_fee_shares; use crate::Contract; +use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver as _; use near_sdk::env; +use near_sdk::serde_json; use near_sdk::test_utils::accounts; +use near_sdk::PromiseOrValue; use near_sdk::{json_types::U128, AccountId}; use near_sdk_contract_tools::ft::Nep141Controller as _; +use near_sdk_contract_tools::mt::Nep245Receiver as _; use near_sdk_contract_tools::owner::OwnerExternal; use rstest::rstest; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; +use templar_common::vault::{AllocationMode, DepositMsg}; #[rstest(len => [2usize, 3, 5])] #[should_panic = "Duplicate market"] @@ -71,7 +80,7 @@ fn fee_accrues_only_on_growth_unit() { c.idle_balance = 1_000; // Set fee to 10% - c.performance_fee = crate::wad::WAD / 10; + c.performance_fee = crate::wad::Wad::one() / 10; // Baseline: last_total_assets = current, so no profit => no fee c.last_total_assets = c.get_total_assets().0; @@ -81,16 +90,16 @@ fn fee_accrues_only_on_growth_unit() { // Simulate profit: increase idle_balance; now fees should mint c.idle_balance = 1_500; - let expect = crate::wad::compute_fee_shares( - c.get_total_assets().0, - c.last_total_assets, + let expect = compute_fee_shares( + c.get_total_assets().0.into(), + c.last_total_assets.into(), c.performance_fee, - c.total_supply(), + c.total_supply().into(), ); c.internal_accrue_fee(); assert_eq!( c.total_supply(), - ts_before + expect, + ts_before + expect.as_u128_trunc(), "fee shares minted must match compute_fee_shares" ); } @@ -396,28 +405,25 @@ fn compute_burn_shares_cases(escrow: u128, collected: u128, requested: u128, exp fn compute_effective_totals_fee_share_and_virtuals() { let vault_id = accounts(0); setup_env(&vault_id, &vault_id, vec![]); - let c = new_test_contract(&vault_id); - let cur = 1_500u128; - let last = 1_000u128; - let perf = crate::wad::WAD / 10; // 10% - let ts = 1_000u128; - let vs = 1u128; - let va = 1u128; + let cur = 1_500u128.into(); + let last = 1_000u128.into(); + let perf = crate::wad::Wad::one() / 10; // 10% + let ts = 1_000u128.into(); + let vs = 1u128.into(); + let va = 1u128.into(); let (nts, nta) = Contract::compute_effective_totals(cur, last, perf, ts, vs, va); - let expected_fee = crate::wad::compute_fee_shares(cur, last, perf, ts); + let expected_fee = compute_fee_shares(cur, last, perf, ts); assert_eq!(nts, ts + expected_fee + vs); assert_eq!(nta, cur + va); } -#[test] #[test] fn compute_escrow_settlement_burns_min_and_refunds_rest() { let vault_id = accounts(0); setup_env(&vault_id, &vault_id, vec![]); - let c = new_test_contract(&vault_id); let s1: (u128, u128) = Contract::compute_escrow_settlement(100, 40).into(); assert_eq!(s1, (40u128, 60u128)); @@ -797,11 +803,16 @@ fn set_fee_recipient_accrues_before_switch() { // Simulate profit: last=1000, current=1500 c.idle_balance = 1_500; c.last_total_assets = 1_000; - c.performance_fee = crate::wad::WAD / 10; + c.performance_fee = crate::wad::Wad::one() / 10; let cur = c.get_total_assets().0; let ts_before = c.total_supply(); - let expect = crate::wad::compute_fee_shares(cur, 1_000, c.performance_fee, ts_before); + let expect = compute_fee_shares( + cur.into(), + 1_000.into(), + c.performance_fee, + ts_before.into(), + ); let old_recipient = c.fee_recipient.clone(); let old_balance = c.balance_of(&old_recipient); @@ -812,12 +823,12 @@ fn set_fee_recipient_accrues_before_switch() { assert_eq!( c.balance_of(&old_recipient), - old_balance + expect, + old_balance + expect.as_u128_trunc(), "fees must accrue to the old recipient before switching" ); assert_eq!( c.total_supply(), - ts_before + expect, + ts_before + expect.as_u128_trunc(), "total supply must increase by minted fee shares" ); assert_eq!( @@ -843,11 +854,16 @@ fn set_fee_recipient_accrues_before_switch_variant() { // Simulate profit: last=2000, current=2400 c.idle_balance = 2_400; c.last_total_assets = 2_000; - c.performance_fee = crate::wad::WAD / 20; // 5% + c.performance_fee = crate::wad::Wad::one() / 20; // 5% let cur = c.get_total_assets().0; let ts_before = c.total_supply(); - let expect = crate::wad::compute_fee_shares(cur, 2_000, c.performance_fee, ts_before); + let expect = compute_fee_shares( + cur.into(), + 2_000.into(), + c.performance_fee, + ts_before.into(), + ); let old_recipient = c.fee_recipient.clone(); let old_balance = c.balance_of(&old_recipient); @@ -858,12 +874,12 @@ fn set_fee_recipient_accrues_before_switch_variant() { assert_eq!( c.balance_of(&old_recipient), - old_balance + expect, + old_balance + expect.as_u128_trunc(), "fees must accrue to the old recipient before switching" ); assert_eq!( c.total_supply(), - ts_before + expect, + ts_before + expect.as_u128_trunc(), "total supply must increase by minted fee shares" ); assert_eq!( @@ -893,29 +909,34 @@ fn set_performance_fee_accrues_with_old_rate_then_updates() { c.last_total_assets = 1_000; // Old rate = 10%, new rate = 1% - c.performance_fee = crate::wad::WAD / 10; + c.performance_fee = crate::wad::Wad::one() / 10; let cur = c.get_total_assets().0; let ts_before = c.total_supply(); - let expect_old = crate::wad::compute_fee_shares(cur, 1_000, c.performance_fee, ts_before); + let expect_old = compute_fee_shares( + cur.into(), + 1_000.into(), + c.performance_fee, + ts_before.into(), + ); let recipient = c.fee_recipient.clone(); let bal_before = c.balance_of(&recipient); - c.set_performance_fee(U128(crate::wad::WAD / 100)); + c.set_performance_fee(U128(u128::from(crate::wad::Wad::one() / 100))); assert_eq!( c.balance_of(&recipient), - bal_before + expect_old, + bal_before + expect_old.as_u128_trunc(), "accrual must use the old fee rate before updating" ); assert_eq!( c.total_supply(), - ts_before + expect_old, + ts_before + expect_old.as_u128_trunc(), "total supply must reflect fee shares minted at old rate" ); assert_eq!( c.performance_fee, - crate::wad::WAD / 100, + crate::wad::Wad::one() / 100, "performance fee must be updated to the new rate" ); assert_eq!( @@ -941,29 +962,34 @@ fn set_performance_fee_accrues_with_old_rate_then_updates_variant() { c.last_total_assets = 2_000; // Old rate = 5%, new rate = 0.5% - c.performance_fee = crate::wad::WAD / 20; // 5% + c.performance_fee = crate::wad::Wad::one() / 20; // 5% let cur = c.get_total_assets().0; let ts_before = c.total_supply(); - let expect_old = crate::wad::compute_fee_shares(cur, 2_000, c.performance_fee, ts_before); + let expect_old = compute_fee_shares( + cur.into(), + 2_000.into(), + c.performance_fee, + ts_before.into(), + ); let recipient = c.fee_recipient.clone(); let bal_before = c.balance_of(&recipient); - c.set_performance_fee(U128(crate::wad::WAD / 200)); // 0.5% + c.set_performance_fee(U128(u128::from(crate::wad::Wad::one() / 200))); // 0.5% assert_eq!( c.balance_of(&recipient), - bal_before + expect_old, + bal_before + expect_old.as_u128_trunc(), "accrual must use the old fee rate before updating" ); assert_eq!( c.total_supply(), - ts_before + expect_old, + ts_before + expect_old.as_u128_trunc(), "total supply must reflect fee shares minted at old rate" ); assert_eq!( c.performance_fee, - crate::wad::WAD / 200, + crate::wad::Wad::one() / 200, "performance fee must be updated to the new rate" ); assert_eq!( @@ -984,7 +1010,7 @@ fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { // Loss scenario: last=1000, current=800 c.idle_balance = 800; c.last_total_assets = 1_000; - c.performance_fee = crate::wad::WAD / 10; + c.performance_fee = crate::wad::Wad::one() / 10; let ts_before = c.total_supply(); let fr = c.fee_recipient.clone(); @@ -1008,3 +1034,690 @@ fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { "last_total_assets must update to current even on loss" ); } + +#[test] +fn ft_on_transfer_supply_accepts_full_and_mints_shares() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let asset = c.underlying_asset.contract_id().into(); + setup_env(&vault_id, &asset, vec![]); + + // Prevent eager allocation from firing in this test + c.mode = AllocationMode::Eager { + min_batch: U128(u128::MAX), + }; + + // Setup a market so max_deposit > 0 + let m = mk(9001); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(100); + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + c.supply_queue.push(m); + + let sender = accounts(1); + let deposit = 50u128; + let expect_shares = c.preview_deposit(U128(deposit)).0; + + let res = c.ft_on_transfer( + sender.clone(), + U128(deposit), + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); + match res { + PromiseOrValue::Value(U128(refund)) => assert_eq!(refund, 0, "no refund expected"), + _ => panic!("expected Value refund"), + } + + assert_eq!( + c.balance_of(&sender), + expect_shares, + "sender must receive expected shares" + ); + assert_eq!( + c.idle_balance, deposit, + "idle must increase by accepted deposit" + ); + assert_eq!( + c.last_total_assets, deposit, + "last_total_assets must increase by accepted deposit" + ); + assert!( + matches!(c.op_state, OpState::Idle), + "must remain idle when min_batch not reached" + ); +} + +#[test] +fn ft_on_transfer_supply_partial_refund_when_capped() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let asset = c.underlying_asset.contract_id().into(); + setup_env(&vault_id, &asset, vec![]); + + c.mode = AllocationMode::Eager { + min_batch: U128(u128::MAX), + }; + + let m = mk(9002); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(50); // cap < deposit + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + c.supply_queue.push(m); + + let sender = accounts(2); + let deposit = 80u128; + let accept = 50u128; + let expect_shares = c.preview_deposit(U128(accept)).0; + + let res = c.ft_on_transfer( + sender.clone(), + U128(deposit), + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); + match res { + PromiseOrValue::Value(U128(refund)) => assert_eq!(refund, deposit - accept), + _ => panic!("expected Value refund"), + } + + assert_eq!( + c.balance_of(&sender), + expect_shares, + "shares minted must equal accepted amount preview" + ); + assert_eq!( + c.idle_balance, accept, + "idle increases by accepted amount only" + ); + assert_eq!( + c.last_total_assets, accept, + "last_total_assets increases by accepted amount only" + ); +} + +#[test] +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 + 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 mut cfg = MarketConfiguration::default(); + cfg.cap = U128(100); + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + c.supply_queue.push(m); + + let sender = accounts(3); + let deposit = 70u128; + + let res = c.ft_on_transfer( + sender.clone(), + U128(deposit), + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); + match res { + PromiseOrValue::Value(U128(refund)) => assert_eq!(refund, deposit, "full refund expected"), + _ => panic!("expected Value refund"), + } + assert_eq!(c.balance_of(&sender), 0, "no shares should be minted"); + assert_eq!(c.idle_balance, 0, "idle must remain unchanged"); +} + +#[test] +#[should_panic = "Invalid deposit msg"] +fn ft_on_transfer_invalid_msg_panics() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &vault_id, vec![]); + + let _ = c.ft_on_transfer(accounts(4), U128(10), "not-json".into()); +} + +#[test] +fn ft_on_transfer_zero_amount_returns_zero_refund() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &vault_id, vec![]); + + // Setup a valid market + let m = mk(9004); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(100); + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + c.supply_queue.push(m); + + let sender = accounts(5); + let bal_before = c.balance_of(&sender); + + let res = c.ft_on_transfer( + sender.clone(), + U128(0), + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); + match res { + PromiseOrValue::Value(U128(refund)) => assert_eq!(refund, 0), + _ => panic!("expected Value refund"), + } + assert_eq!( + c.balance_of(&sender), + bal_before, + "no shares should be minted" + ); +} + +#[test] +fn ft_on_transfer_eager_mode_triggers_allocation() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let asset = c.underlying_asset.contract_id().into(); + setup_env(&vault_id, &asset, vec![]); + + // Trigger eager allocation with any positive deposit + c.mode = AllocationMode::Eager { min_batch: U128(1) }; + + // Valid market/cap + let m = mk(9005); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(100); + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + c.supply_queue.push(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() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &vault_id, vec![]); + + let _ = c.mt_on_transfer( + accounts(1), + vec![accounts(1)], + vec!["t".to_string()], + vec![U128(1)], + "bad".into(), + ); +} + +#[test] +#[should_panic = "This contract only accepts one token at a time."] +fn mt_on_transfer_rejects_multiple_tokens() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &vault_id, vec![]); + + let _ = c.mt_on_transfer( + accounts(2), + vec![accounts(2)], + vec!["a".to_string(), "b".to_string()], // len != 1 + vec![U128(1)], + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); +} + +#[test] +#[should_panic = "Invalid input length"] +fn mt_on_transfer_rejects_invalid_input_lengths() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &vault_id, vec![]); + + let _ = c.mt_on_transfer( + accounts(3), + vec![accounts(3), accounts(4)], // len != 1 + vec!["t".to_string()], + vec![U128(1)], + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); +} + +#[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); + setup_env(&vault_id, &vault_id, vec![]); + + let sender = accounts(5); + let amount = 25u128; + + let res = c.mt_on_transfer( + accounts(3), // sender_id (ignored in logic) + vec![sender.clone()], // previous_owner_ids + vec!["token-1".to_string()], // token_ids + vec![U128(amount)], // amounts + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); + match res { + PromiseOrValue::Value(refunds) => { + assert_eq!(refunds.len(), 1); + assert_eq!(refunds[0].0, amount, "full refund expected for wrong asset"); + } + _ => panic!("expected Value refund"), + } + assert_eq!(c.balance_of(&sender), 0, "no shares should be minted"); + assert_eq!(c.idle_balance, 0, "idle must remain unchanged"); +} + +#[test] +fn execute_supply_zero_amount_rejected() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + setup_env(&vault_id, &vault_id, vec![]); + + let sender = accounts(4); + let refund = c.execute_supply(sender.clone(), vault_id.clone(), 0); + assert_eq!(refund, 0, "zero deposit returns zero refund"); + assert_eq!(c.balance_of(&sender), 0, "no shares should be minted"); +} + +#[test] +fn governance_set_curator_grants_allocator() { + 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![]); + + // Prepare a market to exercise allocator permission + let m1 = mk(9101); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(1); + cfg.enabled = true; + c.config.insert(m1.clone(), cfg); + + let new_cur = accounts(3); + c.set_curator(new_cur.clone()); + + // New curator can set supply queue + set_ctx( + &vault_id, + &new_cur, + None, + Some(yocto_for_bytes(storage_bytes_for_queue_account_id())), + ); + c.set_supply_queue(vec![m1.clone()]); + assert_eq!(c.supply_queue.len(), 1); + assert_eq!(c.supply_queue.get(0), Some(&m1)); +} + +#[test] +fn governance_set_is_allocator_grant_allows_queue_ops() { + 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![]); + + let grantee = accounts(4); + + // Market to operate on + let m1 = mk(9102); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(1); + cfg.enabled = true; + c.config.insert(m1.clone(), cfg); + + // Grant Allocator role + c.set_is_allocator(grantee.clone(), true); + + // Grantee can set supply queue + set_ctx( + &vault_id, + &grantee, + None, + Some(yocto_for_bytes(storage_bytes_for_queue_account_id())), + ); + c.set_supply_queue(vec![m1.clone()]); + assert_eq!(c.supply_queue.len(), 1); + assert_eq!(c.supply_queue.get(0), Some(&m1)); +} + +#[test] +#[should_panic] +fn governance_set_is_allocator_revoke_disallows_queue_ops() { + 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![]); + + let grantee = accounts(12); + c.set_is_allocator(grantee.clone(), true); + + // Market to attempt on + let m1 = mk(9103); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(1); + cfg.enabled = true; + c.config.insert(m1.clone(), cfg); + + // Revoke Allocator role; subsequent queue op by grantee should panic due to lack of rights + c.set_is_allocator(grantee.clone(), false); + set_ctx( + &vault_id, + &grantee, + None, + Some(yocto_for_bytes(storage_bytes_for_queue_account_id())), + ); + c.set_supply_queue(vec![m1]); +} + +#[test] +#[should_panic = "not yet"] +fn governance_accept_guardian_not_yet_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![]); + + c.timelock_ns = u64::MAX; + + let new_g = accounts(5); + c.submit_guardian(new_g); + // Timelock not advanced -> should panic + c.accept_guardian(); +} + +#[test] +fn governance_submit_accept_and_revoke_guardian() { + 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![]); + + let new_g = accounts(4); + c.submit_guardian(new_g.clone()); + + // Advance time beyond timelock and accept + set_ctx( + &vault_id, + &owner, + Some(env::block_timestamp() + 1_000_000_000), + None, + ); + c.accept_guardian(); + + // Stage another pending and then revoke it + let another = accounts(3); + set_ctx(&vault_id, &owner, None, None); + c.submit_guardian(another); + c.revoke_pending_guardian(); + + // No pending now; accept should no-op (but must not panic) + c.accept_guardian(); +} + +#[test] +fn governance_submit_accept_timelock_increase_then_decrease() { + 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![]); + + let cur = c.get_configuration().initial_timelock_sec; + + // Increase applies immediately + c.submit_timelock(cur + 1); + assert_eq!( + c.get_configuration().initial_timelock_sec, + cur + 1, + "timelock should increase immediately" + ); + + // Decrease schedules a pending change + c.submit_timelock(cur); + set_ctx( + &vault_id, + &owner, + Some(env::block_timestamp() + 1_000_000_000), + None, + ); + c.accept_timelock(); + assert_eq!( + c.get_configuration().initial_timelock_sec, + cur, + "timelock should decrease after accept" + ); +} + +#[test] +#[should_panic = "No pending timelock change"] +fn governance_accept_timelock_without_pending_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![]); + + // No pending change -> accept should panic + c.accept_timelock(); +} + +#[test] +#[should_panic = "No pending timelock change"] +fn governance_revoke_pending_timelock_then_accept_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![]); + + let cur = c.get_configuration().initial_timelock_sec; + + // Force a pending by first increasing then decreasing + c.submit_timelock(cur + 1); + c.submit_timelock(cur); + + // Revoke the pending change; accept must now panic + c.revoke_pending_timelock(); + c.accept_timelock(); +} + +#[test] +fn governance_submit_cap_immediate_decrease() { + 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![]); + + let m = mk(9104); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(10); + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + + c.submit_cap(m.clone(), U128(3)); + let after = c.config.get(&m).unwrap(); + assert_eq!(after.cap, U128(3)); +} + +#[test] +fn governance_submit_and_accept_cap_new_market_creates_and_enables() { + 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![]); + + let m = mk(9105); + + // Submit raise for a brand-new market + set_ctx( + &vault_id, + &owner, + None, + Some(yocto_for_new_market() + yocto_for_pending_cap()), + ); + c.submit_cap(m.clone(), U128(5)); + + // Advance timelock and accept; attach storage for withdraw queue addition + set_ctx( + &vault_id, + &owner, + Some(env::block_timestamp() + 1_000_000_000), + Some(yocto_for_bytes(storage_bytes_for_queue_account_id())), + ); + c.accept_cap(m.clone()); + + let cfg = c.config.get(&m).unwrap(); + assert_eq!(cfg.cap.0, 5); + assert!( + cfg.enabled, + "market should be enabled after accepting raise" + ); + assert!( + c.withdraw_queue.iter().any(|x| x == &m), + "market must be in withdraw queue after enabling" + ); +} + +#[test] +#[should_panic = "No pending cap change for this market"] +fn governance_revoke_pending_cap_then_accept_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![]); + + let m = mk(9106); + + // Create pending cap raise for a new market + set_ctx( + &vault_id, + &owner, + None, + Some(yocto_for_new_market() + yocto_for_pending_cap()), + ); + 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); +} + +#[test] +fn governance_submit_and_revoke_market_removal() { + 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![]); + + c.timelock_ns = 1; + let m = mk(9107); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(0); + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + + // Submit removal (schedules timelock) + c.submit_market_removal(m.clone()); + let after = c.config.get(&m).unwrap(); + assert!(after.removable_at > 0, "removal must be scheduled"); + + // Revoke pending removal + c.revoke_pending_market_removal(m.clone()); + let after2 = c.config.get(&m).unwrap(); + assert_eq!(after2.removable_at, 0, "removal must be revoked"); +} + +#[test] +fn governance_set_skim_recipient_updates_field() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = accounts(1); + setup_env(&vault_id, &owner, vec![]); + + let new_recipient = accounts(4); + c.set_skim_recipient(new_recipient.clone()); + assert_eq!(c.skim_recipient, new_recipient); +} + +#[test] +fn governance_set_fee_recipient_no_fee_does_not_accrue() { + let vault_id = accounts(0); + let mut c = new_test_contract(&vault_id); + let owner = accounts(1); + setup_env(&vault_id, &owner, vec![]); + + // 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; + c.last_total_assets = 1_000; + c.performance_fee = crate::wad::Wad::zero(); + + let ts_before = c.total_supply(); + let last_before = c.last_total_assets; + + let new_recipient = accounts(5); + c.set_fee_recipient(new_recipient.clone()); + + assert_eq!( + c.total_supply(), + ts_before, + "no fee shares minted when fee=0" + ); + assert_eq!( + c.last_total_assets, last_before, + "last_total_assets should not change when fee=0" + ); + assert_eq!(c.fee_recipient, new_recipient); +} + +#[test] +fn governance_set_withdraw_queue_happy_path() { + 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 enabled markets + let m1 = mk(9201); + let m2 = mk(9202); + for m in [&m1, &m2] { + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(1); + cfg.enabled = true; + c.config.insert(m.clone(), cfg); + } + + set_ctx( + &vault_id, + &owner, + None, + Some(2 * yocto_for_bytes(storage_bytes_for_queue_account_id())), + ); + c.set_withdraw_queue(vec![m1.clone(), m2.clone()]); + + assert_eq!(c.withdraw_queue.len(), 2); + assert_eq!(c.withdraw_queue.get(0), Some(&m1)); + assert_eq!(c.withdraw_queue.get(1), Some(&m2)); +} diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index 163ece93..437db42f 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -1,48 +1,257 @@ -/// Fixed-point helpers and fee-accrual math using 24-decimal WAD precision. -use templar_common::primitive_types::{U256, U512}; +use core::ops::Div; +use std::ops::{Add, Sub}; -pub const WAD: u128 = 1_000_000_000_000_000_000_000_000u128; +use near_sdk::{ + borsh::{BorshDeserialize, BorshSerialize}, + near, +}; +use templar_common::primitive_types::{U256, U512}; -// ! FIXME: wrap this in a newtype so we don't mix them around WadFraction(U256) -pub type WADFraction = u128; pub type WIDE = U512; -/// Multiplies x by y/WAD and floors: floor(x * y / WAD). -/// Typically, y is a WAD-scaled fraction (1e24 = 100%), and x is an unscaled amount. -#[inline] -#[must_use] -pub fn mul_wad_floor(x: u128, y: u128) -> u128 { - mul_div_floor(x, y, WAD) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Number(pub U256); + +impl Number { + #[inline] + pub fn zero() -> Self { + Number(U256::zero()) + } + #[inline] + pub fn one() -> Self { + Number(U256::one()) + } + #[inline] + pub fn is_zero(&self) -> bool { + self.0.is_zero() + } + #[inline] + pub fn is_one(&self) -> bool { + self.0 == U256::one() + } + #[inline] + pub fn as_u128_trunc(self) -> u128 { + let mut b32 = [0u8; 32]; + self.0.to_little_endian(&mut b32); + let mut b16 = [0u8; 16]; + b16.copy_from_slice(&b32[..16]); + u128::from_le_bytes(b16) + } + #[inline] + fn as_u256_trunc(q: U512) -> U256 { + let mut b64 = [0u8; 64]; + q.to_little_endian(&mut b64); + U256::from_little_endian(&b64[..32]) + } + #[inline] + pub fn saturating_add(self, other: Number) -> Number { + Number(self.0.saturating_add(other.0)) + } + #[inline] + pub fn saturating_sub(self, other: Number) -> Number { + Number(self.0.saturating_sub(other.0)) + } + #[inline] + #[must_use] + pub fn mul_div_floor(x: Number, y: Number, denom: Number) -> Number { + if denom.is_zero() { + return Number::zero(); + } + let prod = x.0.full_mul(y.0); + let q = prod / U512::from(denom.0); + Number(Self::as_u256_trunc(q)) + } + #[inline] + #[must_use] + pub fn mul_div_ceil(x: Number, y: Number, denom: Number) -> Number { + if denom.is_zero() { + return Number::zero(); + } + let prod = x.0.full_mul(y.0); + let d = U512::from(denom.0); + let q = prod / d; + let r = prod % d; + let base = Number(Self::as_u256_trunc(q)); + if !r.is_zero() { + base.saturating_add(Number::one()) + } else { + base + } + } } -/// Multiplies and divides with flooring: floor(x * y / denom). -/// Uses 512-bit intermediate (U512) to avoid overflow; returns 0 if denom is 0. -#[inline] -#[must_use] -pub fn mul_div_floor(x: u128, y: u128, denom: u128) -> u128 { - if denom == 0 { - return 0; +impl From for Number { + #[inline] + fn from(v: u128) -> Self { + Number(U256::from(v)) + } +} +impl From for u128 { + #[inline] + fn from(n: Number) -> u128 { + n.as_u128_trunc() + } +} +impl From for Number { + #[inline] + fn from(v: U256) -> Self { + Number(v) + } +} +impl From for U256 { + #[inline] + fn from(n: Number) -> U256 { + n.0 + } +} +impl Div for Number { + type Output = Number; + #[inline] + fn div(self, rhs: u128) -> Number { + Number(self.0 / U256::from(rhs)) + } +} +impl Div for Number { + type Output = Number; + #[inline] + fn div(self, rhs: U256) -> Number { + Number(self.0 / rhs) + } +} +impl Div for Number { + type Output = Number; + #[inline] + fn div(self, rhs: Number) -> Number { + Number(self.0 / rhs.0) + } +} +impl Add for Number { + type Output = Number; + #[inline] + fn add(self, rhs: Number) -> Number { + Number(self.0 + rhs.0) + } +} +impl Sub for Number { + type Output = Number; + #[inline] + fn sub(self, rhs: Number) -> Number { + Number(self.0 - rhs.0) } - let num = WIDE::from(x) * WIDE::from(y); - let q = num / WIDE::from(denom); - q.as_u128() } -/// Multiplies and divides with ceiling: ceil(x * y / denom). -/// Uses 512-bit intermediate (U512) to avoid overflow; returns 0 if denom is 0. -/// Implemented via quotient/remainder to avoid relying on addition overflow behavior. -#[inline] -#[must_use] -pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { - if denom == 0 { - return 0; +impl BorshSerialize for Number { + #[inline] + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + let mut b32 = [0u8; 32]; + self.0.to_little_endian(&mut b32); + writer.write_all(&b32) + } +} + +impl BorshDeserialize for Number { + #[inline] + fn deserialize_reader(reader: &mut R) -> std::io::Result { + let mut b32 = [0u8; 32]; + reader.read_exact(&mut b32)?; + Ok(Number(U256::from_little_endian(&b32))) + } +} + +/// A 24-decimal fixed-point value (1e24 = 100%), backed by U256. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Wad(pub Number); + +impl Wad { + /// Scaling factor (1e24). + pub const SCALE: u128 = 1_000_000_000_000_000_000_000_000u128; + + /// Returns zero. + #[inline] + #[must_use] + pub fn zero() -> Self { + Wad(Number::zero()) + } + + /// Returns one unit (1.0 in WAD scale). + #[inline] + #[must_use] + pub fn one() -> Self { + Wad(Number(U256::from(Self::SCALE))) + } + + #[inline] + pub fn is_zero(&self) -> bool { + self.0.is_zero() + } + + #[inline] + pub fn is_one(&self) -> bool { + self.0 .0 == U256::from(Self::SCALE) + } + + /// Returns the lower 128 bits (truncation) of this WAD value. + #[inline] + #[must_use] + pub fn as_u128_trunc(self) -> u128 { + self.0.as_u128_trunc() + } + + /// Applies this WAD-scaled fraction to an unscaled Number, floored. + #[inline] + #[must_use] + pub fn apply_floored(self, amount: Number) -> Number { + if amount.is_zero() || self.0.is_zero() { + return Number::zero(); + } + let prod = amount.0.full_mul(self.0 .0); + let q = prod / U512::from(Self::SCALE); + Number(Number::as_u256_trunc(q)) + } +} + +impl From for Wad { + #[inline] + fn from(v: u128) -> Self { + Wad(Number::from(v)) + } +} + +impl From for u128 { + #[inline] + fn from(w: Wad) -> u128 { + w.as_u128_trunc() + } +} + +impl Div for Wad { + type Output = Wad; + #[inline] + fn div(self, rhs: u128) -> Wad { + Wad(self.0 / rhs) + } +} +impl Div for Wad { + type Output = Wad; + #[inline] + fn div(self, rhs: Number) -> Wad { + Wad(self.0 / rhs) + } +} + +impl BorshSerialize for Wad { + #[inline] + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + self.0.serialize(writer) + } +} + +impl BorshDeserialize for Wad { + #[inline] + fn deserialize_reader(reader: &mut R) -> std::io::Result { + let inner = ::deserialize_reader(reader)?; + Ok(Wad(inner)) } - let num = WIDE::from(x) * WIDE::from(y); - let d = WIDE::from(denom); - let q = num / d; - let r = num % d; - let base = q.as_u128(); - base.saturating_add((!r.is_zero()) as u128) } /// Computes fee shares to mint given: @@ -56,45 +265,69 @@ pub fn mul_div_ceil(x: u128, y: u128, denom: u128) -> u128 { #[inline] #[must_use] pub fn compute_fee_shares( - cur_total_assets: u128, - last_total_assets: u128, - performance_fee: u128, - total_supply: u128, -) -> u128 { - if performance_fee == 0 || total_supply == 0 || cur_total_assets <= last_total_assets { - return 0; + cur_total_assets: Number, + last_total_assets: Number, + performance_fee: Wad, + total_supply: Number, +) -> Number { + if performance_fee.is_zero() || total_supply.is_zero() || cur_total_assets <= last_total_assets + { + return Number::zero(); } - let profit = cur_total_assets - last_total_assets; - let fee_assets = mul_wad_floor(profit, performance_fee); - let denom = cur_total_assets.saturating_sub(fee_assets); - - if denom == 0 { - return 0; + let profit = cur_total_assets.saturating_sub(last_total_assets); + if profit.is_zero() { + return Number::zero(); } - - if fee_assets == 0 { - return 0; + let fee_assets = performance_fee.apply_floored(profit); + if fee_assets.is_zero() { + return Number::zero(); + } + if fee_assets.0 >= cur_total_assets.0 { + return Number::zero(); } + let denom = Number(cur_total_assets.0 - fee_assets.0); + Number::mul_div_floor(fee_assets, total_supply, denom) +} - // ERC-4626-like: mint shares so that fee_shares / (total_supply + fee_shares) = fee_assets / cur_total_assets - // Rearranged and floored: - mul_div_floor(fee_assets, total_supply, denom) +/// Multiplies x by y/Wad::SCALE and floors: floor(x * y / 1e24). +/// y is a WAD-scaled fraction (1e24 = 100%), and x is an unscaled amount. +#[inline] +#[must_use] +pub fn mul_wad_floor(x: Number, y: Wad) -> Number { + y.apply_floored(x) +} + +/// Multiplies and divides with flooring: floor(x * y / denom). +/// Uses 512-bit intermediate (U512) to avoid overflow; returns 0 if denom is 0. +#[inline] +#[must_use] +pub fn mul_div_floor(x: Number, y: Number, denom: Number) -> Number { + Number::mul_div_floor(x, y, denom) +} + +/// Multiplies and divides with ceiling: ceil(x * y / denom). +/// Uses 512-bit intermediate (U512) to avoid overflow; returns 0 if denom is 0. +/// Implemented via quotient/remainder to avoid relying on addition overflow behavior. +#[inline] +#[must_use] +pub fn mul_div_ceil(x: Number, y: Number, denom: Number) -> Number { + Number::mul_div_ceil(x, y, denom) } #[cfg(test)] mod tests { use super::*; - const W: u128 = WAD; - #[test] fn mul_wad_floor_rounds_down() { // 0.3333... * 0.3333... ~= 0.1111... - let third = W / 3; - let res = mul_wad_floor(third, third); - // floor(1/9 * W) = floor(0.111... * 1e24) - assert!(res <= W / 9); - assert_eq!(res, (W / 9) - 1); // typical floor loss + let third_raw = Number::from(u128::from(Wad::one()) / 3); + let third = Wad::one() / 3; + let res = mul_wad_floor(third_raw, third); + let res_u128: u128 = res.into(); + // floor(1/9 * 1e24) + assert!(res_u128 <= u128::from(Wad::one()) / 9); + assert_eq!(res_u128, (u128::from(Wad::one()) / 9) - 1); // typical floor loss } #[test] @@ -106,39 +339,85 @@ mod tests { // Fake a contract-like environment: let ts = 10_000u128; let ta = 12_000u128; - let to_sh = mul_div_floor(a, ts + 1, ta + 1); - let back_a = mul_div_floor(to_sh, ta + 1, ts + 1); + let to_sh: u128 = + mul_div_floor(Number::from(a), Number::from(ts + 1), Number::from(ta + 1)).into(); + let back_a: u128 = mul_div_floor( + Number::from(to_sh), + Number::from(ta + 1), + Number::from(ts + 1), + ) + .into(); assert!(back_a <= a); - let to_a = mul_div_floor(s, ta + 1, ts + 1); - let back_s = mul_div_ceil(to_a, ts + 1, ta + 1); + let to_a: u128 = + mul_div_floor(Number::from(s), Number::from(ta + 1), Number::from(ts + 1)).into(); + let back_s: u128 = mul_div_ceil( + Number::from(to_a), + Number::from(ts + 1), + Number::from(ts + 1), + ) + .into(); assert!(back_s >= s); } #[test] fn compute_fee_shares_no_profit_or_zero_fee_or_zero_supply() { // no profit => 0 - assert_eq!(compute_fee_shares(1_000, 1_000, W / 10, 1_000), 0); + assert_eq!( + u128::from(compute_fee_shares( + Number::from(1_000), + Number::from(1_000), + Wad::one() / 10, + Number::from(1_000) + )), + 0 + ); // zero fee => 0 - assert_eq!(compute_fee_shares(2_000, 1_000, 0, 1_000), 0); + assert_eq!( + u128::from(compute_fee_shares( + Number::from(2_000), + Number::from(1_000), + Wad::zero(), + Number::from(1_000) + )), + 0 + ); // zero supply => 0 - assert_eq!(compute_fee_shares(2_000, 1_000, W / 10, 0), 0); + assert_eq!( + u128::from(compute_fee_shares( + Number::from(2_000), + Number::from(1_000), + Wad::one() / 10, + Number::from(0) + )), + 0 + ); } #[test] fn compute_fee_shares_mints_proportionally_on_profit() { // cur=1500, last=1000, profit=500, fee=10% => fee_assets=50 // denom = 1500 - 50 = 1450; total_supply=1000 => fee_shares=floor(50*1000/1450)=34 - let fee = W / 10; - let minted = compute_fee_shares(1_500, 1_000, fee, 1_000); - assert_eq!(minted, 34); + let fee = Wad::one() / 10; + let minted = compute_fee_shares( + Number::from(1_500), + Number::from(1_000), + fee, + Number::from(1_000), + ); + assert_eq!(u128::from(minted), 34); } #[test] fn compute_fee_shares_handles_extreme_fee() { // 100% fee on positive profit: fee_assets=profit; denom=cur_total_assets - fee_assets - let minted = compute_fee_shares(2_000, 1_000, W, 1_000); + let minted = compute_fee_shares( + Number::from(2_000), + Number::from(1_000), + Wad::one(), + Number::from(1_000), + ); // fee_assets=1000; denom=1_000 (2_000 - 1_000) => floor(1_000*1_000/1_000)=1_000 - assert_eq!(minted, 1_000); + assert_eq!(u128::from(minted), 1_000); } } diff --git a/contract/vault/tests/conversions.rs b/contract/vault/tests/conversions.rs index 8dd66ff9..34865789 100644 --- a/contract/vault/tests/conversions.rs +++ b/contract/vault/tests/conversions.rs @@ -1,29 +1,52 @@ use rstest::rstest; use templar_vault_contract::{wad::compute_fee_shares, *}; -const W: u128 = WAD; - #[test] fn no_fee_returns_zero() { - assert_eq!(compute_fee_shares(1_000, 900, 0, 1_000), 0); + assert_eq!( + compute_fee_shares(1_000.into(), 900.into(), Wad::zero(), 1_000.into()), + Number::zero() + ); } #[test] fn no_profit_returns_zero() { - assert_eq!(compute_fee_shares(1_000, 1_000, W / 10, 1_000), 0); - assert_eq!(compute_fee_shares(900, 1_000, W / 10, 1_000), 0); + assert_eq!( + compute_fee_shares( + 1_000.into(), + 1_000.into(), + Wad::one() / 10u128, + 1_000.into() + ), + Number::zero() + ); + assert_eq!( + compute_fee_shares(900.into(), 1_000.into(), Wad::one() / 10u128, 1_000.into()), + Number::zero() + ); } #[test] fn zero_supply_returns_zero() { - assert_eq!(compute_fee_shares(1_000, 900, W / 10, 0), 0); + assert_eq!( + compute_fee_shares(1_000.into(), 900.into(), Wad::one() / 10u128, 0u128.into()), + Number::zero() + ); } #[test] fn simple_accrual_10_percent_fee() { // cur=1200, last=1000, profit=200, fee_assets=20 // fee_shares = floor(20 * 1000 / (1200-20)) = floor(20000/1180) = 16 - assert_eq!(compute_fee_shares(1200, 1000, W / 10, 1000), 16); + assert_eq!( + u128::from(compute_fee_shares( + 1200u128.into(), + 1000u128.into(), + Wad::one() / 10u128, + 1000u128.into() + )), + 16 + ); } #[test] @@ -31,66 +54,88 @@ fn full_fee_100_percent() { // cur=1200, last=1000, profit=200, fee_assets=200 // denom = 1200 - 200 = 1000 // fee_shares = 200*1000/1000 = 200 - assert_eq!(compute_fee_shares(1200, 1000, W, 1000), 200); + assert_eq!( + u128::from(compute_fee_shares( + 1200u128.into(), + 1000u128.into(), + Wad::one(), + 1000u128.into() + )), + 200 + ); } // Property: Shares minting never panics, never mints more than `accept` when price ≥ 1 // Model: minted = floor(accept * S / A); price ≥ 1 <=> A >= S => minted ≤ accept #[rstest( - accept => [0u128, 1, 2, 10, 1u128<<32, 1u128<<64, u128::MAX/2, u128::MAX-1], - supply => [0u128, 1, 10, 1u128<<32, 1u128<<64, u128::MAX/2], - assets_base => [1u128, 2, 10, 1u128<<32, 1u128<<64, u128::MAX/2, u128::MAX-1] + accept => [0u128.into(), 1u128.into(), 2u128.into(), 10u128.into(), (1u128<<32).into(), (1u128<<64).into(), (u128::MAX/2).into(), (u128::MAX-1).into()], + supply => [0u128.into(), 1u128.into(), 10u128.into(), (1u128<<32).into(), (1u128<<64).into(), (u128::MAX/2).into()], + assets_base => [1u128.into(), 2u128.into(), 10u128.into(), (1u128<<32).into(), (1u128<<64).into(), (u128::MAX/2).into(), (u128::MAX-1).into()] )] -fn prop_minted_shares_le_accept_when_price_ge_one(accept: u128, supply: u128, assets_base: u128) { - let assets = assets_base.max(supply); // enforce price ≥ 1 +fn prop_minted_shares_le_accept_when_price_ge_one( + accept: Number, + supply: Number, + assets_base: Number, +) { + let assets = core::cmp::max(assets_base, supply); // enforce price ≥ 1 let minted = mul_div_floor(accept, supply, assets); assert!( minted <= accept, - "minted {minted} should be <= accept {accept} when price>=1 (S={supply}, A={assets})" + "minted {minted:?} should be <= accept {accept:?} when price>=1 (S={supply:?}, A={assets:?})" ); } // Property: Fee shares are 0 when not profitable (cur_total_assets <= last_total_assets) #[rstest( - perf => [0u128, W/100, W/10], - last => [0u128, 1u128, 1u128<<32], - ts => [0u128, 1u128, 1u128<<64] - )] -fn prop_fee_zero_when_not_profitable(perf: u128, last: u128, ts: u128) { + perf => [Wad::zero(), Wad::one() / Number::from(100u128), Wad::one() / Number::from(10u128)], + last => [0u128.into(), 1u128.into(), (1u128<<32).into()], + ts => [0u128.into(), 1u128.into(), (1u128<<64).into()] +)] +fn prop_fee_zero_when_not_profitable(perf: Wad, last: Number, ts: Number) { let cur_equal = last; - let cur_lower = last.saturating_sub(1); - assert_eq!(compute_fee_shares(cur_equal, last, perf, ts), 0); - assert_eq!(compute_fee_shares(cur_lower, last, perf, ts), 0); + let cur_lower = last.saturating_sub(Number::one()); + assert_eq!( + compute_fee_shares(cur_equal, last, perf, ts), + Number::zero() + ); + assert_eq!( + compute_fee_shares(cur_lower, last, perf, ts), + Number::zero() + ); } #[rstest( - s =>[0u128, 1, 13, 1<<32, 1<<64], - a =>[1u128, 7, 1<<32, 1<<64, (1u128<<64) + 123], - k =>[0u128, 1, 2, 10, 1<<16] + s =>[0u128.into(), 1u128.into(), 13u128.into(), (1u128<<32).into(), (1u128<<64).into()], + a =>[1u128.into(), 7u128.into(), (1u128<<32).into(), (1u128<<64).into(), ((1u128<<64) + 123).into()], + k =>[0u128.into(), 1u128.into(), 2u128.into(), 10u128.into(), (1u128<<16).into()] )] -fn deposit_is_monotone_in_assets(s: u128, a: u128, k: u128) { +fn deposit_is_monotone_in_assets(s: Number, a: Number, k: Number) { // More assets never produce fewer shares (with fixed totals & offsets). - let shares1 = mul_div_floor(a, s + 1, a + k + 1); - let shares2 = mul_div_floor(a + 1, s + 1, a + k + 2); + let shares1 = mul_div_floor(a, s + Number::one(), a + k + Number::one()); + let shares2 = mul_div_floor( + a + Number::one(), + s + Number::one(), + a + k + Number::from(2u128), + ); assert!(shares2 >= shares1); } // Property: Fee shares are monotone =>profit when fee>0 and total_supply>0 #[rstest( - perf => [W/100, W/10], - last => [0u128, 1u128<<32], - ts => [1u128, 1u128<<64], - p1 => [0u128, 1u128, 1u128<<16], - p2 => [1u128, 1u128<<16, 1u128<<32] + perf => [Wad::one()/100u128, Wad::one()/10u128], + last => [0u128.into(), (1u128<<32).into()], + ts => [1u128.into(), (1u128<<64).into()], + p1 => [0u128.into(), 1u128.into(), (1u128<<16).into()], + p2 => [1u128.into(), (1u128<<16).into(), (1u128<<32).into()] )] -fn prop_fee_monotone_in_profit(perf: u128, last: u128, ts: u128, p1: u128, p2: u128) { - let p_low = p1.min(p2); - let p_high = p1.max(p2); +fn prop_fee_monotone_in_profit(perf: Wad, last: Number, ts: Number, p1: Number, p2: Number) { + let p_low = core::cmp::min(p1, p2); + let p_high = core::cmp::max(p1, p2); let s1 = compute_fee_shares(last.saturating_add(p_low), last, perf, ts); let s2 = compute_fee_shares(last.saturating_add(p_high), last, perf, ts); assert!( s2 >= s1, - "fee shares should be monotone =>profit: s2 {s2} >= s1 {s1} (last={last}, perf={perf}, ts={ts})" + "fee shares should be monotone =>profit: s2 {s2:?} >= s1 {s1:?} (last={last:?}, perf={perf:?}, ts={ts:?})" ); } @@ -114,13 +159,13 @@ fn prop_withdraw_math_never_underflows(before: u128, newp: u128, need: u128, rem } #[rstest( - fee =>[0u128, W/100, W/10], - ts =>[0u128, 1, 1<<32, 1<<64], - last =>[0u128, 1, 1<<32], - profit =>[0u128, 1, 10, 1<<32] + fee =>[Wad::zero(), Wad::one()/100u128, Wad::one()/10u128], + ts =>[0u128.into(), 1u128.into(), (1u128<<32).into(), (1u128<<64).into()], + last =>[0u128.into(), 1u128.into(), (1u128<<32).into()], + profit =>[0u128.into(), 1u128.into(), 10u128.into(), (1u128<<32).into()] )] -fn fee_shares_upper_bound_by_total_supply(fee: u128, ts: u128, last: u128, profit: u128) { +fn fee_shares_upper_bound_by_total_supply(fee: Wad, ts: Number, last: Number, profit: Number) { let cur = last.saturating_add(profit); let minted = compute_fee_shares(cur, last, fee, ts); - assert!(minted <= ts || ts == 0); + assert!(minted <= ts || ts.is_zero()); } From 32cf90a7fdf38a7628aa657e1a215bff29b36199 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 21 Oct 2025 12:00:05 +0100 Subject: [PATCH 056/121] chore: borshschema --- contract/vault/src/wad.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index 437db42f..ad1a1cb6 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -1,8 +1,10 @@ use core::ops::Div; +use std::collections::BTreeMap; use std::ops::{Add, Sub}; +use near_sdk::borsh::schema::{add_definition, Declaration, Definition, Fields}; use near_sdk::{ - borsh::{BorshDeserialize, BorshSerialize}, + borsh::{BorshDeserialize, BorshSchema, BorshSerialize}, near, }; use templar_common::primitive_types::{U256, U512}; @@ -254,6 +256,29 @@ impl BorshDeserialize for Wad { } } +// FIXME: test these +impl BorshSchema for Number { + fn add_definitions_recursively(definitions: &mut BTreeMap) { + let definition = Definition::Primitive(32); + add_definition(Self::declaration(), definition, definitions); + } + + fn declaration() -> Declaration { + "Number".into() + } +} + +impl BorshSchema for Wad { + fn add_definitions_recursively(definitions: &mut BTreeMap) { + let definition = Definition::Primitive(32); + add_definition(Self::declaration(), definition, definitions); + } + + fn declaration() -> Declaration { + "Wad".into() + } +} + /// Computes fee shares to mint given: /// - `cur_total_assets`: current total assets under management /// - `last_total_assets`: previous total assets snapshot From c9bebae8c3e411f6e48b0e9beb2c1838acdb6cb5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 22 Oct 2025 10:08:26 +0100 Subject: [PATCH 057/121] refactor: clean up --- common/src/vault.rs | 8 -- contract/vault/src/impl_callbacks.rs | 166 +++++++++++++-------------- 2 files changed, 77 insertions(+), 97 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index eccdc635..ec9e42b6 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -445,14 +445,6 @@ pub enum Event { // Withdrawal read diagnostics #[event_version("1.0.0")] - WithdrawalPositionMissing { - op_id: U64, - market: AccountId, - index: u32, - before: U128, - need: U128, - }, - #[event_version("1.0.0")] WithdrawalPositionReadFailed { op_id: U64, market: AccountId, diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 97ae6beb..6804c779 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -10,9 +10,8 @@ use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - Callbacks, Event, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_EXEC_WITHDRAW_READ_GAS, - AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_POSITION_CHECK_GAS, EXECUTE_WITHDRAW_REQ_GAS, - GET_SUPPLY_POSITION_GAS, + Event, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_EXEC_WITHDRAW_READ_GAS, AFTER_SEND_TO_USER_GAS, + AFTER_SUPPLY_POSITION_CHECK_GAS, EXECUTE_WITHDRAW_REQ_GAS, GET_SUPPLY_POSITION_GAS, }, }; @@ -21,19 +20,17 @@ impl Contract { #[private] pub fn after_supply_1_check( &mut self, - #[callback_result] accepted: Result, // NOTE: we probably can't rely on - // this as a `true` value of accepted, so we are taking a belt-and-braces approach of + // NOTE: we can't rely on this as a `true` value of accepted, so we are taking a belt-and-braces approach of // querying the supply position + #[callback_result] accepted: Result, op_id: u64, market_index: u32, attempted: U128, ) -> PromiseOrValue<()> { - // Invariant: Index drift or stale op_id results in a graceful stop - let () = if let Err(e) = self.ctx_allocating(op_id) { + if let Err(e) = self.ctx_allocating(op_id) { return self.stop_and_exit(Some(&e)); }; - // Resolve market by plan or supply_queue let market = match self.resolve_supply_market(market_index) { Ok(m) => m, Err(e) => return self.stop_and_exit(Some(&e)), @@ -82,17 +79,15 @@ impl Contract { attempted: U128, accepted: U128, ) -> PromiseOrValue<()> { - let (idx, rem) = match self.ctx_allocating(op_id) { + let (i, remaining) = match self.ctx_allocating(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), }; - // Invariant: Index drift or stale op_id results in a graceful stop - if idx != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + if i != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(i, market_index))); } - // Resolve market by plan (if present) or supply_queue let market = match self.resolve_supply_market(market_index) { Ok(m) => m, Err(e) => return self.stop_and_exit(Some(&e)), @@ -102,7 +97,7 @@ impl Contract { Ok(Some(position)) => { let new_principal: u128 = position.get_deposit().total().into(); let accepted_event = new_principal.saturating_sub(before.0); - let remaining = rem.saturating_sub(accepted_event); + let remaining = remaining.saturating_sub(accepted_event); (new_principal, accepted_event, remaining) } Ok(None) => { @@ -166,14 +161,14 @@ impl Contract { market_index: u32, need: U128, ) -> PromiseOrValue<()> { - let (idx, rem, recv, coll, owner, escrow_shares) = match self.ctx_withdrawing(op_id) { - Ok(v) => v, - Err(e) => return self.stop_and_exit(Some(&e)), - }; + let (i, remaining, received, collected, owner, escrow_shares) = + match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), + }; - // Invariant: Index drift or stale op_id results in a graceful stop - if idx != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + if i != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(i, market_index))); } let market = match self.resolve_withdraw_market(market_index) { @@ -181,12 +176,11 @@ impl Contract { Err(e) => return self.stop_and_exit(Some(&e)), }; - if let Ok(()) = did_create { + if did_create.is_ok() { PromiseOrValue::Promise( ext_market::ext(market.clone()) .with_static_gas(EXECUTE_WITHDRAW_REQ_GAS) - // TODO: we can only do this if there is sufficient liquidity in the market, we - // should check that there is first, but even so, we can be rugged + .with_unused_gas_weight(0) .execute_next_supply_withdrawal_request() .then( ext_self::ext(env::current_account_id()) @@ -199,9 +193,9 @@ impl Contract { self.op_state = OpState::Withdrawing { op_id, index: market_index + 1, - remaining: rem, - receiver: recv, - collected: coll, + remaining: remaining, + receiver: received, + collected: collected, owner, escrow_shares, }; @@ -216,14 +210,13 @@ impl Contract { market_index: u32, need: U128, ) -> PromiseOrValue<()> { - let (idx, _rem, _recv, _coll, _owner, _escrow_shares) = match self.ctx_withdrawing(op_id) { + let (i, _, _, _, _, _) = match self.ctx_withdrawing(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), }; - // Invariant: Index drift or stale op_id results in a graceful stop - if idx != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + if i != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(i, market_index))); } let market = match self.resolve_withdraw_market(market_index) { @@ -255,13 +248,14 @@ impl Contract { before: U128, need: U128, ) -> PromiseOrValue<()> { - let (idx, rem, recv, coll, owner, escrow_shares) = match self.ctx_withdrawing(op_id) { - Ok(v) => v, - Err(e) => return self.stop_and_exit(Some(&e)), - }; + let (i, remaining, received, collected, owner, escrow_shares) = + match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), + }; - if idx != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(idx, market_index))); + if i != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(i, market_index))); } let market = match self.resolve_withdraw_market(market_index) { @@ -277,15 +271,6 @@ impl Contract { } Ok(None) => { // No position => treat as principal = 0 - // NOTE: this is a successful withdraw - Event::WithdrawalPositionMissing { - op_id: op_id.into(), - market: market.clone(), - index: market_index, - before: U128(before_principal), - need, - } - .emit(); 0 } Err(_) => { @@ -301,10 +286,14 @@ impl Contract { } }; - let (credited, remaining, collected, idle_delta) = - self.reconcile_withdraw_outcome(before_principal, new_principal, need.0, rem, coll); + let (_credited, remaining, collected, idle_delta) = self.reconcile_withdraw_outcome( + before_principal, + new_principal, + need.0, + remaining, + collected, + ); - // Update accounting to match market state self.market_supply.insert(market.clone(), new_principal); if idle_delta > 0 { self.idle_balance = self.idle_balance.saturating_add(idle_delta); @@ -314,7 +303,7 @@ impl Contract { if collected > 0 { self.op_state = OpState::Payout { op_id, - receiver: recv.clone(), + receiver: received.clone(), amount: collected, owner: owner.clone(), escrow_shares, @@ -323,16 +312,17 @@ impl Contract { PromiseOrValue::Promise( self.underlying_asset .clone() - .transfer(recv.clone(), U128(collected).into()) + .transfer(received.clone(), U128(collected).into()) .then( ext_self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, recv, U128(collected)), + .after_send_to_user(op_id, received, U128(collected)), ), ) } else { // Nothing collected; refund escrowed shares let self_id = env::current_account_id(); + // We expect the owner to maintain storage accounts, otherwise they will lose access to their funds self.transfer_unchecked(&self_id, &owner, escrow_shares) .expect("Failed to refund escrowed shares"); self.op_state = OpState::Idle; @@ -343,7 +333,7 @@ impl Contract { op_id, index: market_index + 1, remaining, - receiver: recv, + receiver: received, collected, owner, escrow_shares, @@ -360,16 +350,16 @@ impl Contract { receiver: AccountId, amount: U128, ) -> bool { - let (owner, escrow_shares, payout_amount, burn_shares) = match &self.op_state { + let (owner, escrow_shares, amount, burn_shares) = match &self.op_state { OpState::Payout { - op_id: cur, - receiver: r, - amount: a, + op_id: current_op, + receiver: recv, + amount, owner, escrow_shares, burn_shares, - } if *cur == op_id && *r == receiver => { - (owner.clone(), *escrow_shares, *a, *burn_shares) + } if *current_op == op_id && *recv == receiver => { + (owner.clone(), *escrow_shares, *amount, *burn_shares) } _ => { Event::PayoutUnexpectedState { @@ -382,10 +372,10 @@ impl Contract { } }; - if let Ok(()) = result { - // Invariant: On payout success, idle_balance -= payout_amount. + if result.is_ok() { + // On payout success, idle_balance -= payout_amount. // Burn only the proportional shares and refund the remainder to the owner. - self.idle_balance = self.idle_balance.saturating_sub(payout_amount); + self.idle_balance = self.idle_balance.saturating_sub(amount); let EscrowSettlement { to_burn, refund } = Self::compute_escrow_settlement(escrow_shares, burn_shares); if to_burn > 0 { @@ -400,7 +390,7 @@ impl Contract { self.op_state = OpState::Idle; true } else { - // Invariant: On payout failure, refund full escrow to owner and leave idle_balance unchanged + // On payout failure, refund full escrow to owner and leave idle_balance unchanged #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&env::current_account_id(), &owner, escrow_shares) .unwrap_or_else(|e| env::panic_str(&e.to_string())); @@ -442,7 +432,7 @@ impl Contract { } impl Contract { - fn stop_and_exit_allocating( + pub fn stop_and_exit_allocating( &mut self, msg: Option<&T>, ) { @@ -452,7 +442,6 @@ impl Contract { remaining, } = &self.op_state { - // Emit completion vs stop event before reconciling remaining match msg { None => { Event::AllocationCompleted { op_id: *op_id }.emit(); @@ -477,7 +466,7 @@ impl Contract { } /// Stop helper for Withdrawing: refund escrowed shares to owner and go Idle. - fn stop_and_exit_withdrawing( + pub fn stop_and_exit_withdrawing( &mut self, msg: Option<&T>, ) { @@ -501,27 +490,27 @@ impl Contract { } .emit(); } - let (owner_acc, escrow) = match &self.op_state { + if let Some((owner_acc, escrow)) = match &self.op_state { OpState::Withdrawing { owner, escrow_shares, .. - } => (Some(owner.clone()), *escrow_shares), - _ => (None, 0), - }; - if let (Some(owner_acc), escrow) = (owner_acc, escrow) { - if escrow > 0 { - let self_id = env::current_account_id(); - #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&self_id, &owner_acc, escrow) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); - } + } if *escrow_shares > 0 => Some((owner.clone(), *escrow_shares)), + _ => None, + } { + let self_id = env::current_account_id(); + #[allow(clippy::expect_used, reason = "No side effects")] + self.transfer_unchecked(&self_id, &owner_acc, escrow) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); } self.op_state = OpState::Idle; } - /// Payout: refund escrowed shares to owner and go Idle. - fn stop_and_exit_payout(&mut self, msg: Option<&T>) { + /// refund escrowed shares to owner and go Idle. + pub fn stop_and_exit_payout( + &mut self, + msg: Option<&T>, + ) { { if let OpState::Payout { op_id, @@ -539,17 +528,16 @@ impl Contract { .emit(); } } - let (owner_acc, escrow) = match &self.op_state { - OpState::Payout { - owner, - escrow_shares, - .. - } => (Some(owner.clone()), *escrow_shares), - _ => (None, 0), - }; - if let (Some(owner_acc), escrow) = (owner_acc, escrow) { - if escrow > 0 { + if let OpState::Payout { + owner, + escrow_shares, + .. + } = &self.op_state + { + if *escrow_shares > 0 { let self_id = env::current_account_id(); + let owner_acc = owner.clone(); + let escrow = *escrow_shares; #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&self_id, &owner_acc, escrow) .unwrap_or_else(|e| env::panic_str(&e.to_string())); From 8efea166dff8c6a4070bd3ae8fce897af81915df Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 22 Oct 2025 10:45:30 +0100 Subject: [PATCH 058/121] test: reconcile supply --- contract/vault/src/impl_callbacks.rs | 333 +++++++++++++++++++++++++-- contract/vault/src/lib.rs | 3 +- 2 files changed, 315 insertions(+), 21 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 6804c779..d63a9f0c 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -93,13 +93,16 @@ impl Contract { Err(e) => return self.stop_and_exit(Some(&e)), }; - let (new_principal, accepted_event, remaining_next) = match position { - Ok(Some(position)) => { - let new_principal: u128 = position.get_deposit().total().into(); - let accepted_event = new_principal.saturating_sub(before.0); - let remaining = remaining.saturating_sub(accepted_event); - (new_principal, accepted_event, remaining) - } + let SupplyReconciliation { + new_principal, + accepted_event, + remaining, + } = match position { + Ok(Some(position)) => reconcile_supply_outcome( + &position.get_deposit().total().into(), + &before.0, + &remaining, + ), Ok(None) => { Event::AllocationPositionMissing { op_id: op_id.into(), @@ -134,7 +137,7 @@ impl Contract { accepted: U128(accepted_event), attempted, refunded: U128(refunded), - remaining_after: U128(remaining_next), + remaining_after: U128(remaining), } .emit(); @@ -148,7 +151,7 @@ impl Contract { self.op_state = OpState::Allocating { op_id, index: market_index + 1, - remaining: remaining_next, + remaining, }; self.step_allocation() } @@ -248,7 +251,7 @@ impl Contract { before: U128, need: U128, ) -> PromiseOrValue<()> { - let (i, remaining, received, collected, owner, escrow_shares) = + let (i, remaining, receiver, collected, owner, escrow_shares) = match self.ctx_withdrawing(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), @@ -303,7 +306,7 @@ impl Contract { if collected > 0 { self.op_state = OpState::Payout { op_id, - receiver: received.clone(), + receiver: receiver.clone(), amount: collected, owner: owner.clone(), escrow_shares, @@ -312,11 +315,11 @@ impl Contract { PromiseOrValue::Promise( self.underlying_asset .clone() - .transfer(received.clone(), U128(collected).into()) + .transfer(receiver.clone(), U128(collected).into()) .then( ext_self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, received, U128(collected)), + .after_send_to_user(op_id, receiver, U128(collected)), ), ) } else { @@ -333,7 +336,7 @@ impl Contract { op_id, index: market_index + 1, remaining, - receiver: received, + receiver: receiver, collected, owner, escrow_shares, @@ -648,10 +651,31 @@ impl Contract { } } +pub(crate) struct SupplyReconciliation { + new_principal: u128, + accepted_event: u128, + remaining: u128, +} + +pub(crate) fn reconcile_supply_outcome( + total_position: &u128, + before: &u128, + remaining: &u128, +) -> SupplyReconciliation { + let accepted_event = total_position.saturating_sub(*before); + let remaining = remaining.saturating_sub(accepted_event); + SupplyReconciliation { + new_principal: *total_position, + accepted_event, + remaining, + } +} + #[cfg(test)] mod tests { use std::u128; + use crate::impl_callbacks::reconcile_supply_outcome; use crate::test_utils::*; use near_sdk::json_types::U128; @@ -660,12 +684,26 @@ mod tests { use near_sdk::PromiseResult; use rstest::rstest; + use crate::Contract; + use near_sdk::AccountId; + use rstest::fixture; use templar_common::vault::Error; use templar_common::vault::OpState; - #[test] - fn after_supply_1_check_allocating_not_allocating() { - let vault_id = accounts(0); + #[fixture] + fn vault_id() -> AccountId { + accounts(0) + } + + #[fixture] + fn c(vault_id: AccountId) -> Contract { + setup_env(&vault_id, &vault_id, vec![]); + 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( &vault_id, &vault_id, @@ -674,10 +712,22 @@ mod tests { .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), )], ); + new_test_contract(&vault_id) + } - let mut c = new_test_contract(&vault_id); + #[fixture] + fn receiver() -> AccountId { + mk(9) + } - let receiver = mk(7); + #[fixture] + fn owner() -> AccountId { + accounts(1) + } + + #[rstest] + fn after_supply_1_check_allocating_not_allocating(mut c_max: Contract) { + let mut c = c_max; c.op_state = OpState::Idle; @@ -1201,4 +1251,249 @@ mod tests { Err(Error::MissingMarket(2)) )); } + + #[test] + fn after_supply_2_read_missing_position_stops() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Resolve market via supply_queue + let market = mk(42); + c.supply_queue.push(market); + + // Must be in Allocating ctx + c.op_state = OpState::Allocating { + op_id: 1, + index: 0, + remaining: 10, + }; + + // Missing position -> stop_and_exit + let res = c.after_supply_2_read(Ok(None), 1, 0, U128(0), U128(5), U128(5)); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value on missing position"), + } + assert!(matches!(c.op_state, OpState::Idle)); + } + + #[test] + fn after_supply_2_read_read_failed_stops() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Resolve market via supply_queue + let market = mk(43); + c.supply_queue.push(market); + + // Must be in Allocating ctx + c.op_state = OpState::Allocating { + op_id: 7, + index: 0, + remaining: 100, + }; + + // Read failure -> stop_and_exit + let res = c.after_supply_2_read( + Err(near_sdk::PromiseError::Failed), + 7, + 0, + U128(0), + U128(10), + U128(10), + ); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value on read failure"), + } + assert!(matches!(c.op_state, OpState::Idle)); + } + + #[test] + fn after_create_withdraw_req_success_returns_promise() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let market = mk(50); + c.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 100); + + c.op_state = OpState::Withdrawing { + op_id: 21, + index: 0, + remaining: 60, + receiver: mk(9), + collected: 10, + owner: accounts(1), + escrow_shares: 5, + }; + + let res = c.after_create_withdraw_req(Ok(()), 21, 0, U128(60)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise when create succeeds"), + } + // State remains Withdrawing and will continue via the promise chain + assert!(matches!(c.op_state, OpState::Withdrawing { .. })); + } + + #[test] + fn after_exec_withdraw_req_returns_promise() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let market = mk(60); + c.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 10); + + c.op_state = OpState::Withdrawing { + op_id: 33, + index: 0, + remaining: 5, + receiver: mk(9), + collected: 0, + owner: accounts(1), + escrow_shares: 0, + }; + + let res = c.after_exec_withdraw_req(33, 0, U128(5)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to read supply position after exec"), + } + assert!(matches!(c.op_state, OpState::Withdrawing { .. })); + } + + #[test] + fn after_exec_withdraw_read_advances_when_remaining() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Two markets; first has principal to withdraw + let m1 = mk(70); + let m2 = mk(71); + c.withdraw_queue.push(m1.clone()); + c.withdraw_queue.push(m2.clone()); + c.market_supply.insert(m1.clone(), 10); + + let owner = accounts(1); + let receiver = mk(9); + c.op_state = OpState::Withdrawing { + op_id: 0, + index: 0, + remaining: 100, + receiver: receiver.clone(), + collected: 0, + owner: owner.clone(), + escrow_shares: 0, + }; + + // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 + let res = c.after_exec_withdraw_read(Ok(None), 0, 0, U128(10), U128(100)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to continue withdraw steps"), + } + + // Idle credited, state advanced to next index with remaining reduced + assert_eq!(c.idle_balance, 10); + + // This works + match &c.op_state { + OpState::Payout { + op_id, + receiver: r, + amount, + owner: o, + escrow_shares, + burn_shares, + } => { + assert_eq!(*op_id, 0); + assert_eq!(*amount, 10); + assert_eq!(*escrow_shares, 0); + assert_eq!(*burn_shares, 0); + assert_eq!(*r, receiver); + assert_eq!(*o, owner); + } + other => panic!("Unexpected state after advancing: {other:?}"), + } + } + + #[test] + fn stop_and_exit_when_idle_emits_and_stays_idle() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Already Idle; ensure branch is executed + c.op_state = OpState::Idle; + + let res = c.stop_and_exit::<&str>(Some(&"reason")); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value on stop while Idle"), + } + assert!(matches!(c.op_state, OpState::Idle)); + } + #[test] + fn accepts_increase_and_decrements_remaining() { + let out = reconcile_supply_outcome(&1_600, &1_000, &1_000); + let expected_accepted = 1_600u128.saturating_sub(1_000); + let expected_remaining = 1_000u128.saturating_sub(expected_accepted); + + assert_eq!(out.new_principal, 1_600); + assert_eq!(out.accepted_event, expected_accepted); // 600 + assert_eq!(out.remaining, expected_remaining); // 400 + } + + #[test] + fn no_accept_when_total_does_not_increase() { + // decreased + let out = reconcile_supply_outcome(&1_500, &2_000, &5_000); + assert_eq!(out.new_principal, 1_500); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 5_000); + + // equal + let out = reconcile_supply_outcome(&2_000, &2_000, &1_234); + assert_eq!(out.new_principal, 2_000); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 1_234); + } + + #[test] + fn remaining_saturates_to_zero_when_acceptance_exceeds_it() { + let out = reconcile_supply_outcome(&u128::MAX, &0, &1); + assert_eq!(out.new_principal, u128::MAX); + assert_eq!(out.accepted_event, u128::MAX); + assert_eq!(out.remaining, 0); + + let out = reconcile_supply_outcome(&10_000, &0, &5); + assert_eq!(out.new_principal, 10_000); + assert_eq!(out.accepted_event, 10_000); + assert_eq!(out.remaining, 0); + } + + #[test] + fn handles_extreme_boundaries_correctly() { + let out = reconcile_supply_outcome(&0, &0, &0); + assert_eq!(out.new_principal, 0); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 0); + + let out = reconcile_supply_outcome(&0, &u128::MAX, &123); + assert_eq!(out.new_principal, 0); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 123); + + let out = reconcile_supply_outcome(&u128::MAX, &(u128::MAX - 5), &2); + assert_eq!(out.new_principal, u128::MAX); + assert_eq!(out.accepted_event, 5); + assert_eq!(out.remaining, 0); + } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index c8dd45b0..3bae215a 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -90,8 +90,7 @@ pub struct Contract { /// configuration per market (market ID -> MarketConfig) config: IterableMap, - /// TODO: decimal offset for virtual shares - /// Performance fee (as WAD fraction) + /// Performance fee performance_fee: wad::Wad, fee_recipient: AccountId, skim_recipient: AccountId, From 62a11429b0a1129abc4f00839d93f90440a9b333 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 22 Oct 2025 10:46:29 +0100 Subject: [PATCH 059/121] test: clean up callback tests --- contract/vault/src/impl_callbacks.rs | 55 ++++++++++------------------ 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index d63a9f0c..2e289587 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -823,13 +823,8 @@ mod tests { ); } - #[test] - fn after_exec_withdraw_read_none_to_payout() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - - let mut c = new_test_contract(&vault_id); - + #[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_queue.push(market.clone()); @@ -1311,12 +1306,12 @@ mod tests { assert!(matches!(c.op_state, OpState::Idle)); } - #[test] - fn after_create_withdraw_req_success_returns_promise() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - + #[rstest] + fn after_create_withdraw_req_success_returns_promise( + mut c: Contract, + receiver: AccountId, + owner: AccountId, + ) { let market = mk(50); c.withdraw_queue.push(market.clone()); c.market_supply.insert(market.clone(), 100); @@ -1325,9 +1320,9 @@ mod tests { op_id: 21, index: 0, remaining: 60, - receiver: mk(9), + receiver: receiver.clone(), collected: 10, - owner: accounts(1), + owner: owner.clone(), escrow_shares: 5, }; @@ -1340,12 +1335,8 @@ mod tests { assert!(matches!(c.op_state, OpState::Withdrawing { .. })); } - #[test] - fn after_exec_withdraw_req_returns_promise() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - + #[rstest] + fn after_exec_withdraw_req_returns_promise(mut c: Contract) { let market = mk(60); c.withdraw_queue.push(market.clone()); c.market_supply.insert(market.clone(), 10); @@ -1368,12 +1359,12 @@ mod tests { assert!(matches!(c.op_state, OpState::Withdrawing { .. })); } - #[test] - fn after_exec_withdraw_read_advances_when_remaining() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - + #[rstest] + fn after_exec_withdraw_read_advances_when_remaining( + mut c: Contract, + owner: AccountId, + receiver: AccountId, + ) { // Two markets; first has principal to withdraw let m1 = mk(70); let m2 = mk(71); @@ -1381,8 +1372,6 @@ mod tests { c.withdraw_queue.push(m2.clone()); c.market_supply.insert(m1.clone(), 10); - let owner = accounts(1); - let receiver = mk(9); c.op_state = OpState::Withdrawing { op_id: 0, index: 0, @@ -1424,12 +1413,8 @@ mod tests { } } - #[test] - fn stop_and_exit_when_idle_emits_and_stays_idle() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - + #[rstest] + fn stop_and_exit_when_idle_emits_and_stays_idle(mut c: Contract) { // Already Idle; ensure branch is executed c.op_state = OpState::Idle; From 3a74a17ed5054f8aa6f62c1807cebd3a3a3d7fff Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 22 Oct 2025 11:00:45 +0100 Subject: [PATCH 060/121] test: idle tests --- contract/vault/src/impl_callbacks.rs | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 2e289587..b11c148c 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -1481,4 +1481,82 @@ mod tests { assert_eq!(out.accepted_event, 5); assert_eq!(out.remaining, 0); } + + #[rstest] + 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; + + // 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())); + + // Enter Payout with non-zero escrow + c.op_state = OpState::Payout { + op_id: 123, + receiver: receiver.clone(), + amount: 77, + owner: owner.clone(), + escrow_shares: escrow, + burn_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 idle_before = c.idle_balance; + + c.stop_and_exit_payout::<&str>(Some(&"reason")); + + // Escrow refunded, no burn, vault goes Idle + assert!(matches!(c.op_state, OpState::Idle)); + assert_eq!(c.total_supply(), supply_before, "No burn/mint on stop"); + assert_eq!( + c.balance_of(&near_sdk::env::current_account_id()), + vault_before.saturating_sub(escrow), + "Vault should transfer escrow to owner" + ); + assert_eq!( + c.balance_of(&owner), + owner_before.saturating_add(escrow), + "Owner should receive escrow refund" + ); + assert_eq!(c.idle_balance, idle_before, "Idle balance unchanged"); + } + + #[rstest] + fn stop_and_exit_payout_zero_escrow_just_idle( + mut c: Contract, + owner: AccountId, + receiver: AccountId, + ) { + // Enter Payout with zero escrow; no transfers should occur + c.op_state = OpState::Payout { + op_id: 7, + receiver, + amount: 1, + owner: owner.clone(), + escrow_shares: 0, + burn_shares: 0, + }; + + 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); + + c.stop_and_exit_payout::<&str>(None); + + assert!(matches!(c.op_state, OpState::Idle)); + assert_eq!(c.total_supply(), supply_before, "No supply change"); + assert_eq!( + c.balance_of(&near_sdk::env::current_account_id()), + vault_before, + "Vault balance unchanged" + ); + assert_eq!(c.balance_of(&owner), owner_before, "Owner balance unchanged"); + } } From 3c449d7d9647c1f75955b0f7283aa40b812940bf Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 22 Oct 2025 11:01:40 +0100 Subject: [PATCH 061/121] test: use more fixtures --- contract/vault/src/tests.rs | 163 +++++++++++++++++++----------------- 1 file changed, 87 insertions(+), 76 deletions(-) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index b1041575..139ddcd8 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -16,11 +16,49 @@ use near_sdk::{json_types::U128, AccountId}; use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::mt::Nep245Receiver as _; use near_sdk_contract_tools::owner::OwnerExternal; -use rstest::rstest; +use rstest::{rstest, fixture}; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; use templar_common::vault::{AllocationMode, DepositMsg}; +#[fixture] +fn vault_id_fixture() -> 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) +} + +#[fixture] +fn c_owner_env(vault_id_fixture: AccountId) -> Contract { + let mut c = new_test_contract(&vault_id_fixture); + let owner = c + .own_get_owner() + .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + setup_env(&vault_id_fixture, &owner, vec![]); + c +} + +#[fixture] +fn c_asset_env(vault_id_fixture: AccountId) -> Contract { + let mut c = new_test_contract(&vault_id_fixture); + let asset: AccountId = c.underlying_asset.contract_id().into(); + setup_env(&vault_id_fixture, &asset, vec![]); + c +} + +#[fixture] +fn enabled_market_100() -> (AccountId, MarketConfiguration) { + let m = mk(9001); + let mut cfg = MarketConfiguration::default(); + cfg.cap = U128(100); + cfg.enabled = true; + (m, cfg) +} + #[rstest(len => [2usize, 3, 5])] #[should_panic = "Duplicate market"] fn prop_supply_queue_mustnt_have_duplicates(len: usize) { @@ -67,11 +105,9 @@ fn prop_withdraw_queue_mustnt_have_duplicates(len: usize) { c.set_withdraw_queue(queue); } -#[test] -fn fee_accrues_only_on_growth_unit() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); +#[rstest] +fn fee_accrues_only_on_growth_unit(mut c_vault_env: Contract) { + let mut c = c_vault_env; // Seed total supply so fees can mint let user = accounts(1); @@ -104,11 +140,11 @@ fn fee_accrues_only_on_growth_unit() { ); } -#[test] -fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); +#[rstest] +fn payout_success_burns_only_proportional_escrow_and_refunds_remainder( + mut c_vault_env: Contract, +) { + let mut c = c_vault_env; let receiver = mk(7); let owner = accounts(1); @@ -140,14 +176,13 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder() { assert!(matches!(c.op_state, OpState::Idle)); } -#[test] -fn execute_next_withdrawal_request_skips_holes() { +#[rstest] +fn execute_next_withdrawal_request_skips_holes(mut c_owner_env: Contract) { + let mut c = c_owner_env; let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); let owner = c .own_get_owner() .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); - setup_env(&vault_id, &owner, vec![]); println!("vault_id: {vault_id}"); println!("owner: {owner}"); @@ -226,11 +261,9 @@ fn set_withdraw_queue_must_include_all_holding() { c.set_withdraw_queue(vec![m2]); } -#[test] -fn execute_supply_wrong_token_refunds_full() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); +#[rstest] +fn execute_supply_wrong_token_refunds_full(mut c_vault_env: Contract) { + let mut c = c_vault_env; let sender = accounts(1); let wrong_token: AccountId = "wrong.token".parse().unwrap(); @@ -267,11 +300,9 @@ fn set_withdraw_queue_must_include_all_enabled() { c.set_withdraw_queue(vec![m2]); } -#[test] -fn start_allocation_reserves_only_amount() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); +#[rstest] +fn start_allocation_reserves_only_amount(mut 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); @@ -1035,23 +1066,14 @@ fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { ); } -#[test] -fn ft_on_transfer_supply_accepts_full_and_mints_shares() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); - let asset = c.underlying_asset.contract_id().into(); - setup_env(&vault_id, &asset, vec![]); - - // Prevent eager allocation from firing in this test - c.mode = AllocationMode::Eager { - min_batch: U128(u128::MAX), - }; - - // Setup a market so max_deposit > 0 - let m = mk(9001); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(100); - cfg.enabled = true; +#[rstest] +fn ft_on_transfer_supply_accepts_full_and_mints_shares( + mut c_asset_env: Contract, + 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.config.insert(m.clone(), cfg); c.supply_queue.push(m); @@ -1088,21 +1110,15 @@ fn ft_on_transfer_supply_accepts_full_and_mints_shares() { ); } -#[test] -fn ft_on_transfer_supply_partial_refund_when_capped() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); - let asset = c.underlying_asset.contract_id().into(); - setup_env(&vault_id, &asset, vec![]); - - c.mode = AllocationMode::Eager { - min_batch: U128(u128::MAX), - }; - - let m = mk(9002); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(50); // cap < deposit - cfg.enabled = true; +#[rstest] +fn ft_on_transfer_supply_partial_refund_when_capped( + mut c_asset_env: Contract, + 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.config.insert(m.clone(), cfg); c.supply_queue.push(m); @@ -1181,17 +1197,15 @@ fn ft_on_transfer_invalid_msg_panics() { let _ = c.ft_on_transfer(accounts(4), U128(10), "not-json".into()); } -#[test] -fn ft_on_transfer_zero_amount_returns_zero_refund() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); - setup_env(&vault_id, &vault_id, vec![]); +#[rstest] +fn ft_on_transfer_zero_amount_returns_zero_refund( + mut c_vault_env: Contract, + enabled_market_100: (AccountId, MarketConfiguration), +) { + let mut c = c_vault_env; // Setup a valid market - let m = mk(9004); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(100); - cfg.enabled = true; + let (m, cfg) = enabled_market_100; c.config.insert(m.clone(), cfg); c.supply_queue.push(m); @@ -1214,21 +1228,18 @@ fn ft_on_transfer_zero_amount_returns_zero_refund() { ); } -#[test] -fn ft_on_transfer_eager_mode_triggers_allocation() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); - let asset = c.underlying_asset.contract_id().into(); - setup_env(&vault_id, &asset, vec![]); +#[rstest] +fn ft_on_transfer_eager_mode_triggers_allocation( + mut 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 = mk(9005); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(100); - cfg.enabled = true; + let (m, cfg) = enabled_market_100; c.config.insert(m.clone(), cfg); c.supply_queue.push(m); From ced410cfa703281828e722da8578ad4167086388 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 22 Oct 2025 11:23:57 +0100 Subject: [PATCH 062/121] refactor: document codebase and slight style changes --- common/src/vault.rs | 60 +++++++++- contract/vault/src/impl_callbacks.rs | 161 ++++++++++++++++----------- 2 files changed, 154 insertions(+), 67 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index ec9e42b6..a5c456f1 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -210,29 +210,79 @@ pub struct PendingValue { #[derive(Debug, Clone, PartialEq, Eq)] #[near(serializers = [borsh])] /// Operation state machine for asynchronous allocation, withdrawal, and payout flows. +/// +/// State machine: +/// - Allocating -> Withdrawing (or Idle via stop) +/// - Withdrawing -> Withdrawing (advance) | Payout | Idle (refund) +/// - Payout -> Idle (success or failure) +/// +/// Invariants: +/// - idle_balance increases only when funds are received and decreases only on payout success. +/// - escrow_shares are refunded on stop/failure or partially burned/refunded on payout success. pub enum OpState { + /// No operation in-flight. The vault is ready to start a new allocation or withdrawal. Idle, + + /// Supplying idle underlying to markets according to a plan or queue. + /// + /// Transitions: + /// - On completion of allocation: Withdrawing (to satisfy pending user requests) or Idle (if stopped). + /// - On stop/failure: Idle. Allocating { - // FIXME: docs pls + /// Unique operation id used to correlate async callbacks and detect drift. op_id: u64, + /// Zero-based position within the allocation plan/queue currently being processed. index: u32, + /// Amount of underlying (in asset units) still to allocate during this operation. remaining: u128, }, + + /// Collecting liquidity from markets to satisfy a user withdrawal/redeem request. + /// + /// Transitions: + /// - Advance within queue: Withdrawing (index increments) while collecting funds. + /// - When enough is collected to satisfy the request: Payout. + /// - If the op is stopped or cannot proceed and needs to refund: Idle (escrow_shares refunded). Withdrawing { + /// Unique operation id used to correlate async callbacks and detect drift. op_id: u64, + /// Zero-based position within the withdraw queue currently being processed. index: u32, + /// Remaining assets that must still be collected to satisfy the request. remaining: u128, + /// Assets already collected and held as idle_balance pending payout. collected: u128, + /// Account that should receive the assets during payout. receiver: AccountId, + /// The owner whose shares are being redeemed. owner: AccountId, + /// Shares locked in escrow for this request. + /// - Refunded on stop/failure. + /// - On payout success, a portion is burned (see burn_shares) and any remainder is refunded. escrow_shares: u128, }, + + /// Final step that transfers assets to the receiver and settles the share escrow. + /// + /// Transitions: + /// - On success or failure: Idle. + /// + /// Invariant hooks: + /// - idle_balance decreases only on payout success by `amount`. + /// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded. + /// - On failure, all `escrow_shares` are refunded. Payout { + /// Unique operation id used to correlate async callbacks and detect drift. op_id: u64, + /// Receiver of the asset payout. receiver: AccountId, + /// Amount of assets to transfer out from idle_balance. amount: u128, + /// The owner whose shares were escrowed for this payout. owner: AccountId, + /// Total shares currently held in escrow for this operation. escrow_shares: u128, + /// Portion of `escrow_shares` that will be burned on successful payout. burn_shares: u128, }, } @@ -453,6 +503,14 @@ pub enum Event { need: U128, }, + #[event_version("1.0.0")] + CreateWithdrawalFailed { + op_id: U64, + market: AccountId, + index: u32, + need: U128, + }, + // Payout and stop diagnostics #[event_version("1.0.0")] PayoutUnexpectedState { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index b11c148c..b1d471ab 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -15,6 +15,15 @@ use templar_common::{ }, }; +/// State machine: +/// +/// - Allocating -> Withdrawing (or Idle via stop) +/// - Withdrawing -> Withdrawing (advance) | Payout | Idle (refund) +/// - Payout -> Idle (success or failure) +/// +/// Invariants: +/// - idle_balance increases only when funds are received and decreases only on payout success. +/// - escrow_shares are refunded on stop/failure or partially burned/refunded on payout success. #[near] impl Contract { #[private] @@ -36,37 +45,39 @@ impl Contract { Err(e) => return self.stop_and_exit(Some(&e)), }; - // If the transfer failed, do not attempt to reconcile; stop and leave remaining untouched - if accepted.is_err() { - Event::AllocationTransferFailed { - op_id: op_id.into(), - index: market_index, - market: market.clone(), - attempted, + match accepted { + Err(_) => { + Event::AllocationTransferFailed { + op_id: op_id.into(), + index: market_index, + market: market.clone(), + attempted, + } + .emit(); + self.stop_and_exit(Some(&Error::MarketTransferFailed)) } - .emit(); - return self.stop_and_exit(Some(&Error::MarketTransferFailed)); - } + Ok(accepted) => { + let before = self.market_supply.get(&market).unwrap_or(&0); - let before = self.market_supply.get(&market).unwrap_or(&0); - - PromiseOrValue::Promise( - ext_market::ext(market.clone()) - .with_static_gas(GET_SUPPLY_POSITION_GAS) - .with_unused_gas_weight(0) - .get_supply_position(env::current_account_id()) - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_SUPPLY_POSITION_CHECK_GAS) - .after_supply_2_read( - op_id, - market_index, - U128(*before), - attempted, - accepted.unwrap_or(U128(0)), + PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(GET_SUPPLY_POSITION_GAS) + .with_unused_gas_weight(0) + .get_supply_position(env::current_account_id()) + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(AFTER_SUPPLY_POSITION_CHECK_GAS) + .after_supply_2_read( + op_id, + market_index, + U128(*before), + attempted, + accepted, + ), ), - ), - ) + ) + } + } } #[private] @@ -79,7 +90,7 @@ impl Contract { attempted: U128, accepted: U128, ) -> PromiseOrValue<()> { - let (i, remaining) = match self.ctx_allocating(op_id) { + let (i, remaining_ctx) = match self.ctx_allocating(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), }; @@ -96,12 +107,12 @@ impl Contract { let SupplyReconciliation { new_principal, accepted_event, - remaining, + remaining: remaining_next, } = match position { Ok(Some(position)) => reconcile_supply_outcome( &position.get_deposit().total().into(), &before.0, - &remaining, + &remaining_ctx, ), Ok(None) => { Event::AllocationPositionMissing { @@ -137,7 +148,7 @@ impl Contract { accepted: U128(accepted_event), attempted, refunded: U128(refunded), - remaining_after: U128(remaining), + remaining_after: U128(remaining_next), } .emit(); @@ -151,7 +162,7 @@ impl Contract { self.op_state = OpState::Allocating { op_id, index: market_index + 1, - remaining, + remaining: remaining_next, }; self.step_allocation() } @@ -192,7 +203,13 @@ impl Contract { ), ) } else { - env::log_str("create_supply_withdrawal_request failed; moving to next market"); + Event::CreateWithdrawalFailed { + op_id: op_id.into(), + market: market.clone(), + index: i, + need: need, + } + .emit(); self.op_state = OpState::Withdrawing { op_id, index: market_index + 1, @@ -242,6 +259,12 @@ impl Contract { ) } + /// Cash flow: + /// - Reconcile market position to compute 'credited' (funds returned from market). + /// - Increment idle_balance by credited to reflect funds now held by the vault. + /// - If remaining == 0, transition to Payout; otherwise continue Withdrawing on next market. + /// - Later in after_send_to_user, idle_balance is decremented on successful transfer to the user. + /// - On transfer failure, idle_balance stays unchanged and escrowed shares are refunded to the owner. #[private] pub fn after_exec_withdraw_read( &mut self, @@ -251,7 +274,7 @@ impl Contract { before: U128, need: U128, ) -> PromiseOrValue<()> { - let (i, remaining, receiver, collected, owner, escrow_shares) = + let (i, remaining_ctx, receiver, collected_ctx, owner, escrow_shares) = match self.ctx_withdrawing(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), @@ -289,25 +312,26 @@ impl Contract { } }; - let (_credited, remaining, collected, idle_delta) = self.reconcile_withdraw_outcome( - before_principal, - new_principal, - need.0, - remaining, - collected, - ); + let (_credited, remaining_next, collected_next, idle_delta) = self + .reconcile_withdraw_outcome( + before_principal, + new_principal, + need.0, + remaining_ctx, + collected_ctx, + ); self.market_supply.insert(market.clone(), new_principal); if idle_delta > 0 { self.idle_balance = self.idle_balance.saturating_add(idle_delta); } - if remaining == 0 { - if collected > 0 { + if remaining_next == 0 { + if collected_next > 0 { self.op_state = OpState::Payout { op_id, receiver: receiver.clone(), - amount: collected, + amount: collected_next, owner: owner.clone(), escrow_shares, burn_shares: escrow_shares, @@ -315,11 +339,11 @@ impl Contract { PromiseOrValue::Promise( self.underlying_asset .clone() - .transfer(receiver.clone(), U128(collected).into()) + .transfer(receiver.clone(), U128(collected_next).into()) .then( ext_self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, receiver, U128(collected)), + .after_send_to_user(op_id, receiver, U128(collected_next)), ), ) } else { @@ -335,9 +359,9 @@ impl Contract { self.op_state = OpState::Withdrawing { op_id, index: market_index + 1, - remaining, + remaining: remaining_next, receiver: receiver, - collected, + collected: collected_next, owner, escrow_shares, }; @@ -345,6 +369,10 @@ impl Contract { } } + /// Cash flow: + /// - Runs in Payout context after funds were credited in after_exec_withdraw_read. + /// - On success: idle_balance -= amount; burn a portion of escrow_shares and refund the rest to the owner. + /// - On failure: refund full escrow_shares to the owner and keep idle_balance unchanged (funds remain in vault). #[private] pub fn after_send_to_user( &mut self, @@ -421,16 +449,12 @@ impl Contract { return PromiseOrValue::Value(()); } }; - if amount == 0 { - PromiseOrValue::Value(()) - } else { - PromiseOrValue::Promise( - ext_ft_core::ext(token) - .with_attached_deposit(NearToken::from_yoctonear(1)) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) - .ft_transfer(recipient, U128(amount), None), - ) - } + PromiseOrValue::Promise( + ext_ft_core::ext(token) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .ft_transfer(recipient, U128(amount), None), + ) } } @@ -559,7 +583,7 @@ impl Contract { OpState::Payout { .. } => self.stop_and_exit_payout(msg), OpState::Idle => { Event::OperationStoppedWhileIdle { - reason: msg.map(|m| format!("{m:?}")), + reason: msg.map(std::string::ToString::to_string), } .emit(); self.op_state = OpState::Idle; @@ -682,6 +706,7 @@ mod tests { use near_sdk::test_utils::accounts; use near_sdk::PromiseOrValue; use near_sdk::PromiseResult; + use near_sdk_contract_tools::ft::Nep141 as _; use rstest::rstest; use crate::Contract; @@ -1544,19 +1569,23 @@ mod tests { burn_shares: 0, }; - 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 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()); c.stop_and_exit_payout::<&str>(None); assert!(matches!(c.op_state, OpState::Idle)); - assert_eq!(c.total_supply(), supply_before, "No supply change"); + assert_eq!(c.ft_total_supply(), supply_before, "No supply change"); assert_eq!( - c.balance_of(&near_sdk::env::current_account_id()), + c.ft_balance_of(near_sdk::env::current_account_id()), vault_before, "Vault balance unchanged" ); - assert_eq!(c.balance_of(&owner), owner_before, "Owner balance unchanged"); + assert_eq!( + c.ft_balance_of(owner), + owner_before, + "Owner balance unchanged" + ); } } From c6ee233d6dbc26323a022d8091d323d356e19ee8 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 23 Oct 2025 14:40:32 +0100 Subject: [PATCH 063/121] refactor: don't acount markets without supply --- contract/vault/src/lib.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 3bae215a..d27ee69f 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -123,9 +123,6 @@ pub struct Contract { op_state: OpState, next_op_id: u64, - /// Storage usage - storage_usage_supply: u64, - storage_usage_role: u64, /// Pending withdrawals queue (vault-level, FIFO by id) pending_withdrawals: IterableMap, @@ -181,8 +178,6 @@ impl Contract { }; } - let storage_usage_supply = env::storage_usage(); - let storage_usage_role = env::storage_usage(); let mut contract = Self { underlying_asset: underlying_token, @@ -203,8 +198,6 @@ impl Contract { idle_balance: 0, op_state: OpState::Idle, next_op_id: 1, - storage_usage_supply, - storage_usage_role, mode, plan: None, @@ -228,7 +221,7 @@ impl Contract { Self::with_members_of(&Role::Curator, |members| { require!( members.len() < 2, - "Invariant violation: Cannot Have more than 1 Curator" + "Invariant violation: Cannot have more than one Curator" ); require!( !members.contains(&account), @@ -271,7 +264,7 @@ impl Contract { Self::with_members_of(&Role::Guardian, |members| { require!( members.len() < 2, - "Invariant violation: Cannot Have more than 1 Guardian" + "Invariant violation: Cannot have more than one Guardian" ); require!(!members.contains(&new_g), "Already set to this address"); guardian_occupied = !members.is_empty(); @@ -880,14 +873,14 @@ impl Contract { curator: Self::with_members_of(&Role::Curator, |members| { require!( members.len() == 1, - "Invariant violation: Cannot Have more than 1 Curator" + "Invariant violation: Cannot have more than one Curator" ); members.iter().next().expect("Curator not set").clone() }), guardian: Self::with_members_of(&Role::Guardian, |members| { require!( members.len() == 1, - "Invariant violation: Cannot Have more than 1 Guardian" + "Invariant violation: Cannot have more than one Guardian" ); members.iter().next().expect("Guardian not set").clone() }), @@ -1377,7 +1370,7 @@ impl Contract { PromiseOrValue::Promise(self.supply_and_then(&market, to_supply, op_id, index)) } else { - self.stop_and_exit(Some("Market not found")) + self.stop_and_exit::(None) } } From 16518602a1737b8e05fa278d66cf80eefcb7804f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Thu, 23 Oct 2025 14:56:33 +0100 Subject: [PATCH 064/121] refactor: timelock jumping between ns <> secs --- common/src/vault.rs | 8 ++++---- contract/vault/src/lib.rs | 32 +++++++++++++----------------- contract/vault/src/tests.rs | 28 ++++++++++++++------------ test-utils/src/controller/vault.rs | 4 ++-- test-utils/src/lib.rs | 2 +- 5 files changed, 36 insertions(+), 38 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index a5c456f1..d85c4e80 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -88,7 +88,7 @@ pub struct VaultConfiguration { /// The underlying asset for this vault. pub underlying_token: FungibleAsset, /// The initial timelock for this vault used for modifying the configuration. - pub initial_timelock_sec: u32, + pub initial_timelock_ns: U64, /// The account that receives fees for this vault. pub fee_recipient: AccountId, /// The skim account that can unorphan any assets erroneously sent to this vault. @@ -112,7 +112,7 @@ pub trait VaultExt { fn set_skim_recipient(account: AccountId); fn set_fee_recipient(account: AccountId); fn set_performance_fee(fee: U128); - fn submit_timelock(new_timelock_secs: u32); + fn submit_timelock(new_timelock_ns: U64); fn accept_timelock(); fn revoke_pending_timelock(); @@ -426,9 +426,9 @@ pub enum Event { PerformanceFeeSet { fee: U128 }, #[event_version("1.0.0")] - TimelockSet { seconds: u32 }, + TimelockSet { seconds: U64 }, #[event_version("1.0.0")] - TimelockChangeSubmitted { new_seconds: u32, valid_at: U64 }, + TimelockChangeSubmitted { new_ns: U64, valid_at: U64 }, #[event_version("1.0.0")] PendingTimelockRevoked, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index d27ee69f..4f1850fb 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -13,7 +13,7 @@ use crate::storage_management::{ use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ env, - json_types::U128, + json_types::{U128, U64}, near, require, serde_json, store::{IterableMap, LookupMap, Vector}, AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, @@ -123,7 +123,6 @@ pub struct Contract { op_state: OpState, next_op_id: u64, - /// Pending withdrawals queue (vault-level, FIFO by id) pending_withdrawals: IterableMap, next_withdraw_id: u64, @@ -150,7 +149,7 @@ impl Contract { curator, guardian, underlying_token, - initial_timelock_sec, + initial_timelock_ns, fee_recipient, skim_recipient, name, @@ -159,9 +158,8 @@ impl Contract { mode, } = configuration; - let timelock_ns = u64::from(initial_timelock_sec) * 1_000_000_000; require!( - (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&timelock_ns), + (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&initial_timelock_ns.0), "timelock bounds" ); @@ -178,10 +176,9 @@ impl Contract { }; } - let mut contract = Self { underlying_asset: underlying_token, - timelock_ns, + timelock_ns: initial_timelock_ns.0, performance_fee: Default::default(), fee_recipient, skim_recipient, @@ -362,33 +359,33 @@ impl Contract { /* ----- Timelocks / Pending ----- */ /// Proposes a new governance timelock in seconds. /// If increasing, applies immediately; if decreasing, starts a timelock equal to the current duration. - pub fn submit_timelock(&mut self, new_timelock_secs: u32) { + pub fn submit_timelock(&mut self, new_timelock_ns: U64) { Self::require_owner(); - let as_nanos = u64::from(new_timelock_secs) * 1_000_000_000; + let tl = &new_timelock_ns.0; - require!(as_nanos != self.timelock_ns, "Already set to this value"); + require!(tl != &self.timelock_ns, "Already set to this value"); require!( self.pending_timelock.is_none(), "Timelock change already pending" ); require!( - (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&as_nanos), + (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&tl), "Timelock out of bounds" ); - if as_nanos > self.timelock_ns { - self.timelock_ns = as_nanos; + if tl > &self.timelock_ns { + self.timelock_ns = *tl; Event::TimelockSet { - seconds: new_timelock_secs, + seconds: new_timelock_ns, } .emit(); } else { let valid_at = env::block_timestamp() + self.timelock_ns; self.pending_timelock = Some(PendingValue { - value: as_nanos, + value: *tl, valid_at, }); Event::TimelockChangeSubmitted { - new_seconds: new_timelock_secs, + new_ns: new_timelock_ns, valid_at: valid_at.into(), } .emit(); @@ -865,7 +862,6 @@ impl Contract { impl Contract { #[allow(clippy::expect_used, reason = "No side effects")] pub fn get_configuration(&self) -> VaultConfiguration { - let timelock_sec = self.timelock_ns / 1_000_000_000; VaultConfiguration { owner: self .own_get_owner() @@ -885,7 +881,7 @@ impl Contract { members.iter().next().expect("Guardian not set").clone() }), underlying_token: self.underlying_asset.clone(), - initial_timelock_sec: timelock_sec as u32, + initial_timelock_ns: self.timelock_ns.clone().into(), fee_recipient: self.fee_recipient.clone(), skim_recipient: self.skim_recipient.clone(), name: self.get_metadata().name, diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 139ddcd8..ccb33518 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -16,7 +16,7 @@ use near_sdk::{json_types::U128, AccountId}; use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::mt::Nep245Receiver as _; use near_sdk_contract_tools::owner::OwnerExternal; -use rstest::{rstest, fixture}; +use rstest::{fixture, rstest}; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; use templar_common::vault::{AllocationMode, DepositMsg}; @@ -141,9 +141,7 @@ fn fee_accrues_only_on_growth_unit(mut c_vault_env: Contract) { } #[rstest] -fn payout_success_burns_only_proportional_escrow_and_refunds_remainder( - mut c_vault_env: Contract, -) { +fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(mut c_vault_env: Contract) { let mut c = c_vault_env; let receiver = mk(7); @@ -1072,7 +1070,9 @@ 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) }; + c.mode = AllocationMode::Eager { + min_batch: U128(u128::MAX), + }; let (m, cfg) = enabled_market_100; c.config.insert(m.clone(), cfg); c.supply_queue.push(m); @@ -1116,7 +1116,9 @@ 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) }; + 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.config.insert(m.clone(), cfg); @@ -1495,13 +1497,13 @@ fn governance_submit_accept_timelock_increase_then_decrease() { let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - let cur = c.get_configuration().initial_timelock_sec; + let cur = c.get_configuration().initial_timelock_ns; // Increase applies immediately - c.submit_timelock(cur + 1); + c.submit_timelock((cur.0 + 1).into()); assert_eq!( - c.get_configuration().initial_timelock_sec, - cur + 1, + c.get_configuration().initial_timelock_ns.0, + cur.0 + 1, "timelock should increase immediately" ); @@ -1515,7 +1517,7 @@ fn governance_submit_accept_timelock_increase_then_decrease() { ); c.accept_timelock(); assert_eq!( - c.get_configuration().initial_timelock_sec, + c.get_configuration().initial_timelock_ns, cur, "timelock should decrease after accept" ); @@ -1541,10 +1543,10 @@ fn governance_revoke_pending_timelock_then_accept_panics() { let owner = c.own_get_owner().unwrap(); setup_env(&vault_id, &owner, vec![]); - let cur = c.get_configuration().initial_timelock_sec; + let cur = c.get_configuration().initial_timelock_ns; // Force a pending by first increasing then decreasing - c.submit_timelock(cur + 1); + c.submit_timelock((cur.0 + 1).into()); c.submit_timelock(cur); // Revoke the pending change; accept must now panic diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index bd4bc0dc..dfb1adc4 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -4,7 +4,7 @@ use crate::{ print_execution, UnifiedMarketController, }; use near_sdk::{ - json_types::U128, + json_types::{U128, U64}, serde_json::{self, json}, AccountId, NearToken, }; @@ -134,7 +134,7 @@ impl VaultController { pub fn set_performance_fee(fee: U128); #[call(exec, tgas(50))] - pub fn submit_timelock(new_timelock_secs: u32); + pub fn submit_timelock(new_timelock_ns: U64); #[call(exec, tgas(50))] pub fn accept_timelock(); diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index 0934aa2e..90b31e7e 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -153,7 +153,7 @@ pub fn vault_configuration( curator: curator_id, guardian: guardian_id, underlying_token: FungibleAsset::nep141(borrow_asset_id), - initial_timelock_sec: templar_common::vault::MIN_TIMELOCK_NS as u32, + initial_timelock_ns: templar_common::vault::MIN_TIMELOCK_NS.into(), fee_recipient: fee_recipient_id, skim_recipient: skim_recipient_id, name: "Vault".to_string(), From 544abb5bea2ed3784e89f1ab04bdc0d059a918df Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 09:38:24 +0100 Subject: [PATCH 065/121] fix: critical idle balance issue with overdrawn markets --- contract/vault/src/impl_callbacks.rs | 53 +++++++++++++++------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index b1d471ab..f3aa6205 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -651,28 +651,6 @@ impl Contract { .cloned() .ok_or(Error::MissingMarket(market_index)) } - - /// Pure reconciliation for withdraw read outcome to enable unit tests - pub(crate) fn reconcile_withdraw_outcome( - &self, - before_principal: u128, - new_principal: u128, - need: u128, - rem: u128, - coll: u128, - ) -> ( - u128, /* credited */ - u128, /* remaining_next */ - u128, /* collected_next */ - u128, /* idle_delta */ - ) { - let withdrawn = before_principal.saturating_sub(new_principal); - let credited = withdrawn.min(need); - let remaining_next = rem.saturating_sub(credited); - let collected_next = coll.saturating_add(credited); - let idle_delta = credited; - (credited, remaining_next, collected_next, idle_delta) - } } pub(crate) struct SupplyReconciliation { @@ -695,6 +673,33 @@ pub(crate) fn reconcile_supply_outcome( } } +pub struct WithdrawReconciliation { + pub payout_delta: u128, + pub remaining_next: u128, + pub collected_next: u128, + pub idle_delta: u128, +} + +/// Pure reconciliation for withdraw read outcome to enable unit tests +pub(crate) fn reconcile_withdraw_outcome( + before_principal: u128, + new_principal: u128, + remaining_total: u128, + collected_total: u128, +) -> 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); + WithdrawReconciliation { + payout_delta, + remaining_next, + collected_next, + idle_delta, + } +} + #[cfg(test)] mod tests { use std::u128; @@ -880,8 +885,8 @@ mod tests { ); assert_eq!( - c.idle_balance, 60, - "Idle balance should increase by credited amount" + c.idle_balance, 100, + "Idle balance should increase by returned amount" ); // State should transition to Payout with amount = collected (10) + credited (60) = 70 From 879c0af814b3ad6c482c06ab3bec5bbc682fe9e0 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 09:41:57 +0100 Subject: [PATCH 066/121] fix: saturating math & test fix --- contract/vault/src/impl_callbacks.rs | 64 ++++++++++++++++++---------- contract/vault/src/tests.rs | 20 +++++---- 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index f3aa6205..564e9e90 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -161,7 +161,7 @@ impl Contract { self.op_state = OpState::Allocating { op_id, - index: market_index + 1, + index: market_index.saturating_add(1), remaining: remaining_next, }; self.step_allocation() @@ -212,7 +212,7 @@ impl Contract { .emit(); self.op_state = OpState::Withdrawing { op_id, - index: market_index + 1, + index: market_index.saturating_add(1), remaining: remaining, receiver: received, collected: collected, @@ -312,14 +312,17 @@ impl Contract { } }; - let (_credited, remaining_next, collected_next, idle_delta) = self - .reconcile_withdraw_outcome( - before_principal, - new_principal, - need.0, - remaining_ctx, - collected_ctx, - ); + let WithdrawReconciliation { + remaining_next, + collected_next, + idle_delta, + .. + } = reconcile_withdraw_outcome( + before_principal, + new_principal, + remaining_ctx, + collected_ctx, + ); self.market_supply.insert(market.clone(), new_principal); if idle_delta > 0 { @@ -358,7 +361,7 @@ impl Contract { } else { self.op_state = OpState::Withdrawing { op_id, - index: market_index + 1, + index: market_index.saturating_add(1), remaining: remaining_next, receiver: receiver, collected: collected_next, @@ -381,7 +384,7 @@ impl Contract { receiver: AccountId, amount: U128, ) -> bool { - let (owner, escrow_shares, amount, burn_shares) = match &self.op_state { + let (owner, escrow_shares, expected_amount, burn_shares) = match &self.op_state { OpState::Payout { op_id: current_op, receiver: recv, @@ -405,27 +408,42 @@ impl Contract { if result.is_ok() { // On payout success, idle_balance -= payout_amount. + self.idle_balance = self.idle_balance.saturating_sub(expected_amount); + + let EscrowSettlement { + to_burn: burn_shares, + refund, + } = Self::compute_escrow_settlement(escrow_shares, burn_shares); + // Burn only the proportional shares and refund the remainder to the owner. - self.idle_balance = self.idle_balance.saturating_sub(amount); - let EscrowSettlement { to_burn, refund } = - Self::compute_escrow_settlement(escrow_shares, burn_shares); - if to_burn > 0 { - self.withdraw_unchecked(&env::current_account_id(), to_burn) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + if burn_shares > 0 { + // Serious issue: this should be infallible - if the withdrawal panics here we have an escrow settlement error + self.withdraw_unchecked(&env::current_account_id(), burn_shares) + .unwrap_or_else(|e| env::log_str(&e.to_string())); + // TODO: emit burn event } + + // Maybe refund any delta to the owner if refund > 0 { + // Serious issue: this should be infallible - if the transfer panics here we have an escrow settlement error + // Note: this should be infallible since we are transferring to an existing owner, and they are unable to unregister from storage #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&env::current_account_id(), &owner, refund) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| env::log_str(&e.to_string())); + // TODO: emit Refund event } + + // Pop the withdrawing id and reconcile the primer self.op_state = OpState::Idle; + sanitise_queue(); true } else { // On payout failure, refund full escrow to owner and leave idle_balance unchanged - #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&env::current_account_id(), &owner, escrow_shares) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + // If this fails, this is a serious issue as above + .unwrap_or_else(|e| env::log_str(&e.to_string())); self.op_state = OpState::Idle; + sanitise_queue(); false } } @@ -528,7 +546,7 @@ impl Contract { let self_id = env::current_account_id(); #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&self_id, &owner_acc, escrow) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| env::log_str(&e.to_string())); } self.op_state = OpState::Idle; } @@ -567,7 +585,7 @@ impl Contract { let escrow = *escrow_shares; #[allow(clippy::expect_used, reason = "No side effects")] self.transfer_unchecked(&self_id, &owner_acc, escrow) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| env::log_str(&e.to_string())); } } self.op_state = OpState::Idle; diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index ccb33518..89f14edd 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,5 +1,6 @@ use std::u64; +use crate::impl_callbacks::WithdrawReconciliation; use crate::storage_management::storage_bytes_for_queue_account_id; use crate::storage_management::yocto_for_bytes; use crate::storage_management::yocto_for_new_market; @@ -717,18 +718,21 @@ fn reconcile_withdraw_outcome_invariants_cases( rem: u128, coll: u128, ) { - let c = new_test_contract(&mk(0)); - let (credited, remaining_next, collected_next, idle_delta) = - c.reconcile_withdraw_outcome(before, new_principal, need, rem, coll); + let WithdrawReconciliation { + payout_delta, + remaining_next, + collected_next, + idle_delta, + } = crate::impl_callbacks::reconcile_withdraw_outcome(before, new_principal, rem, coll); let withdrawn = before.saturating_sub(new_principal); let expected_credited = withdrawn.min(need); - assert_eq!(credited, expected_credited); - assert!(credited <= need); - assert_eq!(remaining_next, rem.saturating_sub(credited)); - assert_eq!(collected_next, coll.saturating_add(credited)); - assert_eq!(idle_delta, credited); + assert_eq!(payout_delta, expected_credited); + assert!(payout_delta <= need); + assert_eq!(remaining_next, rem.saturating_sub(payout_delta)); + assert_eq!(collected_next, coll.saturating_add(payout_delta)); + assert_eq!(idle_delta, payout_delta); } #[rstest( From 8a8f50225f4757ee80007a7b4ec1bf927fb3d1fa Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 10:09:00 +0100 Subject: [PATCH 067/121] fix: add force unregister hook --- contract/vault/src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 4f1850fb..92d24add 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -76,11 +76,24 @@ pub enum Role { } #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] -/// FIXME: #[nep145(force_unregister_hook = "Self")] +#[fungible_token(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] #[near(contract_state)] /// Vault contract that issues shares over an underlying fungible asset and allocates liquidity /// across configured markets. Implements 4626-like deposit/withdraw semantics. +/// +/// What this contract does (high-level mental model) +/// - Issues a share token (NEP-141) that represents a vault over an underlying NEP-141 “BorrowAsset”. +/// - Allocates deposits across “markets” (external contracts) via a supply queue, and withdraws via a withdraw queue. +/// - Governance uses Owner + RBAC (Curator/Guardian/Allocator) with a timelock for certain changes. +/// - Withdraw flow escrows shares, builds market-side withdrawal requests, then pays out and burns proportional escrow. +/// - Performance fees accrue by minting fee shares based on increases in total assets. +/// Critical invariants the code intends to keep +/// - Assets accounting is correct: total_assets = idle_balance + sum(all principals in markets). +/// - Withdraw queue contains every market that either is enabled or still holds principal (until that principal is zero). +/// - Only one op in flight (op_state); mutating ops require Idle. +/// - Governance changes obey timelocks; Guardian may revoke pending changes. +/// /// Note: RBAC storage (role membership) is paid by the contract; callers are not charged deposits for RBAC changes. pub struct Contract { mode: AllocationMode, @@ -1535,5 +1548,11 @@ impl Contract { } } +impl near_sdk_contract_tools::hook::Hook> for Contract { + fn hook(_: &mut Self, _: &Nep145ForceUnregister, _: impl FnOnce(&mut Self) -> R) -> R { + // Invariant: Force unregister must fail to preserve FT ledger integrity. + env::panic_str("force unregistration is not supported") + } +} #[cfg(test)] mod tests; From c956d767f93c7addfa26720c0f44b77db03618c5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 10:10:51 +0100 Subject: [PATCH 068/121] fix: owner can exfiltrate underlying and/or escrowed share tokens via skim --- contract/vault/src/lib.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 92d24add..b615f7c8 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -784,6 +784,22 @@ impl Contract { /// Sends the entire balance of `token` held by the vault to the `skim_recipient`. pub fn skim(&mut self, token: AccountId) -> Promise { Self::require_owner(); + + // Disallow skimming underlying or this own share token + let share_token_id = env::current_account_id(); + let underlying_token_id = self.underlying_asset.contract_id(); + + require!( + token != share_token_id, + "Refusing to skim the share token (would steal escrowed shares)" + ); + require!( + token != underlying_token_id, + "Refusing to skim the underlying token" + ); + + self.ensure_idle(); + ext_ft_core::ext(token.clone()) .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) .ft_balance_of(env::current_account_id()) From 7e9f556789dd02389e6414c7ef946801cf649811 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 10:15:31 +0100 Subject: [PATCH 069/121] chore: emit events on timelock updates --- contract/vault/src/lib.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index b615f7c8..2c9af065 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -308,6 +308,10 @@ impl Contract { }); Self::add_role(self, &p.value, &Role::Guardian); }); + Event::GuardianSet { + account: p.value.clone(), + } + .emit(); self.pending_guardian = None; } } @@ -414,6 +418,10 @@ impl Contract { "Timelock not elapsed yet" ); self.timelock_ns = p.value; + Event::TimelockSet { + seconds: p.value.into(), + } + .emit(); self.pending_timelock = None; } else { env::panic_str("No pending timelock change"); @@ -557,6 +565,10 @@ impl Contract { Self::assert_curator_or_owner(); if self.pending_cap.get(&market).is_some() { self.pending_cap.remove(&market); + Event::SupplyCapRaiseRevoked { + market: market.clone(), + } + .emit(); } } @@ -735,7 +747,9 @@ impl Contract { let assets = self.convert_to_assets(U128(shares)).0; let sender = env::predecessor_account_id(); - require_attached_for_pending_withdrawal(); + require!(shares > 0, "Invalid shares"); + + let _ = require_attached_for_pending_withdrawal(); // Move shares into escrow #[allow(clippy::expect_used, reason = "No side effects")] From ae321fcb7d4828ef46de3bfad963fb048cbb45d8 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 10:23:39 +0100 Subject: [PATCH 070/121] test: prevent owner reaping --- contract/vault/src/tests.rs | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 89f14edd..fa276e03 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1738,3 +1738,53 @@ fn governance_set_withdraw_queue_happy_path() { assert_eq!(c.withdraw_queue.get(0), Some(&m1)); assert_eq!(c.withdraw_queue.get(1), Some(&m2)); } + +#[test] +fn test_prevent_skim_underlying_and_shares() { + 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![]); + + // Set a skim recipient + let recipient = accounts(8); + c.set_skim_recipient(recipient.clone()); + + // Seed idle underlying and escrow some shares (held by the vault itself) + c.idle_balance = 123; + c.deposit_unchecked(&vault_id, 100) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + // Snapshot pre-state + let pre_idle = c.idle_balance; + let pre_vault_shares = c.balance_of(&vault_id); + let pre_recipient_shares = c.balance_of(&recipient); + + // Attempt to skim underlying token -> must panic + let underlying: AccountId = c.underlying_asset.contract_id().into(); + let r1 = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = c.skim(underlying.clone()); + })); + assert!(r1.is_err(), "skimming underlying token should panic"); + + // Attempt to skim the share token -> must panic + let share_token: AccountId = vault_id.clone(); + let r2 = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = c.skim(share_token.clone()); + })); + assert!(r2.is_err(), "skimming share token should panic"); + + // State must be unchanged + assert_eq!(c.idle_balance, pre_idle, "idle balance must be unchanged"); + assert_eq!( + c.balance_of(&vault_id), + pre_vault_shares, + "vault's escrowed shares must be unchanged" + ); + assert_eq!( + c.balance_of(&recipient), + pre_recipient_shares, + "skim recipient must not receive any shares" + ); + assert!(matches!(c.op_state, OpState::Idle), "op_state must remain Idle"); +} From 93044a7ea9bf6a48bde821a255463e9aeca712e7 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 11:04:12 +0100 Subject: [PATCH 071/121] refactor!: convert introduce AUM modes to better underline tradeoffs between governance write downs and pure accounting --- contract/vault/src/lib.rs | 76 ++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 2c9af065..85f702ff 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -936,12 +936,7 @@ impl Contract { /// Returns total assets under management = idle balance + sum of market principals. pub fn get_total_assets(&self) -> U128 { - // TODO: join - let mut sum = self.idle_balance; - self.withdraw_queue.iter().for_each(|m| { - sum = sum.saturating_add(self.principal_of(m)); - }); - U128(sum) + AUM::GovernanceAbandonment.get_total_assets(&self) } pub fn get_total_supply(&self) -> U128 { @@ -1086,9 +1081,7 @@ impl Contract { market: market.clone(), } .emit(); - if before_principal > 0 { - self.last_total_assets = self.last_total_assets.saturating_add(before_principal); - } + AUM::GovernanceAbandonment.paper_aum_undercounting(self, &before_principal); } /// Enqueue a vault-level pending withdrawal request (escrow already taken). @@ -1584,5 +1577,70 @@ impl near_sdk_contract_tools::hook::Hook> for Co env::panic_str("force unregistration is not supported") } } + +mod aum { + use super::*; + pub enum AUM { + // MetaMorpho treats “AUM” as the assets of active markets that governance still stands behind. + // Once governance has decided (with a timelock) to abandon a market, MetaMorpho writes that position down to zero for AUM purposes by removing it from the withdrawQueue. + // AUM definition is withdrawQueue‑scoped by design. + // + // - totalAssets() sums MORPHO.expectedSupplyAssets over withdrawQueue only. That is a deliberate filter: if a market is not in the withdrawQueue, it does not contribute to AUM. + // - Removing a market with non‑zero supply is allowed, but only after a timelock. + // - updateWithdrawQueue enforces: to remove an entry you must have cap == 0, no pending cap, and if supplyShares != 0 then removableAt must be set and the timelock elapsed. After that, it deletes config[id] and drops the market from the queue. + // - Effect: it’s a governance “write‑down.” The vault stops counting that position in AUM, even if tokens are still there or might be recoverable later. + // + // Why that’s acceptable to them: + // - It prevents new depositors from paying for stranded or possibly unrecoverable positions. Price (shares per asset) only reflects active, opted‑in markets. + // - The decision is gated by a timelock, giving existing holders time to exit before the write‑down takes effect. It’s an explicit, auditable policy action, not an operational side‑effect. + GovernanceAbandonment, + BalanceSheet, + } + + impl AUM { + pub fn get_total_assets(&self, c: &Contract) -> U128 { + U128(match self { + AUM::GovernanceAbandonment => { + c.withdraw_queue.iter().fold(c.idle_balance, |prev, m| { + prev.saturating_add(c.principal_of(m)) + }) + } + AUM::BalanceSheet => c.supply_queue.iter().fold(c.idle_balance, |prev, m| { + prev.saturating_add(c.principal_of(m)) + }), + }) + } + + pub fn policy_removal(&self, cfg: &MarketConfiguration, has_supply: &bool) { + match self { + AUM::GovernanceAbandonment => { + if *has_supply { + require!( + cfg.removable_at > 0, + "Policy violation: Market still has supply but no removal scheduled" + ); + require!( + env::block_timestamp() >= cfg.removable_at, + "Policy violation: Removal timelock not elapsed for market" + ); + } + } + AUM::BalanceSheet => require!(!has_supply, "Policy violation: Supply shares exist"), + } + } + + pub fn paper_aum_undercounting(&self, c: &mut Contract, before_principal: &u128) { + match self { + AUM::GovernanceAbandonment => { + if *before_principal > 0 { + c.last_total_assets = c.last_total_assets.saturating_add(*before_principal); + } + } + AUM::BalanceSheet => {} + } + } + } +} + #[cfg(test)] mod tests; From d5101a05a80cd3a8a730e0f311385c876cef1345 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 11:04:36 +0100 Subject: [PATCH 072/121] chore: log on fee accrual --- contract/vault/src/lib.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 85f702ff..26706488 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -694,16 +694,7 @@ impl Contract { self.pending_cap.get(id).is_none(), "Policy violation: Cannot remove market with pending cap change" ); - if has_supply { - require!( - cfg.removable_at > 0, - "Policy violation: Market still has supply but no removal scheduled" - ); - require!( - env::block_timestamp() >= cfg.removable_at, - "Policy violation: Removal timelock not elapsed for market" - ); - } + AUM::GovernanceAbandonment.policy_removal(cfg, &has_supply); } else { // Not in current queue: must be included if enabled or holding. env::panic_str( @@ -1202,7 +1193,13 @@ impl Contract { self.total_supply().into(), ); if fee_shares > Number::zero() { - self.mint_shares(&self.fee_recipient.clone(), fee_shares.into()); + let minted: u128 = fee_shares.into(); + self.mint_shares(&self.fee_recipient.clone(), minted); + Event::PerformanceFeeAccrued { + recipient: self.fee_recipient.clone(), + shares: U128(minted), + } + .emit(); } self.last_total_assets = cur; } From bb98bd9908ccf1107e904f5b3aa2836a15a24e64 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 11:06:50 +0100 Subject: [PATCH 073/121] refactor: make market supply iterable --- contract/vault/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 26706488..817e8317 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -129,7 +129,7 @@ pub struct Contract { withdraw_queue: Vector, /// vault's supplied principal per market (borrow-asset units) - market_supply: LookupMap, + market_supply: IterableMap, /// underlying held by vault idle_balance: u128, From b1908785f5de1b4db0f06448635875fe25e32192 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 11:21:39 +0100 Subject: [PATCH 074/121] feat: AUM module --- contract/vault/src/lib.rs | 194 +++++++++++++++++++++++++++++++++++--- 1 file changed, 182 insertions(+), 12 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 817e8317..9bff43bc 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1575,26 +1575,166 @@ impl near_sdk_contract_tools::hook::Hook> for Co } } +/// AUM (Assets Under Management) module +/// +/// This module encodes two coherent accounting models for a vault: +/// - GovernanceAbandonment (MetaMorpho-style): AUM counts only markets that are currently +/// "active" in the withdraw_queue. Governance may perform a timelocked write-down by +/// removing a market from the withdraw_queue even if principal remains. Later, governance +/// may perform a timelocked write-up by re-adding that market. Pricing reflects only the +/// set of markets governance currently stands behind. +/// - BalanceSheet (strict accounting): AUM includes every position that still belongs to +/// the vault until assets actually move (principal decreases or funds are paid out). +/// Queue membership is an operational detail; it must not change AUM. Markets cannot be +/// removed from the withdraw_queue while principal > 0. +/// +/// Choose exactly one model and apply it consistently across: +/// - How total assets are computed (get_total_assets), +/// - When markets may be omitted from the withdraw queue (policy_removal), +/// - How last_total_assets is adjusted around write-down/write-up boundaries (paper_aum_undercounting), +/// - How fees are minted and previews are computed. +/// +/// DO NOT mix semantics (e.g., queue-scoped AUM + removal blocked while principal > 0), +/// as that creates mispricing and attack surface. +/// +/// High-level tradeoffs (pick-your-poison, be explicit) +/// 1) Pricing integrity for mints/redeems +/// - GovernanceAbandonment: Prices reflect only active/supported markets. New depositors +/// are shielded from legacy/stuck risk. Governance actions create price jumps independent +/// of cashflows (write-down/write-up). +/// - BalanceSheet: Price moves only with actual cashflows. No policy-driven price jumps. +/// New depositors buy exposure to legacy risk unless deposits are gated/paused. +/// +/// 2) Fee accrual correctness +/// - GovernanceAbandonment: Must protect against spurious fee mint/burn caused by +/// write-down/write-up reclassification. Common pattern: mint fees only on positive delta +/// and bump last_total_assets when re-adding a previously written-down market that still +/// holds principal (see paper_aum_undercounting). +/// - BalanceSheet: Fees accrue strictly on realized growth. No special handling around +/// policy events. Simpler reasoning. +/// +/// 3) Cohort fairness (who bears losses/who captures recovery) +/// - GovernanceAbandonment: Existing holders bear loss at the write-down cutover. +/// Post-write-down entrants can capture recovery when/if the market is re-added. +/// Timelocks + events are the fairness mechanism. +/// - BalanceSheet: Losses/recovery remain within the continuous cohort. No cohort transfer +/// at policy boundaries; new depositors buy the bag unless you gate deposits. +/// +/// 4) Manipulation/attack surface +/// - GovernanceAbandonment: Potential "yo-yo" price via queue changes. Mitigated by +/// meaningful timelocks, public events, and possibly deposit pauses around effective times. +/// Be explicit about timelock durations and eventing. +/// - BalanceSheet: Risk of "optimistic NAV" if operators keep distressed positions in AUM +/// while accepting deposits. Mitigate by pausing/capping deposits and surfacing a +/// "distressed fraction" metric. +/// +/// 5) Liquidity realism in previews +/// - GovernanceAbandonment: Previews align with supported/active markets. Recovery later +/// causes price discontinuity when re-added. +/// - BalanceSheet: NAV reflects all claims, but previews for withdraw may overstate immediacy. +/// Provide a liquidity-aware maxWithdraw estimator along the queue for UIs/policy. +/// +/// 6) Operational UX and liveness +/// - GovernanceAbandonment: Clean lever to amputate toxic limbs and keep the product usable +/// for new money. Requires disciplined governance, communications, and auditable events. +/// - BalanceSheet: No governance-driven price shocks, but product can feel "stuck" if assets +/// are illiquid. UIs must handle long-running withdrawals gracefully. +/// +/// 7) Complexity +/// - GovernanceAbandonment: More policy code (timelocks, queue-scoped AUM, last_total_assets +/// adjustments, explicit events). +/// - BalanceSheet: Simpler accounting; needs better liquidity simulation and deposit gating. +/// +/// Numeric example (to reason about share effects; do not execute) +/// - t0: AUM = 1,000 (100 idle + 900 in Market M), totalSupply = 1,000 shares, price = 1.00. +/// - Model GovernanceAbandonment: +/// t1 write-down: remove M (timelock elapsed), AUM -> 100, price -> 0.10. Existing holders internalize loss. +/// t2 deposit 100: mints 1,000 shares (NAV 100), totalSupply = 2,000. +/// t3 write-up/recovery: re-add M after timelock and bump last_total_assets; AUM -> 1,100, price -> 0.55. +/// Post t1 entrants capture part of recovery. This is intentional under this model. +/// - Model BalanceSheet: +/// t1 acknowledge distress but keep M in AUM. Price stays 1.00. +/// t2 deposit 100: mints 100 shares. New depositors buy distressed exposure. +/// t3 recovery only changes AUM if cash actually moves; no policy jump. +/// +/// Eventing (strongly recommended) +/// - Emit MarketWriteDown(id, principal_at_cutover, when) on removal with principal > 0. +/// - Emit MarketWriteUp(id, principal_at_readd, when) on re-add of a market with principal > 0. +/// - Emit WithdrawQueueUpdated, CapChanged, PendingCapAccepted. +/// Clear, auditable events are essential for both fairness and downstream analytics. +/// +/// Guardrails per model (must-have) +/// - GovernanceAbandonment: +/// * total assets = sum over withdraw_queue only. +/// * allow omission from queue if cap == 0, no pending cap, and (if principal > 0) removable_at set and elapsed. +/// * on re-add of a market that still holds principal, bump last_total_assets by the principal +/// to avoid accidental fee minting. +/// * consider short deposit pauses around write-down/write-up effective times. +/// - BalanceSheet: +/// * total assets = idle + sum principal across all markets (independent of queue). +/// * cannot remove from queue while principal > 0 (timelock is necessary but not sufficient). +/// * publish staged/receivable metrics for ops visibility; do not feed them into pricing. +/// * implement liquidity-aware maxWithdraw/maxRedeem simulators. +/// +/// Testing checklist +/// - Write-down with principal > 0: +/// * GovernanceAbandonment: price drops, no fee minted on loss, re-add bump adjusts last_total_assets. +/// * BalanceSheet: removal blocked; price unchanged until cash moves. +/// - Re-add with principal > 0: +/// * GovernanceAbandonment: last_total_assets bump prevents fee mint; price jumps as intended. +/// * BalanceSheet: re-add is a no-op for accounting; price continuous. +/// - Deposit/withdraw previews across cutovers: no reentrancy or preview mispricing. +/// - Timelock enforcement: cannot write-down or write-up without elapsed timelock. +/// - Attack simulations: attempt to yo-yo the queue within timelocks; ensure protections hold. +/// +/// Migration notes +/// - Changing models after deployment is a breaking policy change. If unavoidable, perform with +/// long lead-time, explicit events, and optionally paused deposits during the switchover. +/// +/// Terminology +/// - "Staged"/"Primed": operational intent to withdraw; does not change AUM by itself. +/// - "Write-down": governance removal from queue (GovernanceAbandonment) => AUM exclusion. +/// - "Write-up": governance re-add to queue (GovernanceAbandonment) => AUM inclusion with last_total_assets bump. mod aum { use super::*; + + /// AUM model selector. + /// + /// GovernanceAbandonment (MetaMorpho-style): + /// - AUM is withdraw_queue-scoped by design: if a market is not in the withdraw_queue, + /// it does not contribute to AUM. + /// - Removing a market with non-zero principal is allowed, but only after a timelock: + /// cap == 0, no pending cap, removable_at set, and block time >= removable_at. + /// - Effect: governance "writes down" that position for AUM purposes even if tokens + /// remain or recovery is possible. + /// + /// BalanceSheet (strict accounting): + /// - AUM includes all positions that still belong to the vault until assets actually + /// move. Queue membership must not change accounting. + /// - Markets cannot be removed from the withdraw_queue while principal > 0. pub enum AUM { - // MetaMorpho treats “AUM” as the assets of active markets that governance still stands behind. - // Once governance has decided (with a timelock) to abandon a market, MetaMorpho writes that position down to zero for AUM purposes by removing it from the withdrawQueue. - // AUM definition is withdrawQueue‑scoped by design. - // - // - totalAssets() sums MORPHO.expectedSupplyAssets over withdrawQueue only. That is a deliberate filter: if a market is not in the withdrawQueue, it does not contribute to AUM. - // - Removing a market with non‑zero supply is allowed, but only after a timelock. - // - updateWithdrawQueue enforces: to remove an entry you must have cap == 0, no pending cap, and if supplyShares != 0 then removableAt must be set and the timelock elapsed. After that, it deletes config[id] and drops the market from the queue. - // - Effect: it’s a governance “write‑down.” The vault stops counting that position in AUM, even if tokens are still there or might be recoverable later. - // - // Why that’s acceptable to them: - // - It prevents new depositors from paying for stranded or possibly unrecoverable positions. Price (shares per asset) only reflects active, opted‑in markets. - // - The decision is gated by a timelock, giving existing holders time to exit before the write‑down takes effect. It’s an explicit, auditable policy action, not an operational side‑effect. + /// GovernanceAbandonment: queue = truth for AUM. See module docs for tradeoffs. GovernanceAbandonment, + /// BalanceSheet: balance sheet = truth for AUM. See module docs for tradeoffs. BalanceSheet, } impl AUM { + /// Compute total assets according to the selected AUM model. + /// + /// Invariants and expectations: + /// - GovernanceAbandonment: + /// * Sums over withdraw_queue only. This is an intentional filter; it encodes + /// governance's current support set and excludes written-down markets. + /// * If you re-add a market that still holds principal, you must pair this with + /// a last_total_assets bump elsewhere (see paper_aum_undercounting) to avoid + /// spurious fee minting on reclassification. + /// + /// - BalanceSheet: + /// * Sums over all markets that still have principal. Here we assume supply_queue + /// enumerates all configured/held markets. If it does not, replace with an + /// iteration over the authoritative positions map (e.g., `config` or `positions`). + /// * AUM changes only when principal changes or idle balance changes. pub fn get_total_assets(&self, c: &Contract) -> U128 { U128(match self { AUM::GovernanceAbandonment => { @@ -1608,6 +1748,20 @@ mod aum { }) } + /// Enforce removal policy for omitting a market from the withdraw_queue. + /// + /// This function should be called at the point where an operator attempts to + /// remove a market from the withdraw_queue. It enforces model-specific invariants. + /// + /// - GovernanceAbandonment: + /// * If the market still has principal, removal requires that a removal timelock + /// was scheduled (removable_at > 0) and has elapsed (now >= removable_at). + /// * Additional guards external to this function are typically required: + /// cap == 0 and no pending cap. Enforce those where caps are managed. + /// + /// - BalanceSheet: + /// * Removal is prohibited while any principal remains (> 0). + /// * Passing a timelock is necessary but not sufficient; ownership hasn't changed. pub fn policy_removal(&self, cfg: &MarketConfiguration, has_supply: &bool) { match self { AUM::GovernanceAbandonment => { @@ -1626,6 +1780,22 @@ mod aum { } } + /// Handle accounting around potential AUM undercounting when re-adding markets. + /// + /// Context: + /// - Under GovernanceAbandonment, a market removed from the withdraw_queue is excluded + /// from AUM even if it still holds principal. When that market is later re-added, + /// its principal "reappears" in reported AUM. To prevent accidental performance fee + /// minting due purely to reclassification (not economic gain), we bump last_total_assets + /// by the previously excluded principal at re-add time. + /// + /// - Under BalanceSheet, AUM was never reduced during removal attempts, so no bump is + /// necessary. Fees accrue naturally on realized growth only. + /// + /// Safety notes: + /// - Only add before_principal that was actually excluded by the prior write-down. + /// - This adjustment assumes your fee module mints fees on positive delta of + /// (current_total_assets - last_total_assets). If your fee policy differs, audit this path. pub fn paper_aum_undercounting(&self, c: &mut Contract, before_principal: &u128) { match self { AUM::GovernanceAbandonment => { From 061a1e8bd38ff9808b58c99dc567606e26c6814f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 11:27:08 +0100 Subject: [PATCH 075/121] chore: move aum to own module --- contract/vault/src/aum.rs | 231 +++++++++++++++++++++++++++++++++++++ contract/vault/src/lib.rs | 235 +------------------------------------- 2 files changed, 232 insertions(+), 234 deletions(-) create mode 100644 contract/vault/src/aum.rs diff --git a/contract/vault/src/aum.rs b/contract/vault/src/aum.rs new file mode 100644 index 00000000..445664bc --- /dev/null +++ b/contract/vault/src/aum.rs @@ -0,0 +1,231 @@ +use super::*; + +/// AUM (Assets Under Management) module +/// +/// This module encodes two coherent accounting models for a vault: +/// - GovernanceAbandonment (MetaMorpho-style): AUM counts only markets that are currently +/// "active" in the withdraw_queue. Governance may perform a timelocked write-down by +/// removing a market from the withdraw_queue even if principal remains. Later, governance +/// may perform a timelocked write-up by re-adding that market. Pricing reflects only the +/// set of markets governance currently stands behind. +/// - BalanceSheet (strict accounting): AUM includes every position that still belongs to +/// the vault until assets actually move (principal decreases or funds are paid out). +/// Queue membership is an operational detail; it must not change AUM. Markets cannot be +/// removed from the withdraw_queue while principal > 0. +/// +/// Choose exactly one model and apply it consistently across: +/// - How total assets are computed (get_total_assets), +/// - When markets may be omitted from the withdraw queue (policy_removal), +/// - How last_total_assets is adjusted around write-down/write-up boundaries (paper_aum_undercounting), +/// - How fees are minted and previews are computed. +/// +/// DO NOT mix semantics (e.g., queue-scoped AUM + removal blocked while principal > 0), +/// as that creates mispricing and attack surface. +/// +/// High-level tradeoffs +/// 1) Pricing integrity for mints/redeems +/// - GovernanceAbandonment: Prices reflect only active/supported markets. New depositors +/// are shielded from legacy/stuck risk. Governance actions create price jumps independent +/// of cashflows (write-down/write-up). +/// - BalanceSheet: Price moves only with actual cashflows. No policy-driven price jumps. +/// New depositors buy exposure to legacy risk unless deposits are gated/paused. +/// +/// 2) Fee accrual correctness +/// - GovernanceAbandonment: Must protect against spurious fee mint/burn caused by +/// write-down/write-up reclassification. Common pattern: mint fees only on positive delta +/// and bump last_total_assets when re-adding a previously written-down market that still +/// holds principal (see paper_aum_undercounting). +/// - BalanceSheet: Fees accrue strictly on realized growth. No special handling around +/// policy events. Simpler reasoning. +/// +/// 3) Cohort fairness (who bears losses/who captures recovery) +/// - GovernanceAbandonment: Existing holders bear loss at the write-down cutover. +/// Post-write-down entrants can capture recovery when/if the market is re-added. +/// Timelocks + events are the fairness mechanism. +/// - BalanceSheet: Losses/recovery remain within the continuous cohort. No cohort transfer +/// at policy boundaries; new depositors buy the bag unless you gate deposits. +/// +/// 4) Manipulation/attack surface +/// - GovernanceAbandonment: Potential "yo-yo" price via queue changes. Mitigated by +/// meaningful timelocks, public events, and possibly deposit pauses around effective times. +/// Be explicit about timelock durations and eventing. +/// - BalanceSheet: Risk of "optimistic NAV" if operators keep distressed positions in AUM +/// while accepting deposits. Mitigate by pausing/capping deposits and surfacing a +/// "distressed fraction" metric. +/// +/// 5) Liquidity realism in previews +/// - GovernanceAbandonment: Previews align with supported/active markets. Recovery later +/// causes price discontinuity when re-added. +/// - BalanceSheet: NAV reflects all claims, but previews for withdraw may overstate immediacy. +/// Provide a liquidity-aware maxWithdraw estimator along the queue for UIs/policy. +/// +/// 6) Operational UX and liveness +/// - GovernanceAbandonment: Clean lever to amputate toxic limbs and keep the product usable +/// for new money. Requires disciplined governance, communications, and auditable events. +/// - BalanceSheet: No governance-driven price shocks, but product can feel "stuck" if assets +/// are illiquid. UIs must handle long-running withdrawals gracefully. +/// +/// 7) Complexity +/// - GovernanceAbandonment: More policy code (timelocks, queue-scoped AUM, last_total_assets +/// adjustments, explicit events). +/// - BalanceSheet: Simpler accounting; needs better liquidity simulation and deposit gating. +/// +/// Numeric example (to reason about share effects; do not execute) +/// - t0: AUM = 1,000 (100 idle + 900 in Market M), totalSupply = 1,000 shares, price = 1.00. +/// - Model GovernanceAbandonment: +/// t1 write-down: remove M (timelock elapsed), AUM -> 100, price -> 0.10. Existing holders internalize loss. +/// t2 deposit 100: mints 1,000 shares (NAV 100), totalSupply = 2,000. +/// t3 write-up/recovery: re-add M after timelock and bump last_total_assets; AUM -> 1,100, price -> 0.55. +/// Post t1 entrants capture part of recovery. This is intentional under this model. +/// - Model BalanceSheet: +/// t1 acknowledge distress but keep M in AUM. Price stays 1.00. +/// t2 deposit 100: mints 100 shares. New depositors buy distressed exposure. +/// t3 recovery only changes AUM if cash actually moves; no policy jump. +/// +/// Eventing (strongly recommended) +/// - Emit MarketWriteDown(id, principal_at_cutover, when) on removal with principal > 0. +/// - Emit MarketWriteUp(id, principal_at_readd, when) on re-add of a market with principal > 0. +/// - Emit WithdrawQueueUpdated, CapChanged, PendingCapAccepted. +/// Clear, auditable events are essential for both fairness and downstream analytics. +/// +/// Guardrails per model (must-have) +/// - GovernanceAbandonment: +/// * total assets = sum over withdraw_queue only. +/// * allow omission from queue if cap == 0, no pending cap, and (if principal > 0) removable_at set and elapsed. +/// * on re-add of a market that still holds principal, bump last_total_assets by the principal +/// to avoid accidental fee minting. +/// * consider short deposit pauses around write-down/write-up effective times. +/// - BalanceSheet: +/// * total assets = idle + sum principal across all markets (independent of queue). +/// * cannot remove from queue while principal > 0 (timelock is necessary but not sufficient). +/// * publish staged/receivable metrics for ops visibility; do not feed them into pricing. +/// * implement liquidity-aware maxWithdraw/maxRedeem simulators. +/// +/// Testing checklist +/// - Write-down with principal > 0: +/// * GovernanceAbandonment: price drops, no fee minted on loss, re-add bump adjusts last_total_assets. +/// * BalanceSheet: removal blocked; price unchanged until cash moves. +/// - Re-add with principal > 0: +/// * GovernanceAbandonment: last_total_assets bump prevents fee mint; price jumps as intended. +/// * BalanceSheet: re-add is a no-op for accounting; price continuous. +/// - Deposit/withdraw previews across cutovers: no reentrancy or preview mispricing. +/// - Timelock enforcement: cannot write-down or write-up without elapsed timelock. +/// - Attack simulations: attempt to yo-yo the queue within timelocks; ensure protections hold. +/// +/// Migration notes +/// - Changing models after deployment is a breaking policy change. If unavoidable, perform with +/// long lead-time, explicit events, and optionally paused deposits during the switchover. +/// +/// Terminology +/// - "Staged"/"Primed": operational intent to withdraw; does not change AUM by itself. +/// - "Write-down": governance removal from queue (GovernanceAbandonment) => AUM exclusion. +/// - "Write-up": governance re-add to queue (GovernanceAbandonment) => AUM inclusion with last_total_assets bump. +/// AUM model selector. +/// +/// GovernanceAbandonment (MetaMorpho-style): +/// - AUM is withdraw_queue-scoped by design: if a market is not in the withdraw_queue, +/// it does not contribute to AUM. +/// - Removing a market with non-zero principal is allowed, but only after a timelock: +/// cap == 0, no pending cap, removable_at set, and block time >= removable_at. +/// - Effect: governance "writes down" that position for AUM purposes even if tokens +/// remain or recovery is possible. +/// +/// BalanceSheet (strict accounting): +/// - AUM includes all positions that still belong to the vault until assets actually +/// move. Queue membership must not change accounting. +/// - Markets cannot be removed from the withdraw_queue while principal > 0. +pub enum AUM { + /// GovernanceAbandonment: queue = truth for AUM. See module docs for tradeoffs. + GovernanceAbandonment, + /// BalanceSheet: balance sheet = truth for AUM. See module docs for tradeoffs. + BalanceSheet, +} + +impl AUM { + /// Compute total assets according to the selected AUM model. + /// + /// Invariants and expectations: + /// - GovernanceAbandonment: + /// * Sums over withdraw_queue only. This is an intentional filter; it encodes + /// governance's current support set and excludes written-down markets. + /// * If you re-add a market that still holds principal, you must pair this with + /// a last_total_assets bump elsewhere (see paper_aum_undercounting) to avoid + /// spurious fee minting on reclassification. + /// + /// - BalanceSheet: + /// * Sums over all markets that still have principal. Here we assume supply_queue + /// enumerates all configured/held markets. If it does not, replace with an + /// iteration over the authoritative positions map (e.g., `config` or `positions`). + /// * AUM changes only when principal changes or idle balance changes. + pub fn get_total_assets(&self, c: &Contract) -> U128 { + U128(match self { + AUM::GovernanceAbandonment => { + c.withdraw_queue.iter().fold(c.idle_balance, |prev, m| { + prev.saturating_add(c.principal_of(m)) + }) + } + AUM::BalanceSheet => c.supply_queue.iter().fold(c.idle_balance, |prev, m| { + prev.saturating_add(c.principal_of(m)) + }), + }) + } + + /// Enforce removal policy for omitting a market from the withdraw_queue. + /// + /// This function should be called at the point where an operator attempts to + /// remove a market from the withdraw_queue. It enforces model-specific invariants. + /// + /// - GovernanceAbandonment: + /// * If the market still has principal, removal requires that a removal timelock + /// was scheduled (removable_at > 0) and has elapsed (now >= removable_at). + /// * Additional guards external to this function are typically required: + /// cap == 0 and no pending cap. Enforce those where caps are managed. + /// + /// - BalanceSheet: + /// * Removal is prohibited while any principal remains (> 0). + /// * Passing a timelock is necessary but not sufficient; ownership hasn't changed. + pub fn policy_removal(&self, cfg: &MarketConfiguration, has_supply: &bool) { + match self { + AUM::GovernanceAbandonment => { + if *has_supply { + require!( + cfg.removable_at > 0, + "Policy violation: Market still has supply but no removal scheduled" + ); + require!( + env::block_timestamp() >= cfg.removable_at, + "Policy violation: Removal timelock not elapsed for market" + ); + } + } + AUM::BalanceSheet => require!(!has_supply, "Policy violation: Supply shares exist"), + } + } + + /// Handle accounting around potential AUM undercounting when re-adding markets. + /// + /// Context: + /// - Under GovernanceAbandonment, a market removed from the withdraw_queue is excluded + /// from AUM even if it still holds principal. When that market is later re-added, + /// its principal "reappears" in reported AUM. To prevent accidental performance fee + /// minting due purely to reclassification (not economic gain), we bump last_total_assets + /// by the previously excluded principal at re-add time. + /// + /// - Under BalanceSheet, AUM was never reduced during removal attempts, so no bump is + /// necessary. Fees accrue naturally on realized growth only. + /// + /// Safety notes: + /// - Only add before_principal that was actually excluded by the prior write-down. + /// - This adjustment assumes your fee module mints fees on positive delta of + /// (current_total_assets - last_total_assets). If your fee policy differs, audit this path. + pub fn paper_aum_undercounting(&self, c: &mut Contract, before_principal: &u128) { + match self { + AUM::GovernanceAbandonment => { + if *before_principal > 0 { + c.last_total_assets = c.last_total_assets.saturating_add(*before_principal); + } + } + AUM::BalanceSheet => {} + } + } +} diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 9bff43bc..a056f6da 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -39,6 +39,7 @@ use templar_common::{ }; pub use wad::*; +pub mod aum; pub mod impl_callbacks; pub mod impl_token_receiver; pub mod storage_management; @@ -1575,239 +1576,5 @@ impl near_sdk_contract_tools::hook::Hook> for Co } } -/// AUM (Assets Under Management) module -/// -/// This module encodes two coherent accounting models for a vault: -/// - GovernanceAbandonment (MetaMorpho-style): AUM counts only markets that are currently -/// "active" in the withdraw_queue. Governance may perform a timelocked write-down by -/// removing a market from the withdraw_queue even if principal remains. Later, governance -/// may perform a timelocked write-up by re-adding that market. Pricing reflects only the -/// set of markets governance currently stands behind. -/// - BalanceSheet (strict accounting): AUM includes every position that still belongs to -/// the vault until assets actually move (principal decreases or funds are paid out). -/// Queue membership is an operational detail; it must not change AUM. Markets cannot be -/// removed from the withdraw_queue while principal > 0. -/// -/// Choose exactly one model and apply it consistently across: -/// - How total assets are computed (get_total_assets), -/// - When markets may be omitted from the withdraw queue (policy_removal), -/// - How last_total_assets is adjusted around write-down/write-up boundaries (paper_aum_undercounting), -/// - How fees are minted and previews are computed. -/// -/// DO NOT mix semantics (e.g., queue-scoped AUM + removal blocked while principal > 0), -/// as that creates mispricing and attack surface. -/// -/// High-level tradeoffs (pick-your-poison, be explicit) -/// 1) Pricing integrity for mints/redeems -/// - GovernanceAbandonment: Prices reflect only active/supported markets. New depositors -/// are shielded from legacy/stuck risk. Governance actions create price jumps independent -/// of cashflows (write-down/write-up). -/// - BalanceSheet: Price moves only with actual cashflows. No policy-driven price jumps. -/// New depositors buy exposure to legacy risk unless deposits are gated/paused. -/// -/// 2) Fee accrual correctness -/// - GovernanceAbandonment: Must protect against spurious fee mint/burn caused by -/// write-down/write-up reclassification. Common pattern: mint fees only on positive delta -/// and bump last_total_assets when re-adding a previously written-down market that still -/// holds principal (see paper_aum_undercounting). -/// - BalanceSheet: Fees accrue strictly on realized growth. No special handling around -/// policy events. Simpler reasoning. -/// -/// 3) Cohort fairness (who bears losses/who captures recovery) -/// - GovernanceAbandonment: Existing holders bear loss at the write-down cutover. -/// Post-write-down entrants can capture recovery when/if the market is re-added. -/// Timelocks + events are the fairness mechanism. -/// - BalanceSheet: Losses/recovery remain within the continuous cohort. No cohort transfer -/// at policy boundaries; new depositors buy the bag unless you gate deposits. -/// -/// 4) Manipulation/attack surface -/// - GovernanceAbandonment: Potential "yo-yo" price via queue changes. Mitigated by -/// meaningful timelocks, public events, and possibly deposit pauses around effective times. -/// Be explicit about timelock durations and eventing. -/// - BalanceSheet: Risk of "optimistic NAV" if operators keep distressed positions in AUM -/// while accepting deposits. Mitigate by pausing/capping deposits and surfacing a -/// "distressed fraction" metric. -/// -/// 5) Liquidity realism in previews -/// - GovernanceAbandonment: Previews align with supported/active markets. Recovery later -/// causes price discontinuity when re-added. -/// - BalanceSheet: NAV reflects all claims, but previews for withdraw may overstate immediacy. -/// Provide a liquidity-aware maxWithdraw estimator along the queue for UIs/policy. -/// -/// 6) Operational UX and liveness -/// - GovernanceAbandonment: Clean lever to amputate toxic limbs and keep the product usable -/// for new money. Requires disciplined governance, communications, and auditable events. -/// - BalanceSheet: No governance-driven price shocks, but product can feel "stuck" if assets -/// are illiquid. UIs must handle long-running withdrawals gracefully. -/// -/// 7) Complexity -/// - GovernanceAbandonment: More policy code (timelocks, queue-scoped AUM, last_total_assets -/// adjustments, explicit events). -/// - BalanceSheet: Simpler accounting; needs better liquidity simulation and deposit gating. -/// -/// Numeric example (to reason about share effects; do not execute) -/// - t0: AUM = 1,000 (100 idle + 900 in Market M), totalSupply = 1,000 shares, price = 1.00. -/// - Model GovernanceAbandonment: -/// t1 write-down: remove M (timelock elapsed), AUM -> 100, price -> 0.10. Existing holders internalize loss. -/// t2 deposit 100: mints 1,000 shares (NAV 100), totalSupply = 2,000. -/// t3 write-up/recovery: re-add M after timelock and bump last_total_assets; AUM -> 1,100, price -> 0.55. -/// Post t1 entrants capture part of recovery. This is intentional under this model. -/// - Model BalanceSheet: -/// t1 acknowledge distress but keep M in AUM. Price stays 1.00. -/// t2 deposit 100: mints 100 shares. New depositors buy distressed exposure. -/// t3 recovery only changes AUM if cash actually moves; no policy jump. -/// -/// Eventing (strongly recommended) -/// - Emit MarketWriteDown(id, principal_at_cutover, when) on removal with principal > 0. -/// - Emit MarketWriteUp(id, principal_at_readd, when) on re-add of a market with principal > 0. -/// - Emit WithdrawQueueUpdated, CapChanged, PendingCapAccepted. -/// Clear, auditable events are essential for both fairness and downstream analytics. -/// -/// Guardrails per model (must-have) -/// - GovernanceAbandonment: -/// * total assets = sum over withdraw_queue only. -/// * allow omission from queue if cap == 0, no pending cap, and (if principal > 0) removable_at set and elapsed. -/// * on re-add of a market that still holds principal, bump last_total_assets by the principal -/// to avoid accidental fee minting. -/// * consider short deposit pauses around write-down/write-up effective times. -/// - BalanceSheet: -/// * total assets = idle + sum principal across all markets (independent of queue). -/// * cannot remove from queue while principal > 0 (timelock is necessary but not sufficient). -/// * publish staged/receivable metrics for ops visibility; do not feed them into pricing. -/// * implement liquidity-aware maxWithdraw/maxRedeem simulators. -/// -/// Testing checklist -/// - Write-down with principal > 0: -/// * GovernanceAbandonment: price drops, no fee minted on loss, re-add bump adjusts last_total_assets. -/// * BalanceSheet: removal blocked; price unchanged until cash moves. -/// - Re-add with principal > 0: -/// * GovernanceAbandonment: last_total_assets bump prevents fee mint; price jumps as intended. -/// * BalanceSheet: re-add is a no-op for accounting; price continuous. -/// - Deposit/withdraw previews across cutovers: no reentrancy or preview mispricing. -/// - Timelock enforcement: cannot write-down or write-up without elapsed timelock. -/// - Attack simulations: attempt to yo-yo the queue within timelocks; ensure protections hold. -/// -/// Migration notes -/// - Changing models after deployment is a breaking policy change. If unavoidable, perform with -/// long lead-time, explicit events, and optionally paused deposits during the switchover. -/// -/// Terminology -/// - "Staged"/"Primed": operational intent to withdraw; does not change AUM by itself. -/// - "Write-down": governance removal from queue (GovernanceAbandonment) => AUM exclusion. -/// - "Write-up": governance re-add to queue (GovernanceAbandonment) => AUM inclusion with last_total_assets bump. -mod aum { - use super::*; - - /// AUM model selector. - /// - /// GovernanceAbandonment (MetaMorpho-style): - /// - AUM is withdraw_queue-scoped by design: if a market is not in the withdraw_queue, - /// it does not contribute to AUM. - /// - Removing a market with non-zero principal is allowed, but only after a timelock: - /// cap == 0, no pending cap, removable_at set, and block time >= removable_at. - /// - Effect: governance "writes down" that position for AUM purposes even if tokens - /// remain or recovery is possible. - /// - /// BalanceSheet (strict accounting): - /// - AUM includes all positions that still belong to the vault until assets actually - /// move. Queue membership must not change accounting. - /// - Markets cannot be removed from the withdraw_queue while principal > 0. - pub enum AUM { - /// GovernanceAbandonment: queue = truth for AUM. See module docs for tradeoffs. - GovernanceAbandonment, - /// BalanceSheet: balance sheet = truth for AUM. See module docs for tradeoffs. - BalanceSheet, - } - - impl AUM { - /// Compute total assets according to the selected AUM model. - /// - /// Invariants and expectations: - /// - GovernanceAbandonment: - /// * Sums over withdraw_queue only. This is an intentional filter; it encodes - /// governance's current support set and excludes written-down markets. - /// * If you re-add a market that still holds principal, you must pair this with - /// a last_total_assets bump elsewhere (see paper_aum_undercounting) to avoid - /// spurious fee minting on reclassification. - /// - /// - BalanceSheet: - /// * Sums over all markets that still have principal. Here we assume supply_queue - /// enumerates all configured/held markets. If it does not, replace with an - /// iteration over the authoritative positions map (e.g., `config` or `positions`). - /// * AUM changes only when principal changes or idle balance changes. - pub fn get_total_assets(&self, c: &Contract) -> U128 { - U128(match self { - AUM::GovernanceAbandonment => { - c.withdraw_queue.iter().fold(c.idle_balance, |prev, m| { - prev.saturating_add(c.principal_of(m)) - }) - } - AUM::BalanceSheet => c.supply_queue.iter().fold(c.idle_balance, |prev, m| { - prev.saturating_add(c.principal_of(m)) - }), - }) - } - - /// Enforce removal policy for omitting a market from the withdraw_queue. - /// - /// This function should be called at the point where an operator attempts to - /// remove a market from the withdraw_queue. It enforces model-specific invariants. - /// - /// - GovernanceAbandonment: - /// * If the market still has principal, removal requires that a removal timelock - /// was scheduled (removable_at > 0) and has elapsed (now >= removable_at). - /// * Additional guards external to this function are typically required: - /// cap == 0 and no pending cap. Enforce those where caps are managed. - /// - /// - BalanceSheet: - /// * Removal is prohibited while any principal remains (> 0). - /// * Passing a timelock is necessary but not sufficient; ownership hasn't changed. - pub fn policy_removal(&self, cfg: &MarketConfiguration, has_supply: &bool) { - match self { - AUM::GovernanceAbandonment => { - if *has_supply { - require!( - cfg.removable_at > 0, - "Policy violation: Market still has supply but no removal scheduled" - ); - require!( - env::block_timestamp() >= cfg.removable_at, - "Policy violation: Removal timelock not elapsed for market" - ); - } - } - AUM::BalanceSheet => require!(!has_supply, "Policy violation: Supply shares exist"), - } - } - - /// Handle accounting around potential AUM undercounting when re-adding markets. - /// - /// Context: - /// - Under GovernanceAbandonment, a market removed from the withdraw_queue is excluded - /// from AUM even if it still holds principal. When that market is later re-added, - /// its principal "reappears" in reported AUM. To prevent accidental performance fee - /// minting due purely to reclassification (not economic gain), we bump last_total_assets - /// by the previously excluded principal at re-add time. - /// - /// - Under BalanceSheet, AUM was never reduced during removal attempts, so no bump is - /// necessary. Fees accrue naturally on realized growth only. - /// - /// Safety notes: - /// - Only add before_principal that was actually excluded by the prior write-down. - /// - This adjustment assumes your fee module mints fees on positive delta of - /// (current_total_assets - last_total_assets). If your fee policy differs, audit this path. - pub fn paper_aum_undercounting(&self, c: &mut Contract, before_principal: &u128) { - match self { - AUM::GovernanceAbandonment => { - if *before_principal > 0 { - c.last_total_assets = c.last_total_assets.saturating_add(*before_principal); - } - } - AUM::BalanceSheet => {} - } - } - } -} - #[cfg(test)] mod tests; From 95fdede73826c2e19306dee5e62c8f6df7474391 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 11:55:24 +0100 Subject: [PATCH 076/121] chore: move tests to slim down sloc --- common/src/vault.rs | 8 + contract/vault/src/impl_callbacks.rs | 907 +--------------------- contract/vault/src/impl_token_receiver.rs | 5 +- contract/vault/src/tests.rs | 882 ++++++++++++++++++++- 4 files changed, 894 insertions(+), 908 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index d85c4e80..8dbc5a81 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -413,10 +413,15 @@ pub enum Event { deposit_accepted: U128, }, + #[event_version("1.0.0")] + PerformanceFeeAccrued { recipient: AccountId, shares: U128 }, + // Admin and configuration events #[event_version("1.0.0")] CuratorSet { account: AccountId }, #[event_version("1.0.0")] + GuardianSet { account: AccountId }, + #[event_version("1.0.0")] AllocatorRoleSet { account: AccountId, allowed: bool }, #[event_version("1.0.0")] SkimRecipientSet { account: AccountId }, @@ -441,6 +446,9 @@ pub enum Event { new_cap: U128, valid_at: u64, }, + #[event_version("1.0.0")] + SupplyCapRaiseRevoked { market: AccountId }, + #[event_version("1.0.0")] SupplyCapSet { market: AccountId, new_cap: U128 }, #[event_version("1.0.0")] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 564e9e90..260c6656 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -671,13 +671,13 @@ impl Contract { } } -pub(crate) struct SupplyReconciliation { - new_principal: u128, - accepted_event: u128, - remaining: u128, +pub struct SupplyReconciliation { + pub new_principal: u128, + pub accepted_event: u128, + pub remaining: u128, } -pub(crate) fn reconcile_supply_outcome( +pub fn reconcile_supply_outcome( total_position: &u128, before: &u128, remaining: &u128, @@ -699,7 +699,7 @@ pub struct WithdrawReconciliation { } /// Pure reconciliation for withdraw read outcome to enable unit tests -pub(crate) fn reconcile_withdraw_outcome( +pub fn reconcile_withdraw_outcome( before_principal: u128, new_principal: u128, remaining_total: u128, @@ -717,898 +717,3 @@ pub(crate) fn reconcile_withdraw_outcome( idle_delta, } } - -#[cfg(test)] -mod tests { - use std::u128; - - use crate::impl_callbacks::reconcile_supply_outcome; - use crate::test_utils::*; - - use near_sdk::json_types::U128; - use near_sdk::test_utils::accounts; - use near_sdk::PromiseOrValue; - use near_sdk::PromiseResult; - use near_sdk_contract_tools::ft::Nep141 as _; - use rstest::rstest; - - use crate::Contract; - use near_sdk::AccountId; - use rstest::fixture; - use templar_common::vault::Error; - use templar_common::vault::OpState; - - #[fixture] - fn vault_id() -> AccountId { - accounts(0) - } - - #[fixture] - fn c(vault_id: AccountId) -> Contract { - setup_env(&vault_id, &vault_id, vec![]); - 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( - &vault_id, - &vault_id, - vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), - )], - ); - new_test_contract(&vault_id) - } - - #[fixture] - fn receiver() -> AccountId { - mk(9) - } - - #[fixture] - fn owner() -> AccountId { - accounts(1) - } - - #[rstest] - fn after_supply_1_check_allocating_not_allocating(mut c_max: Contract) { - let mut c = c_max; - - c.op_state = OpState::Idle; - - c.after_supply_1_check(Ok(U128(1)), 0, 2, Default::default()); - - assert_eq!(c.op_state, OpState::Idle); - assert_eq!(c.plan, None); - } - - #[test] - fn after_supply_1_check_allocating_not_allocating_index() { - let vault_id = accounts(0); - setup_env( - &vault_id, - &vault_id, - vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), - )], - ); - - let mut c = new_test_contract(&vault_id); - - let op_id = 1; - let receiver = mk(7); - - c.op_state = OpState::Allocating { - op_id, - index: 0u32, - remaining: 0u128, - }; - - c.after_supply_1_check(Ok(U128(1)), op_id + 1, 0, Default::default()); - - assert_eq!(c.op_state, OpState::Idle); - assert_eq!(c.plan, None); - } - - #[test] - fn after_supply_1_check_allocating() { - let vault_id = accounts(0); - setup_env( - &vault_id, - &vault_id, - vec![PromiseResult::Successful( - near_sdk::serde_json::to_vec(&U128(u128::MAX)) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), - )], - ); - - let mut c = new_test_contract(&vault_id); - - let op_id = 1; - let receiver = mk(7); - - c.op_state = OpState::Allocating { - op_id, - index: 0u32, - remaining: 0u128, - }; - - c.after_supply_1_check(Ok(U128(1)), op_id, 0, Default::default()); - - assert_eq!(c.op_state, OpState::Idle); - assert_eq!(c.plan, None); - } - - #[test] - fn after_send_to_user_success_no_escrow() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - - let mut c = new_test_contract(&vault_id); - - let receiver = mk(7); - - c.idle_balance = 1_000; - c.op_state = OpState::Payout { - op_id: 1, - receiver: receiver.clone(), - amount: 200, - owner: accounts(1), - escrow_shares: 0, - burn_shares: 0, - }; - - let ok = c.after_send_to_user(Ok(()), 1, receiver.clone(), U128(200)); - assert!(ok, "Payout should report success"); - assert_eq!(c.idle_balance, 800, "Idle balance must decrease by payout"); - assert!( - matches!(c.op_state, OpState::Idle), - "Vault must go Idle after successful payout" - ); - } - - #[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_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 100); - - // Withdrawing: need 60, already collected 10; expect position None => new_principal = 0, withdrawn = 100, credited = min(100, 60) = 60 - c.op_state = OpState::Withdrawing { - op_id: 42, - index: 0, - remaining: 60, - receiver: mk(9), - collected: 10, - owner: accounts(1), - escrow_shares: 50, - }; - - let res = c.after_exec_withdraw_read(Ok(None), 42, 0, U128(100), U128(60)); - - match res { - PromiseOrValue::Promise(p) => {} - _ => panic!("Expected a Promise to send payout"), - } - - assert_eq!( - *c.market_supply.get(&market).unwrap_or(&u128::MAX), - 0, - "Market principal should be updated to 0" - ); - - assert_eq!( - c.idle_balance, 100, - "Idle balance should increase by returned amount" - ); - - // State should transition to Payout with amount = collected (10) + credited (60) = 70 - match &c.op_state { - OpState::Payout { amount, .. } => { - assert_eq!(*amount, 70, "Payout amount must match collected + credited"); - } - other => panic!("Unexpected state after read: {other:?}"), - } - } - - #[test] - fn after_skim_balance_zero_noop() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - - let mut c = new_test_contract(&vault_id); - - let res = c.after_skim_balance(Ok(U128(0)), mk(10), mk(11)); - match res { - PromiseOrValue::Value(()) => {} - _ => panic!("Skim with zero balance must be a no-op"), - } - } - - #[test] - fn after_skim_balance_positive_returns_promise() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - - let mut c = new_test_contract(&vault_id); - - // Positive balance -> Promise to ft_transfer - let res = c.after_skim_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: Payout failure keeps idle_balance unchanged and does not burn escrow - #[rstest( - idle => [0u128, 1, 100], - escrow => [0u128, 1, 50], - amount => [0u128, 1, 25] - )] - fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: u128) { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - - let receiver = mk(7); - let owner = accounts(1); - - if escrow > 0 { - use near_sdk_contract_tools::ft::Nep141Controller as _; - - c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); - } - - c.idle_balance = idle; - c.op_state = OpState::Payout { - op_id: 1, - receiver: receiver.clone(), - amount, - owner: owner.clone(), - escrow_shares: escrow, - burn_shares: escrow, - }; - - let before = c.idle_balance; - let ok = c.after_send_to_user( - Err(near_sdk::PromiseError::Failed), - 1, - receiver.clone(), - U128(amount), - ); - assert!(!ok, "Payout failure should return false"); - assert_eq!( - c.idle_balance, before, - "idle_balance must stay the same on payout failure" - ); - assert!( - matches!(c.op_state, OpState::Idle), - "Vault must go Idle after payout failure" - ); - } - - /// 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); - - // Single-market queue so advancing index reaches end-of-queue - let market = mk(8); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 100); - - c.op_state = OpState::Withdrawing { - op_id: 7, - index: 0, - remaining: need, - receiver: mk(9), - collected, - owner: accounts(1), - escrow_shares: 0, - }; - - let res = - c.after_create_withdraw_req(Err(near_sdk::PromiseError::Failed), 7, 0, U128(need)); - match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise after skipping to payout at end-of-queue"), - } - - match &c.op_state { - OpState::Payout { 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], - need => [0u128, 1u128, 50u128], - collected => [1u128, 2u128] - )] - fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collected: u128) { - 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.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), before); - - let initial_idle = c.idle_balance; - - c.op_state = OpState::Withdrawing { - op_id: 99, - index: 0, - remaining: need, - receiver: mk(9), - collected, - owner: accounts(1), - escrow_shares: 0, - }; - - let res = c.after_exec_withdraw_read( - Err(near_sdk::PromiseError::Failed), - 99, - 0, - U128(before), - U128(need), - ); - match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise to send payout at end-of-queue"), - } - - assert_eq!( - *c.market_supply.get(&market).unwrap_or(&u128::MAX), - before, - "principal must remain unchanged on read failure" - ); - assert_eq!( - c.idle_balance, initial_idle, - "idle_balance must not change when nothing credited" - ); - - match &c.op_state { - OpState::Payout { amount, .. } => { - assert_eq!(*amount, collected, "Payout amount must equal collected"); - } - other => panic!("Unexpected state: {other:?}"), - } - } - - /// Property: Callbacks must match current op_id or index; otherwise stop and go Idle - #[rstest( - pass_op => [false, true], - pass_index => [false, true] - )] - fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_index: bool) { - 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.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 10); - - let real_op = 5u64; - let real_idx = 0u32; - - c.op_state = OpState::Withdrawing { - op_id: real_op, - index: real_idx, - remaining: 1, - receiver: mk(9), - collected: 1, - owner: accounts(1), - escrow_shares: 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 }; - - let r = c.after_exec_withdraw_read(Ok(None), call_op, call_idx, U128(10), U128(1)); - if let (true, true) = (pass_op, pass_index) { - assert!( - !matches!(c.op_state, OpState::Idle), - "Valid callback should not immediately stop" - ); - } else { - // Any mismatch should stop and go Idle - if let PromiseOrValue::Value(()) = r {} - assert!( - matches!(c.op_state, OpState::Idle), - "Mismatched callback must stop and go Idle" - ); - } - } - - #[test] - fn refund_path_consistency() { - 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); - - // 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())); - - // Single-market withdraw queue (not used functionally here, just to satisfy path) - let market = mk(12); - c.withdraw_queue.push(market); - - // Withdrawing state with remaining=0 and collected=0 forces refund path - c.op_state = OpState::Withdrawing { - op_id: 77, - index: 0, - remaining: 0, - receiver: mk(9), - collected: 0, - owner: owner.clone(), - escrow_shares: 10, - }; - - 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); - - // Read result with need=0 ensures credited=0; triggers refund branch - let res = c.after_exec_withdraw_read(Ok(None), 77, 0, U128(0), U128(0)); - match res { - PromiseOrValue::Value(()) => {} - _ => panic!("Expected Value(()) on immediate escrow refund"), - } - - // No burn/mint => total supply unchanged - assert_eq!( - c.total_supply(), - supply_before, - "no supply change on refund" - ); - // Escrow shares transferred back to owner - assert_eq!( - c.balance_of(&near_sdk::env::current_account_id()), - vault_before.saturating_sub(10), - "vault should lose refunded escrow" - ); - assert_eq!( - c.balance_of(&owner), - owner_before.saturating_add(10), - "owner should receive refunded escrow" - ); - // Vault returns to Idle - assert!( - matches!(c.op_state, OpState::Idle), - "Vault must go Idle after refund" - ); - } - - #[test] - fn ctx_allocating_ok_and_err() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - - c.op_state = OpState::Allocating { - op_id: 42, - index: 3, - remaining: 77, - }; - - let ok = c.ctx_allocating(42).expect("ctx_allocating should succeed"); - assert_eq!(ok, (3, 77)); - - // Wrong op_id => error - assert!(c.ctx_allocating(43).is_err()); - } - - #[test] - fn ctx_withdrawing_ok_and_err() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - - let recv = mk(1); - let owner = accounts(1); - - c.op_state = OpState::Withdrawing { - op_id: 7, - index: 1, - remaining: 50, - receiver: recv.clone(), - collected: 5, - owner: owner.clone(), - escrow_shares: 10, - }; - - let (idx, rem, r, coll, o, escrow) = c - .ctx_withdrawing(7) - .expect("ctx_withdrawing should succeed"); - assert_eq!(idx, 1); - assert_eq!(rem, 50); - assert_eq!(r, recv); - assert_eq!(coll, 5); - assert_eq!(o, owner); - assert_eq!(escrow, 10); - - // Wrong op_id => error - assert!(c.ctx_withdrawing(8).is_err()); - } - - #[test] - fn resolve_market_helpers_supply_and_withdraw() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - - // Prepare markets - let m1 = mk(1001); - let m2 = mk(1002); - - // Supply: plan takes precedence - c.plan = Some(vec![(m2.clone(), 1u128)]); - c.supply_queue.push(m1.clone()); - c.supply_queue.push(m2.clone()); - - assert_eq!(c.resolve_supply_market(0).unwrap(), m2); - assert!(matches!( - c.resolve_supply_market(1), - Err(Error::MissingMarket(1)) - )); - - // Without plan, use queue - c.plan = None; - assert_eq!(c.resolve_supply_market(0).unwrap(), m1); - assert_eq!(c.resolve_supply_market(1).unwrap(), m2); - assert!(matches!( - c.resolve_supply_market(2), - Err(Error::MissingMarket(2)) - )); - - // Withdraw resolver uses withdraw_queue - c.withdraw_queue.push(m1.clone()); - c.withdraw_queue.push(m2.clone()); - assert_eq!(c.resolve_withdraw_market(0).unwrap(), m1); - assert_eq!(c.resolve_withdraw_market(1).unwrap(), m2); - assert!(matches!( - c.resolve_withdraw_market(2), - Err(Error::MissingMarket(2)) - )); - } - - #[test] - fn after_supply_2_read_missing_position_stops() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - - // Resolve market via supply_queue - let market = mk(42); - c.supply_queue.push(market); - - // Must be in Allocating ctx - c.op_state = OpState::Allocating { - op_id: 1, - index: 0, - remaining: 10, - }; - - // Missing position -> stop_and_exit - let res = c.after_supply_2_read(Ok(None), 1, 0, U128(0), U128(5), U128(5)); - match res { - PromiseOrValue::Value(()) => {} - _ => panic!("Expected Value on missing position"), - } - assert!(matches!(c.op_state, OpState::Idle)); - } - - #[test] - fn after_supply_2_read_read_failed_stops() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - - // Resolve market via supply_queue - let market = mk(43); - c.supply_queue.push(market); - - // Must be in Allocating ctx - c.op_state = OpState::Allocating { - op_id: 7, - index: 0, - remaining: 100, - }; - - // Read failure -> stop_and_exit - let res = c.after_supply_2_read( - Err(near_sdk::PromiseError::Failed), - 7, - 0, - U128(0), - U128(10), - U128(10), - ); - match res { - PromiseOrValue::Value(()) => {} - _ => panic!("Expected Value on read failure"), - } - assert!(matches!(c.op_state, OpState::Idle)); - } - - #[rstest] - fn after_create_withdraw_req_success_returns_promise( - mut c: Contract, - receiver: AccountId, - owner: AccountId, - ) { - let market = mk(50); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 100); - - c.op_state = OpState::Withdrawing { - op_id: 21, - index: 0, - remaining: 60, - receiver: receiver.clone(), - collected: 10, - owner: owner.clone(), - escrow_shares: 5, - }; - - let res = c.after_create_withdraw_req(Ok(()), 21, 0, U128(60)); - match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise when create succeeds"), - } - // State remains Withdrawing and will continue via the promise chain - assert!(matches!(c.op_state, OpState::Withdrawing { .. })); - } - - #[rstest] - fn after_exec_withdraw_req_returns_promise(mut c: Contract) { - let market = mk(60); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 10); - - c.op_state = OpState::Withdrawing { - op_id: 33, - index: 0, - remaining: 5, - receiver: mk(9), - collected: 0, - owner: accounts(1), - escrow_shares: 0, - }; - - let res = c.after_exec_withdraw_req(33, 0, U128(5)); - match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise to read supply position after exec"), - } - assert!(matches!(c.op_state, OpState::Withdrawing { .. })); - } - - #[rstest] - fn after_exec_withdraw_read_advances_when_remaining( - mut c: Contract, - owner: AccountId, - receiver: AccountId, - ) { - // Two markets; first has principal to withdraw - let m1 = mk(70); - let m2 = mk(71); - c.withdraw_queue.push(m1.clone()); - c.withdraw_queue.push(m2.clone()); - c.market_supply.insert(m1.clone(), 10); - - c.op_state = OpState::Withdrawing { - op_id: 0, - index: 0, - remaining: 100, - receiver: receiver.clone(), - collected: 0, - owner: owner.clone(), - escrow_shares: 0, - }; - - // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 - let res = c.after_exec_withdraw_read(Ok(None), 0, 0, U128(10), U128(100)); - match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise to continue withdraw steps"), - } - - // Idle credited, state advanced to next index with remaining reduced - assert_eq!(c.idle_balance, 10); - - // This works - match &c.op_state { - OpState::Payout { - op_id, - receiver: r, - amount, - owner: o, - escrow_shares, - burn_shares, - } => { - assert_eq!(*op_id, 0); - assert_eq!(*amount, 10); - assert_eq!(*escrow_shares, 0); - assert_eq!(*burn_shares, 0); - assert_eq!(*r, receiver); - assert_eq!(*o, owner); - } - other => panic!("Unexpected state after advancing: {other:?}"), - } - } - - #[rstest] - fn stop_and_exit_when_idle_emits_and_stays_idle(mut c: Contract) { - // Already Idle; ensure branch is executed - c.op_state = OpState::Idle; - - let res = c.stop_and_exit::<&str>(Some(&"reason")); - match res { - PromiseOrValue::Value(()) => {} - _ => panic!("Expected Value on stop while Idle"), - } - assert!(matches!(c.op_state, OpState::Idle)); - } - #[test] - fn accepts_increase_and_decrements_remaining() { - let out = reconcile_supply_outcome(&1_600, &1_000, &1_000); - let expected_accepted = 1_600u128.saturating_sub(1_000); - let expected_remaining = 1_000u128.saturating_sub(expected_accepted); - - assert_eq!(out.new_principal, 1_600); - assert_eq!(out.accepted_event, expected_accepted); // 600 - assert_eq!(out.remaining, expected_remaining); // 400 - } - - #[test] - fn no_accept_when_total_does_not_increase() { - // decreased - let out = reconcile_supply_outcome(&1_500, &2_000, &5_000); - assert_eq!(out.new_principal, 1_500); - assert_eq!(out.accepted_event, 0); - assert_eq!(out.remaining, 5_000); - - // equal - let out = reconcile_supply_outcome(&2_000, &2_000, &1_234); - assert_eq!(out.new_principal, 2_000); - assert_eq!(out.accepted_event, 0); - assert_eq!(out.remaining, 1_234); - } - - #[test] - fn remaining_saturates_to_zero_when_acceptance_exceeds_it() { - let out = reconcile_supply_outcome(&u128::MAX, &0, &1); - assert_eq!(out.new_principal, u128::MAX); - assert_eq!(out.accepted_event, u128::MAX); - assert_eq!(out.remaining, 0); - - let out = reconcile_supply_outcome(&10_000, &0, &5); - assert_eq!(out.new_principal, 10_000); - assert_eq!(out.accepted_event, 10_000); - assert_eq!(out.remaining, 0); - } - - #[test] - fn handles_extreme_boundaries_correctly() { - let out = reconcile_supply_outcome(&0, &0, &0); - assert_eq!(out.new_principal, 0); - assert_eq!(out.accepted_event, 0); - assert_eq!(out.remaining, 0); - - let out = reconcile_supply_outcome(&0, &u128::MAX, &123); - assert_eq!(out.new_principal, 0); - assert_eq!(out.accepted_event, 0); - assert_eq!(out.remaining, 123); - - let out = reconcile_supply_outcome(&u128::MAX, &(u128::MAX - 5), &2); - assert_eq!(out.new_principal, u128::MAX); - assert_eq!(out.accepted_event, 5); - assert_eq!(out.remaining, 0); - } - - #[rstest] - 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; - - // 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())); - - // Enter Payout with non-zero escrow - c.op_state = OpState::Payout { - op_id: 123, - receiver: receiver.clone(), - amount: 77, - owner: owner.clone(), - escrow_shares: escrow, - burn_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 idle_before = c.idle_balance; - - c.stop_and_exit_payout::<&str>(Some(&"reason")); - - // Escrow refunded, no burn, vault goes Idle - assert!(matches!(c.op_state, OpState::Idle)); - assert_eq!(c.total_supply(), supply_before, "No burn/mint on stop"); - assert_eq!( - c.balance_of(&near_sdk::env::current_account_id()), - vault_before.saturating_sub(escrow), - "Vault should transfer escrow to owner" - ); - assert_eq!( - c.balance_of(&owner), - owner_before.saturating_add(escrow), - "Owner should receive escrow refund" - ); - assert_eq!(c.idle_balance, idle_before, "Idle balance unchanged"); - } - - #[rstest] - fn stop_and_exit_payout_zero_escrow_just_idle( - mut c: Contract, - owner: AccountId, - receiver: AccountId, - ) { - // Enter Payout with zero escrow; no transfers should occur - c.op_state = OpState::Payout { - op_id: 7, - receiver, - amount: 1, - owner: owner.clone(), - escrow_shares: 0, - burn_shares: 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()); - - c.stop_and_exit_payout::<&str>(None); - - assert!(matches!(c.op_state, OpState::Idle)); - assert_eq!(c.ft_total_supply(), supply_before, "No supply change"); - assert_eq!( - c.ft_balance_of(near_sdk::env::current_account_id()), - vault_before, - "Vault balance unchanged" - ); - assert_eq!( - c.ft_balance_of(owner), - owner_before, - "Owner balance unchanged" - ); - } -} diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 13f7d841..e09a5979 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -1,10 +1,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 templar_common::{ - asset::ReturnStyle, - vault::{require_at_least, AllocationMode, DepositMsg, Event, SUPPLY_GAS}, -}; +use templar_common::vault::{require_at_least, AllocationMode, DepositMsg, Event, SUPPLY_GAS}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index fa276e03..d5308cb6 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,5 +1,6 @@ use std::u64; +use crate::impl_callbacks::reconcile_supply_outcome; use crate::impl_callbacks::WithdrawReconciliation; use crate::storage_management::storage_bytes_for_queue_account_id; use crate::storage_management::yocto_for_bytes; @@ -13,11 +14,14 @@ use near_sdk::env; use near_sdk::serde_json; use near_sdk::test_utils::accounts; use near_sdk::PromiseOrValue; +use near_sdk::PromiseResult; use near_sdk::{json_types::U128, AccountId}; +use near_sdk_contract_tools::ft::Nep141 as _; use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::mt::Nep245Receiver as _; use near_sdk_contract_tools::owner::OwnerExternal; use rstest::{fixture, rstest}; +use templar_common::vault::Error; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; use templar_common::vault::{AllocationMode, DepositMsg}; @@ -35,7 +39,7 @@ fn c_vault_env(vault_id_fixture: AccountId) -> Contract { #[fixture] fn c_owner_env(vault_id_fixture: AccountId) -> Contract { - let mut c = new_test_contract(&vault_id_fixture); + let c = new_test_contract(&vault_id_fixture); let owner = c .own_get_owner() .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); @@ -45,7 +49,7 @@ fn c_owner_env(vault_id_fixture: AccountId) -> Contract { #[fixture] fn c_asset_env(vault_id_fixture: AccountId) -> Contract { - let mut c = new_test_contract(&vault_id_fixture); + let c = new_test_contract(&vault_id_fixture); let asset: AccountId = c.underlying_asset.contract_id().into(); setup_env(&vault_id_fixture, &asset, vec![]); c @@ -60,6 +64,41 @@ fn enabled_market_100() -> (AccountId, MarketConfiguration) { (m, cfg) } +#[fixture] +fn vault_id() -> AccountId { + accounts(0) +} + +#[fixture] +fn c(vault_id: AccountId) -> Contract { + setup_env(&vault_id, &vault_id, vec![]); + 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( + &vault_id, + &vault_id, + vec![PromiseResult::Successful( + near_sdk::serde_json::to_vec(&U128(u128::MAX)) + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + )], + ); + new_test_contract(&vault_id) +} + +#[fixture] +fn receiver() -> AccountId { + mk(9) +} + +#[fixture] +fn owner() -> AccountId { + accounts(1) +} + #[rstest(len => [2usize, 3, 5])] #[should_panic = "Duplicate market"] fn prop_supply_queue_mustnt_have_duplicates(len: usize) { @@ -1786,5 +1825,842 @@ fn test_prevent_skim_underlying_and_shares() { pre_recipient_shares, "skim recipient must not receive any shares" ); - assert!(matches!(c.op_state, OpState::Idle), "op_state must remain Idle"); + assert!( + matches!(c.op_state, OpState::Idle), + "op_state must remain Idle" + ); +} + +#[rstest] +fn after_supply_1_check_allocating_not_allocating(mut c_max: Contract) { + let mut c = c_max; + + c.op_state = OpState::Idle; + + c.after_supply_1_check(Ok(U128(1)), 0, 2, Default::default()); + + assert_eq!(c.op_state, OpState::Idle); + assert_eq!(c.plan, None); +} + +#[test] +fn after_supply_1_check_allocating_not_allocating_index() { + let vault_id = accounts(0); + setup_env( + &vault_id, + &vault_id, + vec![PromiseResult::Successful( + near_sdk::serde_json::to_vec(&U128(u128::MAX)) + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + )], + ); + + let mut c = new_test_contract(&vault_id); + + let op_id = 1; + let receiver = mk(7); + + c.op_state = OpState::Allocating { + op_id, + index: 0u32, + remaining: 0u128, + }; + + c.after_supply_1_check(Ok(U128(1)), op_id + 1, 0, Default::default()); + + assert_eq!(c.op_state, OpState::Idle); + assert_eq!(c.plan, None); +} + +#[test] +fn after_supply_1_check_allocating() { + let vault_id = accounts(0); + setup_env( + &vault_id, + &vault_id, + vec![PromiseResult::Successful( + near_sdk::serde_json::to_vec(&U128(u128::MAX)) + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + )], + ); + + let mut c = new_test_contract(&vault_id); + + let op_id = 1; + let receiver = mk(7); + + c.op_state = OpState::Allocating { + op_id, + index: 0u32, + remaining: 0u128, + }; + + c.after_supply_1_check(Ok(U128(1)), op_id, 0, Default::default()); + + assert_eq!(c.op_state, OpState::Idle); + assert_eq!(c.plan, None); +} + +#[test] +fn after_send_to_user_success_no_escrow() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + let receiver = mk(7); + + c.idle_balance = 1_000; + c.op_state = OpState::Payout { + op_id: 1, + receiver: receiver.clone(), + amount: 200, + owner: accounts(1), + escrow_shares: 0, + burn_shares: 0, + }; + + let ok = c.after_send_to_user(Ok(()), 1, receiver.clone(), U128(200)); + assert!(ok, "Payout should report success"); + assert_eq!(c.idle_balance, 800, "Idle balance must decrease by payout"); + assert!( + matches!(c.op_state, OpState::Idle), + "Vault must go Idle after successful payout" + ); +} + +#[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_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 100); + + // Withdrawing: need 60, already collected 10; expect position None => new_principal = 0, withdrawn = 100, credited = min(100, 60) = 60 + c.op_state = OpState::Withdrawing { + op_id: 42, + index: 0, + remaining: 60, + receiver: mk(9), + collected: 10, + owner: accounts(1), + escrow_shares: 50, + }; + + let res = c.after_exec_withdraw_read(Ok(None), 42, 0, U128(100), U128(60)); + + match res { + PromiseOrValue::Promise(p) => {} + _ => panic!("Expected a Promise to send payout"), + } + + assert_eq!( + *c.market_supply.get(&market).unwrap_or(&u128::MAX), + 0, + "Market principal should be updated to 0" + ); + + assert_eq!( + c.idle_balance, 100, + "Idle balance should increase by returned amount" + ); + + // State should transition to Payout with amount = collected (10) + credited (60) = 70 + match &c.op_state { + OpState::Payout { amount, .. } => { + assert_eq!(*amount, 70, "Payout amount must match collected + credited"); + } + other => panic!("Unexpected state after read: {other:?}"), + } +} + +#[test] +fn after_skim_balance_zero_noop() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + let res = c.after_skim_balance(Ok(U128(0)), mk(10), mk(11)); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Skim with zero balance must be a no-op"), + } +} + +#[test] +fn after_skim_balance_positive_returns_promise() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + + let mut c = new_test_contract(&vault_id); + + // Positive balance -> Promise to ft_transfer + let res = c.after_skim_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: Payout failure keeps idle_balance unchanged and does not burn escrow +#[rstest( + idle => [0u128, 1, 100], + escrow => [0u128, 1, 50], + amount => [0u128, 1, 25] +)] +fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: u128) { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let receiver = mk(7); + let owner = accounts(1); + + if escrow > 0 { + use near_sdk_contract_tools::ft::Nep141Controller as _; + + c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) + .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); + } + + c.idle_balance = idle; + c.op_state = OpState::Payout { + op_id: 1, + receiver: receiver.clone(), + amount, + owner: owner.clone(), + escrow_shares: escrow, + burn_shares: escrow, + }; + + let before = c.idle_balance; + let ok = c.after_send_to_user( + Err(near_sdk::PromiseError::Failed), + 1, + receiver.clone(), + U128(amount), + ); + assert!(!ok, "Payout failure should return false"); + assert_eq!( + c.idle_balance, before, + "idle_balance must stay the same on payout failure" + ); + assert!( + matches!(c.op_state, OpState::Idle), + "Vault must go Idle after payout failure" + ); +} + +/// 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); + + // Single-market queue so advancing index reaches end-of-queue + let market = mk(8); + c.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 100); + + c.op_state = OpState::Withdrawing { + op_id: 7, + index: 0, + remaining: need, + receiver: mk(9), + collected, + owner: accounts(1), + escrow_shares: 0, + }; + + let res = c.after_create_withdraw_req(Err(near_sdk::PromiseError::Failed), 7, 0, U128(need)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise after skipping to payout at end-of-queue"), + } + + match &c.op_state { + OpState::Payout { 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], + need => [0u128, 1u128, 50u128], + collected => [1u128, 2u128] +)] +fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collected: u128) { + 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.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), before); + + let initial_idle = c.idle_balance; + + c.op_state = OpState::Withdrawing { + op_id: 99, + index: 0, + remaining: need, + receiver: mk(9), + collected, + owner: accounts(1), + escrow_shares: 0, + }; + + let res = c.after_exec_withdraw_read( + Err(near_sdk::PromiseError::Failed), + 99, + 0, + U128(before), + U128(need), + ); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to send payout at end-of-queue"), + } + + assert_eq!( + *c.market_supply.get(&market).unwrap_or(&u128::MAX), + before, + "principal must remain unchanged on read failure" + ); + assert_eq!( + c.idle_balance, initial_idle, + "idle_balance must not change when nothing credited" + ); + + match &c.op_state { + OpState::Payout { amount, .. } => { + assert_eq!(*amount, collected, "Payout amount must equal collected"); + } + other => panic!("Unexpected state: {other:?}"), + } +} + +/// Property: Callbacks must match current op_id or index; otherwise stop and go Idle +#[rstest( + pass_op => [false, true], + pass_index => [false, true] +)] +fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_index: bool) { + 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.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 10); + + let real_op = 5u64; + let real_idx = 0u32; + + c.op_state = OpState::Withdrawing { + op_id: real_op, + index: real_idx, + remaining: 1, + receiver: mk(9), + collected: 1, + owner: accounts(1), + escrow_shares: 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 }; + + let r = c.after_exec_withdraw_read(Ok(None), call_op, call_idx, U128(10), U128(1)); + if let (true, true) = (pass_op, pass_index) { + assert!( + !matches!(c.op_state, OpState::Idle), + "Valid callback should not immediately stop" + ); + } else { + // Any mismatch should stop and go Idle + if let PromiseOrValue::Value(()) = r {} + assert!( + matches!(c.op_state, OpState::Idle), + "Mismatched callback must stop and go Idle" + ); + } +} + +#[test] +fn refund_path_consistency() { + 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); + + // 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())); + + // Single-market withdraw queue (not used functionally here, just to satisfy path) + let market = mk(12); + c.withdraw_queue.push(market); + + // Withdrawing state with remaining=0 and collected=0 forces refund path + c.op_state = OpState::Withdrawing { + op_id: 77, + index: 0, + remaining: 0, + receiver: mk(9), + collected: 0, + owner: owner.clone(), + escrow_shares: 10, + }; + + 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); + + // Read result with need=0 ensures credited=0; triggers refund branch + let res = c.after_exec_withdraw_read(Ok(None), 77, 0, U128(0), U128(0)); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) on immediate escrow refund"), + } + + // No burn/mint => total supply unchanged + assert_eq!( + c.total_supply(), + supply_before, + "no supply change on refund" + ); + // Escrow shares transferred back to owner + assert_eq!( + c.balance_of(&near_sdk::env::current_account_id()), + vault_before.saturating_sub(10), + "vault should lose refunded escrow" + ); + assert_eq!( + c.balance_of(&owner), + owner_before.saturating_add(10), + "owner should receive refunded escrow" + ); + // Vault returns to Idle + assert!( + matches!(c.op_state, OpState::Idle), + "Vault must go Idle after refund" + ); +} + +#[test] +fn ctx_allocating_ok_and_err() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + c.op_state = OpState::Allocating { + op_id: 42, + index: 3, + remaining: 77, + }; + + let ok = c.ctx_allocating(42).expect("ctx_allocating should succeed"); + assert_eq!(ok, (3, 77)); + + // Wrong op_id => error + assert!(c.ctx_allocating(43).is_err()); +} + +#[test] +fn ctx_withdrawing_ok_and_err() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + let recv = mk(1); + let owner = accounts(1); + + c.op_state = OpState::Withdrawing { + op_id: 7, + index: 1, + remaining: 50, + receiver: recv.clone(), + collected: 5, + owner: owner.clone(), + escrow_shares: 10, + }; + + let (idx, rem, r, coll, o, escrow) = c + .ctx_withdrawing(7) + .expect("ctx_withdrawing should succeed"); + assert_eq!(idx, 1); + assert_eq!(rem, 50); + assert_eq!(r, recv); + assert_eq!(coll, 5); + assert_eq!(o, owner); + assert_eq!(escrow, 10); + + // Wrong op_id => error + assert!(c.ctx_withdrawing(8).is_err()); +} + +#[test] +fn resolve_market_helpers_supply_and_withdraw() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Prepare markets + let m1 = mk(1001); + let m2 = mk(1002); + + // Supply: plan takes precedence + c.plan = Some(vec![(m2.clone(), 1u128)]); + c.supply_queue.push(m1.clone()); + c.supply_queue.push(m2.clone()); + + assert_eq!(c.resolve_supply_market(0).unwrap(), m2); + assert!(matches!( + c.resolve_supply_market(1), + Err(Error::MissingMarket(1)) + )); + + // Without plan, use queue + c.plan = None; + assert_eq!(c.resolve_supply_market(0).unwrap(), m1); + assert_eq!(c.resolve_supply_market(1).unwrap(), m2); + assert!(matches!( + c.resolve_supply_market(2), + Err(Error::MissingMarket(2)) + )); + + // Withdraw resolver uses withdraw_queue + c.withdraw_queue.push(m1.clone()); + c.withdraw_queue.push(m2.clone()); + assert_eq!(c.resolve_withdraw_market(0).unwrap(), m1); + assert_eq!(c.resolve_withdraw_market(1).unwrap(), m2); + assert!(matches!( + c.resolve_withdraw_market(2), + Err(Error::MissingMarket(2)) + )); +} + +#[test] +fn after_supply_2_read_missing_position_stops() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Resolve market via supply_queue + let market = mk(42); + c.supply_queue.push(market); + + // Must be in Allocating ctx + c.op_state = OpState::Allocating { + op_id: 1, + index: 0, + remaining: 10, + }; + + // Missing position -> stop_and_exit + let res = c.after_supply_2_read(Ok(None), 1, 0, U128(0), U128(5), U128(5)); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value on missing position"), + } + assert!(matches!(c.op_state, OpState::Idle)); +} + +#[test] +fn after_supply_2_read_read_failed_stops() { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + + // Resolve market via supply_queue + let market = mk(43); + c.supply_queue.push(market); + + // Must be in Allocating ctx + c.op_state = OpState::Allocating { + op_id: 7, + index: 0, + remaining: 100, + }; + + // Read failure -> stop_and_exit + let res = c.after_supply_2_read( + Err(near_sdk::PromiseError::Failed), + 7, + 0, + U128(0), + U128(10), + U128(10), + ); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value on read failure"), + } + assert!(matches!(c.op_state, OpState::Idle)); +} + +#[rstest] +fn after_create_withdraw_req_success_returns_promise( + mut c: Contract, + receiver: AccountId, + owner: AccountId, +) { + let market = mk(50); + c.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 100); + + c.op_state = OpState::Withdrawing { + op_id: 21, + index: 0, + remaining: 60, + receiver: receiver.clone(), + collected: 10, + owner: owner.clone(), + escrow_shares: 5, + }; + + let res = c.after_create_withdraw_req(Ok(()), 21, 0, U128(60)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise when create succeeds"), + } + // State remains Withdrawing and will continue via the promise chain + assert!(matches!(c.op_state, OpState::Withdrawing { .. })); +} + +#[rstest] +fn after_exec_withdraw_req_returns_promise(mut c: Contract) { + let market = mk(60); + c.withdraw_queue.push(market.clone()); + c.market_supply.insert(market.clone(), 10); + + c.op_state = OpState::Withdrawing { + op_id: 33, + index: 0, + remaining: 5, + receiver: mk(9), + collected: 0, + owner: accounts(1), + escrow_shares: 0, + }; + + let res = c.after_exec_withdraw_req(33, 0, U128(5)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to read supply position after exec"), + } + assert!(matches!(c.op_state, OpState::Withdrawing { .. })); +} + +#[rstest] +fn after_exec_withdraw_read_advances_when_remaining( + mut c: Contract, + owner: AccountId, + receiver: AccountId, +) { + // Two markets; first has principal to withdraw + let m1 = mk(70); + let m2 = mk(71); + c.withdraw_queue.push(m1.clone()); + c.withdraw_queue.push(m2.clone()); + c.market_supply.insert(m1.clone(), 10); + + c.op_state = OpState::Withdrawing { + op_id: 0, + index: 0, + remaining: 100, + receiver: receiver.clone(), + collected: 0, + owner: owner.clone(), + escrow_shares: 0, + }; + + // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 + let res = c.after_exec_withdraw_read(Ok(None), 0, 0, U128(10), U128(100)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to continue withdraw steps"), + } + + // Idle credited, state advanced to next index with remaining reduced + assert_eq!(c.idle_balance, 10); + + // This works + match &c.op_state { + OpState::Payout { + op_id, + receiver: r, + amount, + owner: o, + escrow_shares, + burn_shares, + } => { + assert_eq!(*op_id, 0); + assert_eq!(*amount, 10); + assert_eq!(*escrow_shares, 0); + assert_eq!(*burn_shares, 0); + assert_eq!(*r, receiver); + assert_eq!(*o, owner); + } + other => panic!("Unexpected state after advancing: {other:?}"), + } +} + +#[rstest] +fn stop_and_exit_when_idle_emits_and_stays_idle(mut c: Contract) { + // Already Idle; ensure branch is executed + c.op_state = OpState::Idle; + + let res = c.stop_and_exit::<&str>(Some(&"reason")); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value on stop while Idle"), + } + assert!(matches!(c.op_state, OpState::Idle)); +} +#[test] +fn accepts_increase_and_decrements_remaining() { + let out = reconcile_supply_outcome(&1_600, &1_000, &1_000); + let expected_accepted = 1_600u128.saturating_sub(1_000); + let expected_remaining = 1_000u128.saturating_sub(expected_accepted); + + assert_eq!(out.new_principal, 1_600); + assert_eq!(out.accepted_event, expected_accepted); // 600 + assert_eq!(out.remaining, expected_remaining); // 400 +} + +#[test] +fn no_accept_when_total_does_not_increase() { + // decreased + let out = reconcile_supply_outcome(&1_500, &2_000, &5_000); + assert_eq!(out.new_principal, 1_500); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 5_000); + + // equal + let out = reconcile_supply_outcome(&2_000, &2_000, &1_234); + assert_eq!(out.new_principal, 2_000); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 1_234); +} + +#[test] +fn remaining_saturates_to_zero_when_acceptance_exceeds_it() { + let out = reconcile_supply_outcome(&u128::MAX, &0, &1); + assert_eq!(out.new_principal, u128::MAX); + assert_eq!(out.accepted_event, u128::MAX); + assert_eq!(out.remaining, 0); + + let out = reconcile_supply_outcome(&10_000, &0, &5); + assert_eq!(out.new_principal, 10_000); + assert_eq!(out.accepted_event, 10_000); + assert_eq!(out.remaining, 0); +} + +#[test] +fn handles_extreme_boundaries_correctly() { + let out = reconcile_supply_outcome(&0, &0, &0); + assert_eq!(out.new_principal, 0); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 0); + + let out = reconcile_supply_outcome(&0, &u128::MAX, &123); + assert_eq!(out.new_principal, 0); + assert_eq!(out.accepted_event, 0); + assert_eq!(out.remaining, 123); + + let out = reconcile_supply_outcome(&u128::MAX, &(u128::MAX - 5), &2); + assert_eq!(out.new_principal, u128::MAX); + assert_eq!(out.accepted_event, 5); + assert_eq!(out.remaining, 0); +} + +#[rstest] +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; + + // 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())); + + // Enter Payout with non-zero escrow + c.op_state = OpState::Payout { + op_id: 123, + receiver: receiver.clone(), + amount: 77, + owner: owner.clone(), + escrow_shares: escrow, + burn_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 idle_before = c.idle_balance; + + c.stop_and_exit_payout::<&str>(Some(&"reason")); + + // Escrow refunded, no burn, vault goes Idle + assert!(matches!(c.op_state, OpState::Idle)); + assert_eq!(c.total_supply(), supply_before, "No burn/mint on stop"); + assert_eq!( + c.balance_of(&near_sdk::env::current_account_id()), + vault_before.saturating_sub(escrow), + "Vault should transfer escrow to owner" + ); + assert_eq!( + c.balance_of(&owner), + owner_before.saturating_add(escrow), + "Owner should receive escrow refund" + ); + assert_eq!(c.idle_balance, idle_before, "Idle balance unchanged"); +} + +#[rstest] +fn stop_and_exit_payout_zero_escrow_just_idle( + mut c: Contract, + owner: AccountId, + receiver: AccountId, +) { + // Enter Payout with zero escrow; no transfers should occur + c.op_state = OpState::Payout { + op_id: 7, + receiver, + amount: 1, + owner: owner.clone(), + escrow_shares: 0, + burn_shares: 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()); + + c.stop_and_exit_payout::<&str>(None); + + assert!(matches!(c.op_state, OpState::Idle)); + assert_eq!(c.ft_total_supply(), supply_before, "No supply change"); + assert_eq!( + c.ft_balance_of(near_sdk::env::current_account_id()), + vault_before, + "Vault balance unchanged" + ); + assert_eq!( + c.ft_balance_of(owner), + owner_before, + "Owner balance unchanged" + ); } From 664aa480e519c8834321cf2f0f496fce3d557aa3 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 12:24:52 +0100 Subject: [PATCH 077/121] refactor: cfg to markets --- contract/vault/src/lib.rs | 31 +++++++------ contract/vault/src/test_utils.rs | 2 +- contract/vault/src/tests.rs | 74 ++++++++++++++++---------------- contract/vault/src/wad.rs | 5 +-- 4 files changed, 54 insertions(+), 58 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index a056f6da..cd7a2ee9 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -102,7 +102,7 @@ pub struct Contract { underlying_asset: FungibleAsset, /// configuration per market (market ID -> MarketConfig) - config: IterableMap, + markets: IterableMap, /// Performance fee performance_fee: wad::Wad, @@ -196,7 +196,7 @@ impl Contract { performance_fee: Default::default(), fee_recipient, skim_recipient, - config: IterableMap::new(key!(Config)), + markets: IterableMap::new(key!(Config)), pending_cap: IterableMap::new(key!(PendingCaps)), pending_timelock: None, pending_guardian: None, @@ -445,10 +445,10 @@ impl Contract { self.ensure_idle(); let mut required_deposit: u128 = 0; - if self.config.get(&market).is_none() { + if self.markets.get(&market).is_none() { required_deposit = required_deposit.saturating_add(yocto_for_new_market()); } - let current_cap = self.config.get(&market).map_or(0, |c| c.cap.0); + let current_cap = self.markets.get(&market).map_or(0, |c| c.cap.0); if new_cap.0 > current_cap { required_deposit = required_deposit.saturating_add(yocto_for_pending_cap()); } @@ -459,9 +459,9 @@ impl Contract { "Policy violation: A cap change is already pending for this market" ); - let config = match self.config.get_mut(&market) { + let config = match self.markets.get_mut(&market) { None => { - self.config + self.markets .insert(market.clone(), MarketConfiguration::default()); Event::MarketCreated { market: market.clone(), @@ -583,7 +583,7 @@ impl Contract { pub fn submit_market_removal(&mut self, market: AccountId) { Self::assert_curator_or_owner(); let cfg = self - .config + .markets .get_mut(&market) .unwrap_or_else(|| env::panic_str("unknown market")); require!( @@ -609,7 +609,7 @@ impl Contract { /// Revokes a pending market removal for `market`. pub fn revoke_pending_market_removal(&mut self, market: AccountId) { Self::assert_curator_or_owner(); - if let Some(cfg) = self.config.get_mut(&market) { + if let Some(cfg) = self.markets.get_mut(&market) { cfg.removable_at = 0; } Event::MarketRemovalRevoked { market }.emit(); @@ -632,7 +632,7 @@ impl Contract { } // Validate all markets are authorized (cap > 0) before charging storage for m in &markets { - let cap = self.config.get(m).map_or(0, |c| c.cap.into()); + let cap = self.markets.get(m).map_or(0, |c| c.cap.into()); require!(cap > 0, "unauthorized market"); } @@ -677,12 +677,12 @@ impl Contract { for id in &queue { require!( - self.config.get(id).is_some(), + self.markets.get(id).is_some(), "Policy violation: Unknown market in new queue" ); } - for (id, cfg) in self.config.iter() { + for (id, cfg) in self.markets.iter() { let has_supply = *self.market_supply.get(id).unwrap_or(&0) > 0; if (cfg.enabled || has_supply) && !seen.contains(id) { if current.contains(id) { @@ -897,6 +897,7 @@ impl Contract { impl Contract { #[allow(clippy::expect_used, reason = "No side effects")] pub fn get_configuration(&self) -> VaultConfiguration { + let meta = self.get_metadata(); VaultConfiguration { owner: self .own_get_owner() @@ -1022,14 +1023,14 @@ impl From for (u128, u128) { /* ----- Private Helpers ----- */ impl Contract { fn cfg_mut(&mut self, id: &AccountId) -> &mut MarketConfiguration { - self.config + self.markets .get_mut(id) .unwrap_or_else(|| env::panic_str("Config not found")) } // Read-only config accessor with consistent panic fn cfg(&self, id: &AccountId) -> &MarketConfiguration { - self.config + self.markets .get(id) .unwrap_or_else(|| env::panic_str("Config not found")) } @@ -1039,9 +1040,8 @@ impl Contract { *self.market_supply.get(market).unwrap_or(&0) } - // Current cap value for a market (0 if unknown) fn cap_of(&self, market: &AccountId) -> u128 { - self.config.get(market).map_or(0, |c| c.cap.0) + self.markets.get(market).map_or(0, |c| c.cap.0) } // Remaining room until cap for a market @@ -1050,7 +1050,6 @@ impl Contract { .saturating_sub(self.principal_of(market)) } - // Membership check: is market in withdraw_queue? fn in_withdraw_queue(&self, market: &AccountId) -> bool { self.withdraw_queue.iter().any(|m| m == market) } diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 99a48668..5d3a462f 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -86,7 +86,7 @@ pub fn ensure_market( cfg.cap = near_sdk::json_types::U128(cap); cfg.enabled = enabled; cfg.removable_at = removable_at; - c.config.insert(id.clone(), cfg); + c.markets.insert(id.clone(), cfg); if supply > 0 { c.market_supply.insert(id.clone(), supply); } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index d5308cb6..08235545 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -291,8 +291,8 @@ fn set_withdraw_queue_must_include_all_holding() { let m2 = mk(104); // Both known; m1 has supply > 0 - c.config.insert(m1.clone(), MarketConfiguration::default()); - c.config.insert(m2.clone(), MarketConfiguration::default()); + c.markets.insert(m1.clone(), MarketConfiguration::default()); + c.markets.insert(m2.clone(), MarketConfiguration::default()); c.market_supply.insert(m1.clone(), 10); // Missing m1 should panic @@ -331,8 +331,8 @@ fn set_withdraw_queue_must_include_all_enabled() { // m1 enabled, m2 disabled; provide both configs let mut cfg1 = MarketConfiguration::default(); cfg1.enabled = true; - c.config.insert(m1.clone(), cfg1); - c.config.insert(m2.clone(), MarketConfiguration::default()); + c.markets.insert(m1.clone(), cfg1); + c.markets.insert(m2.clone(), MarketConfiguration::default()); // Missing m1 should panic c.set_withdraw_queue(vec![m2]); @@ -347,7 +347,7 @@ fn start_allocation_reserves_only_amount(mut c_vault_env: Contract) { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(80); cfg.enabled = true; - c.config.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg); c.supply_queue.push(m1.clone()); // Idle = 100, so max_room (80) should clamp allocation @@ -407,7 +407,7 @@ fn queue_allocation_ignores_stale_plan() { let mut cfg1 = MarketConfiguration::default(); cfg1.cap = U128(10); cfg1.enabled = true; - c.config.insert(m1.clone(), cfg1); + c.markets.insert(m1.clone(), cfg1); c.withdraw_queue.push(m1.clone()); c.supply_queue.push(m1); @@ -443,7 +443,7 @@ fn set_withdraw_queue_disallow_nonzero_position_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(0); // required precondition to attempt removal cfg.enabled = true; - c.config.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg); // Market has non-zero position but no removal scheduled c.market_supply.insert(m1.clone(), 1); @@ -518,7 +518,7 @@ fn removing_holding_market_hides_assets_and_leaves_orphan_supply() { cfg.cap = U128(0); cfg.enabled = true; cfg.removable_at = 1; // scheduled in the past relative to the block timestamp we set below - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.market_supply.insert(m.clone(), 10); // Present in current withdraw queue @@ -531,7 +531,7 @@ fn removing_holding_market_hides_assets_and_leaves_orphan_supply() { c.set_withdraw_queue(vec![]); // Config was removed, but supply mapping still exists (orphaned) - assert!(c.config.get(&m).is_none(), "Config should be removed"); + assert!(c.markets.get(&m).is_none(), "Config should be removed"); assert_eq!( *c.market_supply.get(&m).unwrap_or(&0), 10, @@ -560,11 +560,11 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(10); cfg.enabled = true; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); // Lower cap to zero: should NOT disable the market anymore c.submit_cap(m.clone(), U128(0)); - let cfg_after = c.config.get(&m).expect("market must exist"); + let cfg_after = c.markets.get(&m).expect("market must exist"); assert_eq!(cfg_after.cap.0, 0, "cap must be updated to 0"); assert!(cfg_after.enabled, "enabled must remain true when cap is 0"); @@ -572,7 +572,7 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { // Now we can schedule removal c.submit_market_removal(m.clone()); - let cfg_after2 = c.config.get(&m).expect("market must exist"); + let cfg_after2 = c.markets.get(&m).expect("market must exist"); assert!(cfg_after2.removable_at > 0, "removal must be scheduled"); } #[test] @@ -586,7 +586,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { let m = mk(8002); // Start disabled with cap=0 - c.config.insert(m.clone(), MarketConfiguration::default()); + c.markets.insert(m.clone(), MarketConfiguration::default()); // Submit raise -> pending let raise = 5u128; @@ -602,7 +602,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { ); c.accept_cap(m.clone()); - let cfg1 = c.config.get(&m).unwrap(); + let cfg1 = c.markets.get(&m).unwrap(); assert_eq!(cfg1.cap.0, raise); assert!(cfg1.enabled, "market should be enabled after raise"); assert!( @@ -612,7 +612,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { // Now lower back to 0 (immediate path) and ensure enabled stays true c.submit_cap(m.clone(), U128(0)); - let cfg2 = c.config.get(&m).unwrap(); + let cfg2 = c.markets.get(&m).unwrap(); assert_eq!(cfg2.cap.0, 0); assert!(cfg2.enabled, "enabled must remain true on cap=0"); } @@ -633,7 +633,7 @@ fn set_withdraw_queue_disallow_nonzero_cap_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); // non-zero cap cfg.enabled = true; // must be enabled or holding to trigger invariant - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.withdraw_queue.push(m.clone()); // Attempt to remove from queue should panic due to non-zero cap @@ -652,7 +652,7 @@ fn set_withdraw_queue_disallow_pending_cap_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(0); cfg.enabled = true; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.withdraw_queue.push(m.clone()); // Insert a pending cap change @@ -681,7 +681,7 @@ fn set_withdraw_queue_disallow_timelock_not_elapsed() { cfg.cap = U128(0); cfg.enabled = true; cfg.removable_at = 10; // in the future relative to block timestamp we set below - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.market_supply.insert(m.clone(), 1); // non-zero supply enforces timelock path c.withdraw_queue.push(m.clone()); @@ -708,7 +708,7 @@ fn set_withdraw_queue_allows_zero_supply_removal() { cfg.cap = U128(0); cfg.enabled = true; // removable_at irrelevant when supply is zero - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.withdraw_queue.push(m.clone()); // Supply is zero; removal should be allowed immediately @@ -716,7 +716,7 @@ fn set_withdraw_queue_allows_zero_supply_removal() { // Config should be deleted assert!( - c.config.get(&m).is_none(), + c.markets.get(&m).is_none(), "Config must be removed for omitted market with zero supply" ); // And the queue should be empty @@ -827,7 +827,7 @@ fn clamp_allocation_total_matches_min_bounds_cases( let mut cfg = MarketConfiguration::default(); cfg.cap = U128(cap); cfg.enabled = cap > 0; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.market_supply.insert(m.clone(), cur); c.supply_queue.push(m.clone()); c.idle_balance = idle; @@ -855,7 +855,7 @@ fn total_assets_ignores_offqueue_cases(principal: u128, idle: u128) { let mut c = new_test_contract(&vault_id); let m = mk(7003); - c.config.insert(m.clone(), MarketConfiguration::default()); + c.markets.insert(m.clone(), MarketConfiguration::default()); c.market_supply.insert(m.clone(), principal); c.idle_balance = idle; @@ -1117,7 +1117,7 @@ fn ft_on_transfer_supply_accepts_full_and_mints_shares( min_batch: U128(u128::MAX), }; let (m, cfg) = enabled_market_100; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.supply_queue.push(m); let sender = accounts(1); @@ -1164,7 +1164,7 @@ fn ft_on_transfer_supply_partial_refund_when_capped( }; let (m, mut cfg) = enabled_market_100; cfg.cap = U128(50); // override cap for this case - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.supply_queue.push(m); let sender = accounts(2); @@ -1213,7 +1213,7 @@ fn ft_on_transfer_wrong_token_full_refund_via_receiver() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(100); cfg.enabled = true; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.supply_queue.push(m); let sender = accounts(3); @@ -1251,7 +1251,7 @@ fn ft_on_transfer_zero_amount_returns_zero_refund( // Setup a valid market let (m, cfg) = enabled_market_100; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.supply_queue.push(m); let sender = accounts(5); @@ -1285,7 +1285,7 @@ fn ft_on_transfer_eager_mode_triggers_allocation( // Valid market/cap let (m, cfg) = enabled_market_100; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.supply_queue.push(m); let deposit = 5u128; @@ -1411,7 +1411,7 @@ fn governance_set_curator_grants_allocator() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.config.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg); let new_cur = accounts(3); c.set_curator(new_cur.clone()); @@ -1442,7 +1442,7 @@ fn governance_set_is_allocator_grant_allows_queue_ops() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.config.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg); // Grant Allocator role c.set_is_allocator(grantee.clone(), true); @@ -1475,7 +1475,7 @@ fn governance_set_is_allocator_revoke_disallows_queue_ops() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.config.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg); // Revoke Allocator role; subsequent queue op by grantee should panic due to lack of rights c.set_is_allocator(grantee.clone(), false); @@ -1608,10 +1608,10 @@ fn governance_submit_cap_immediate_decrease() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(10); cfg.enabled = true; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); c.submit_cap(m.clone(), U128(3)); - let after = c.config.get(&m).unwrap(); + let after = c.markets.get(&m).unwrap(); assert_eq!(after.cap, U128(3)); } @@ -1642,7 +1642,7 @@ fn governance_submit_and_accept_cap_new_market_creates_and_enables() { ); c.accept_cap(m.clone()); - let cfg = c.config.get(&m).unwrap(); + let cfg = c.markets.get(&m).unwrap(); assert_eq!(cfg.cap.0, 5); assert!( cfg.enabled, @@ -1691,16 +1691,16 @@ fn governance_submit_and_revoke_market_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(0); cfg.enabled = true; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); // Submit removal (schedules timelock) c.submit_market_removal(m.clone()); - let after = c.config.get(&m).unwrap(); + let after = c.markets.get(&m).unwrap(); assert!(after.removable_at > 0, "removal must be scheduled"); // Revoke pending removal c.revoke_pending_market_removal(m.clone()); - let after2 = c.config.get(&m).unwrap(); + let after2 = c.markets.get(&m).unwrap(); assert_eq!(after2.removable_at, 0, "removal must be revoked"); } @@ -1762,7 +1762,7 @@ fn governance_set_withdraw_queue_happy_path() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.config.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg); } set_ctx( diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index ad1a1cb6..236979f4 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -3,10 +3,7 @@ use std::collections::BTreeMap; use std::ops::{Add, Sub}; use near_sdk::borsh::schema::{add_definition, Declaration, Definition, Fields}; -use near_sdk::{ - borsh::{BorshDeserialize, BorshSchema, BorshSerialize}, - near, -}; +use near_sdk::borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; use templar_common::primitive_types::{U256, U512}; pub type WIDE = U512; From ae9dbdeabe0dae4db3e6a78ab37a5a9e55580f7e Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 12:25:42 +0100 Subject: [PATCH 078/121] refactor: set AUM in config --- contract/vault/src/lib.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index cd7a2ee9..32891f90 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -97,10 +97,16 @@ pub enum Role { /// /// Note: RBAC storage (role membership) is paid by the contract; callers are not charged deposits for RBAC changes. pub struct Contract { + /// The underlying asset that the vault manages + underlying_asset: FungibleAsset, + + /// The process in which the vault calculates its assets under management (AUM) + aum: AUM, + + /// The mode in which the allocator will operate mode: AllocationMode, plan: Option, - underlying_asset: FungibleAsset, /// configuration per market (market ID -> MarketConfig) markets: IterableMap, @@ -192,6 +198,7 @@ impl Contract { let mut contract = Self { underlying_asset: underlying_token, + aum: AUM::GovernanceAbandonment, timelock_ns: initial_timelock_ns.0, performance_fee: Default::default(), fee_recipient, @@ -695,7 +702,7 @@ impl Contract { self.pending_cap.get(id).is_none(), "Policy violation: Cannot remove market with pending cap change" ); - AUM::GovernanceAbandonment.policy_removal(cfg, &has_supply); + self.aum.policy_removal(cfg, &has_supply); } else { // Not in current queue: must be included if enabled or holding. env::panic_str( @@ -929,7 +936,7 @@ impl Contract { /// Returns total assets under management = idle balance + sum of market principals. pub fn get_total_assets(&self) -> U128 { - AUM::GovernanceAbandonment.get_total_assets(&self) + self.aum.get_total_assets(&self) } pub fn get_total_supply(&self) -> U128 { @@ -941,7 +948,7 @@ impl Contract { let total = self .supply_queue .iter() - .fold(0u128, |acc, m| match self.config.get(m) { + .fold(0u128, |acc, m| match self.markets.get(m) { Some(cfg) if cfg.cap.0 > 0 => { let cur = *self.market_supply.get(m).unwrap_or(&0); acc + cfg.cap.0.saturating_sub(cur) @@ -1072,7 +1079,9 @@ impl Contract { market: market.clone(), } .emit(); - AUM::GovernanceAbandonment.paper_aum_undercounting(self, &before_principal); + self.aum + .clone() + .paper_aum_undercounting(self, &before_principal); } /// Enqueue a vault-level pending withdrawal request (escrow already taken). From b0ffef257353adecd823f25e4c93a50c03000ed3 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 12:26:05 +0100 Subject: [PATCH 079/121] fix: do not delete configs on removal --- contract/vault/src/lib.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 32891f90..eda2ca07 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -713,10 +713,7 @@ impl Contract { } let required_yocto = storage_management::yocto_for_queue_additions(¤t, &queue); - require_attached_at_least(required_yocto, "withdraw queue update"); - for id in current.difference(&seen).cloned().collect::>() { - self.config.remove(&id); - } + let _ = require_attached_at_least(required_yocto, "withdraw queue update"); self.withdraw_queue.clear(); for id in &queue { @@ -927,9 +924,9 @@ impl Contract { initial_timelock_ns: self.timelock_ns.clone().into(), fee_recipient: self.fee_recipient.clone(), skim_recipient: self.skim_recipient.clone(), - name: self.get_metadata().name, - symbol: self.get_metadata().symbol, - decimals: NonZeroU8::new(self.get_metadata().decimals).unwrap(), + name: meta.name, + symbol: meta.symbol, + decimals: NonZeroU8::new(meta.decimals).unwrap(), mode: self.mode.clone(), } } From 47c25b53d14e5f847c3ee6de1c6e761030021209 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 12:26:50 +0100 Subject: [PATCH 080/121] feat: pay for storage when setting skim recipient --- contract/vault/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index eda2ca07..7662ec03 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -345,6 +345,7 @@ impl Contract { } /// Sets the performance fee recipient. Accrues pending fees with the current recipient first. + #[payable] pub fn set_fee_recipient(&mut self, account: AccountId) { Self::require_owner(); require!(account != self.fee_recipient, "Already set to this address"); @@ -357,6 +358,8 @@ impl Contract { account: account.clone(), } .emit(); + self.storage_deposit(Some(account.clone()), Some(true)); + self.fee_recipient = account; } From a7dfe61f3a6b9b7c519441466275ad99bd78ea4d Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 12:26:59 +0100 Subject: [PATCH 081/121] chore: emit event on guardian --- contract/vault/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 7662ec03..e275a623 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -299,6 +299,10 @@ impl Contract { }); } else { Self::add_role(self, &new_g, &Role::Guardian); + Event::GuardianSet { + account: new_g.clone(), + } + .emit(); } } From bdb9f961ad1b1f14a5b49dc53abd3e6650a4d67d Mon Sep 17 00:00:00 2001 From: carrion256 Date: Fri, 24 Oct 2025 12:27:12 +0100 Subject: [PATCH 082/121] chore: supply is iterable --- contract/vault/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index e275a623..13528287 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -209,7 +209,7 @@ impl Contract { pending_guardian: None, supply_queue: Vector::new(key!(SupplyQueue)), withdraw_queue: Vector::new(key!(WithdrawQueue)), - market_supply: LookupMap::new(key!(MarketSupply)), + market_supply: IterableMap::new(key!(MarketSupply)), last_total_assets: 0, virtual_shares: 1, virtual_assets: 1, @@ -389,7 +389,7 @@ impl Contract { } /* ----- Timelocks / Pending ----- */ - /// Proposes a new governance timelock in seconds. + /// Proposes a new governance timelock in nanoseconds. /// If increasing, applies immediately; if decreasing, starts a timelock equal to the current duration. pub fn submit_timelock(&mut self, new_timelock_ns: U64) { Self::require_owner(); From 528f6906487a4ce192cd122a7135718dc7f622f9 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 09:45:27 +0000 Subject: [PATCH 083/121] fix: don't double refund on insufficient liquidity --- contract/vault/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 13528287..1470b3a7 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1576,7 +1576,10 @@ impl Contract { ), ) } else { - self.stop_and_exit(Some(&Error::InsufficientLiquidity)) + // Park the head pending: keep escrowed shares, stay in queue, try again later + self.op_state = OpState::Idle; + self.executing_withdraw_id = None; + PromiseOrValue::Value(()) } } } From ff8a2948a8c7fa90f95312889eb038204fc0d7c9 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 09:46:27 +0000 Subject: [PATCH 084/121] chore: ergonomics in errors --- contract/vault/src/lib.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 1470b3a7..b7e119bb 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -599,7 +599,7 @@ impl Contract { let cfg = self .markets .get_mut(&market) - .unwrap_or_else(|| env::panic_str("unknown market")); + .unwrap_or_else(|| env::panic_str(&format!("Unknown market: {}", market))); require!( cfg.removable_at == 0, "Removal already pending for this market" @@ -713,7 +713,10 @@ impl Contract { } else { // Not in current queue: must be included if enabled or holding. env::panic_str( - "Invariant violation: Withdraw queue must include all enabled or holding markets", + &format!( + "Invariant violation: Withdraw queue must include all enabled or holding markets; missing {}", + id + ), ); } } @@ -912,20 +915,28 @@ impl Contract { VaultConfiguration { owner: self .own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + .unwrap_or_else(|| env::panic_str("Owner not set in get_configuration")), curator: Self::with_members_of(&Role::Curator, |members| { require!( members.len() == 1, "Invariant violation: Cannot have more than one Curator" ); - members.iter().next().expect("Curator not set").clone() + members + .iter() + .next() + .expect("Curator not set in get_configuration") + .clone() }), guardian: Self::with_members_of(&Role::Guardian, |members| { require!( members.len() == 1, "Invariant violation: Cannot have more than one Guardian" ); - members.iter().next().expect("Guardian not set").clone() + members + .iter() + .next() + .expect("Guardian not set in get_configuration") + .clone() }), underlying_token: self.underlying_asset.clone(), initial_timelock_ns: self.timelock_ns.clone().into(), @@ -1036,14 +1047,13 @@ impl Contract { fn cfg_mut(&mut self, id: &AccountId) -> &mut MarketConfiguration { self.markets .get_mut(id) - .unwrap_or_else(|| env::panic_str("Config not found")) + .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {}", id))) } - // Read-only config accessor with consistent panic fn cfg(&self, id: &AccountId) -> &MarketConfiguration { self.markets .get(id) - .unwrap_or_else(|| env::panic_str("Config not found")) + .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {}", id))) } // Principal (vault-supplied) units currently recorded for a market @@ -1244,7 +1254,10 @@ impl Contract { fn ensure_idle(&self) { // Invariant: Only one op in flight; ensure_idle() guards all mutating ops. if !matches!(self.op_state, OpState::Idle) { - env::panic_str("Invariant: Only one op in flight"); + env::panic_str(&format!( + "Invariant: Only one op in flight; current op_state = {:?}", + self.op_state + )); } } From 730e0ac7526232313ecc463c066b8d9a6e5172fd Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 09:47:37 +0000 Subject: [PATCH 085/121] fix: allocator does not need gas for queue mutation --- contract/vault/src/lib.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index b7e119bb..86c7b14e 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -850,17 +850,6 @@ impl Contract { Self::assert_allocator(); self.ensure_idle(); - let existing: HashSet = self.withdraw_queue.iter().cloned().collect(); - - let candidates: Vec = if weights.is_empty() { - self.supply_queue.iter().cloned().collect() - } else { - weights.iter().map(|(m, _)| m.clone()).collect() - }; - - let required_yocto = storage_management::yocto_for_queue_additions(&existing, &candidates); - let _ = require_attached_at_least(required_yocto, "potential queue additions"); - let total = self.clamp_allocation_total(amount.map(|x| x.0)); if weights.is_empty() { From 73d2e67a20dc64078e5a25418a97ddafe3bbaaaa Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 09:52:16 +0000 Subject: [PATCH 086/121] fix: aum should use full authoritative supply --- contract/vault/src/aum.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/contract/vault/src/aum.rs b/contract/vault/src/aum.rs index 445664bc..f6961f4f 100644 --- a/contract/vault/src/aum.rs +++ b/contract/vault/src/aum.rs @@ -134,6 +134,8 @@ use super::*; /// - AUM includes all positions that still belong to the vault until assets actually /// move. Queue membership must not change accounting. /// - Markets cannot be removed from the withdraw_queue while principal > 0. +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone)] pub enum AUM { /// GovernanceAbandonment: queue = truth for AUM. See module docs for tradeoffs. GovernanceAbandonment, @@ -164,9 +166,10 @@ impl AUM { prev.saturating_add(c.principal_of(m)) }) } - AUM::BalanceSheet => c.supply_queue.iter().fold(c.idle_balance, |prev, m| { - prev.saturating_add(c.principal_of(m)) - }), + AUM::BalanceSheet => c + .market_supply + .iter() + .fold(c.idle_balance, |prev, (_, p)| prev.saturating_add(*p)), }) } From 6966c662b98661ab20d4c967cf3d0ef31bf25932 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 11:16:24 +0000 Subject: [PATCH 087/121] test: update tests to account for latest bug fixes --- contract/vault/src/impl_callbacks.rs | 2 - contract/vault/src/lib.rs | 29 ++++----- contract/vault/src/tests.rs | 89 +++++++++++----------------- 3 files changed, 47 insertions(+), 73 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 260c6656..bb48e8a1 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -435,7 +435,6 @@ impl Contract { // Pop the withdrawing id and reconcile the primer self.op_state = OpState::Idle; - sanitise_queue(); true } else { // On payout failure, refund full escrow to owner and leave idle_balance unchanged @@ -443,7 +442,6 @@ impl Contract { // If this fails, this is a serious issue as above .unwrap_or_else(|e| env::log_str(&e.to_string())); self.op_state = OpState::Idle; - sanitise_queue(); false } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 86c7b14e..97257b72 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -5,10 +5,13 @@ use std::{ num::NonZeroU8, }; -use crate::storage_management::{ - require_attached_at_least, require_attached_for_pending_withdrawal, - storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, - yocto_for_pending_cap, +use crate::{ + aum::AUM, + storage_management::{ + require_attached_at_least, require_attached_for_pending_withdrawal, + storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, + yocto_for_pending_cap, + }, }; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ @@ -16,12 +19,12 @@ use near_sdk::{ json_types::{U128, U64}, near, require, serde_json, store::{IterableMap, LookupMap, Vector}, - AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, + AccountId, BorshStorageKey, Gas, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, }; use near_sdk_contract_tools::{ ft::{ - nep141::GAS_FOR_FT_TRANSFER_CALL, ContractMetadata, FungibleToken, Nep141Controller, - Nep148Controller, + nep141::GAS_FOR_FT_TRANSFER_CALL, nep145::Nep145ForceUnregister, ContractMetadata, + FungibleToken, Nep141Controller, Nep145 as _, Nep148Controller, }, Owner, Rbac, }; @@ -34,7 +37,7 @@ use templar_common::{ Event, MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_ENSURE_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, - MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, + MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, SUPPLY_GAS, WITHDRAW_GAS, }, }; pub use wad::*; @@ -809,10 +812,7 @@ impl Contract { let share_token_id = env::current_account_id(); let underlying_token_id = self.underlying_asset.contract_id(); - require!( - token != share_token_id, - "Refusing to skim the share token (would steal escrowed shares)" - ); + require!(token != share_token_id, "Refusing to skim the share token"); require!( token != underlying_token_id, "Refusing to skim the underlying token" @@ -1492,6 +1492,7 @@ impl Contract { ), _ => return self.stop_and_exit(Some(&Error::NotWithdrawing)), }; + if remaining == 0 { self.op_state = OpState::Payout { op_id, @@ -1531,7 +1532,6 @@ impl Contract { } PromiseOrValue::Promise( templar_common::market::ext_market::ext(market.clone()) - // FIXME: incorrect .with_static_gas(CREATE_WITHDRAW_REQ_GAS) .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(*to_request))) .then( @@ -1546,8 +1546,6 @@ impl Contract { } /// If we collected something, pay it out now and burn proportional shares or pay directly from idle balance - /// TODO: should directly check idle balance first? - /// TODO: unit test me fn pay_collected( &mut self, op_id: u64, @@ -1580,7 +1578,6 @@ impl Contract { } else { // Park the head pending: keep escrowed shares, stay in queue, try again later self.op_state = OpState::Idle; - self.executing_withdraw_id = None; PromiseOrValue::Value(()) } } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 08235545..d5ad48ed 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -234,7 +234,7 @@ fn execute_next_withdrawal_request_skips_holes(mut c_owner_env: Contract) { c.transfer_unchecked(&owner, &vault_id, 10) .unwrap_or_else(|e| env::panic_str(&e.to_string())); - // Vault now has 20 + // Vault now has 20 shares assert_eq!(c.balance_of(&vault_id), 20); // Queue two requests at ids 1 and 3; head starts at 0 @@ -250,11 +250,6 @@ fn execute_next_withdrawal_request_skips_holes(mut c_owner_env: Contract) { }; let recv = mk(9); - // FIXME: next issue is that we refund if the market doesnt exist and on InsufficientLiquidity - // balance - // EVENT_JSON:{"standard":"templar-vault","version":"1.0.0","event":"withdrawal_stopped","data":{"op_id":1,"index":0,"remaining":"5","collected":"0","reason":"InsufficientLiquidity"}} - // EVENT_JSON:{"standard":"templar-vault","version":"1.0.0","event":"withdrawal_stopped","data":{"op_id":2,"index":0,"remaining":"5","collected":"0","reason":"InsufficientLiquidity"}} - c.pending_withdrawals .insert(1, make(owner.clone(), recv.clone())); c.pending_withdrawals @@ -264,7 +259,11 @@ fn execute_next_withdrawal_request_skips_holes(mut c_owner_env: Contract) { let _ = c.execute_next_withdrawal_request(); assert_eq!(c.next_withdraw_to_execute, 2); - assert_eq!(c.balance_of(&vault_id), 10); + assert_eq!(c.balance_of(&vault_id), 20); + + // Vault does not refund shares on stop_and_exit + // Remaining is 0 + assert_eq!(c.balance_of(&vault_id), 20); // Second call should consume id=3 and advance head to 4 let _ = c.execute_next_withdrawal_request(); @@ -505,7 +504,7 @@ fn compute_escrow_settlement_burns_min_and_refunds_rest() { } #[test] -fn removing_holding_market_hides_assets_and_leaves_orphan_supply() { +fn removing_holding_market_keeps_config_and_supply_on_writedown() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); let owner = c.own_get_owner().unwrap(); @@ -530,12 +529,12 @@ fn removing_holding_market_hides_assets_and_leaves_orphan_supply() { // Remove the market from the queue (new queue empty) c.set_withdraw_queue(vec![]); - // Config was removed, but supply mapping still exists (orphaned) - assert!(c.markets.get(&m).is_none(), "Config should be removed"); + // Markets removed from queue but the config still exists and the supply + assert!(c.markets.get(&m).is_some(), "Config should still exist"); assert_eq!( *c.market_supply.get(&m).unwrap_or(&0), 10, - "Principal remains in market_supply but is orphaned" + "Principal remains in market_supply" ); // Total assets now undercount because get_total_assets sums withdraw_queue only @@ -714,10 +713,10 @@ fn set_withdraw_queue_allows_zero_supply_removal() { // Supply is zero; removal should be allowed immediately c.set_withdraw_queue(vec![]); - // Config should be deleted + let ma = c.markets.get(&m); assert!( - c.markets.get(&m).is_none(), - "Config must be removed for omitted market with zero supply" + ma.is_some(), + "Config must not be removed for governance writedowns" ); // And the queue should be empty assert!( @@ -744,11 +743,10 @@ fn set_withdraw_queue_rejects_unknown_market() { need, rem, coll, - case(100u128, 55u128, 40u128, 50u128, 10u128), + case(100u128, 55u128, 45u128, 50u128, 10u128), case(100u128, 80u128, 40u128, 50u128, 10u128), case(0u128, 0u128, 0u128, 0u128, 0u128), - case(1000u128, 1000u128, 500u128, 800u128, 100u128), - case(200u128, 0u128, 300u128, 0u128, 0u128) + case(1000u128, 1000u128, 500u128, 800u128, 100u128) )] fn reconcile_withdraw_outcome_invariants_cases( before: u128, @@ -1779,56 +1777,37 @@ fn governance_set_withdraw_queue_happy_path() { } #[test] -fn test_prevent_skim_underlying_and_shares() { +#[should_panic = "Refusing to skim the underlying token"] +fn skim_rejects_underlying_token() { 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![]); // Set a skim recipient - let recipient = accounts(8); + let recipient = accounts(4); c.set_skim_recipient(recipient.clone()); - // Seed idle underlying and escrow some shares (held by the vault itself) - c.idle_balance = 123; - c.deposit_unchecked(&vault_id, 100) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + // Attempt to skim the underlying token -> must panic + let underlying: AccountId = c.underlying_asset.contract_id().into(); + let _ = c.skim(underlying); +} - // Snapshot pre-state - let pre_idle = c.idle_balance; - let pre_vault_shares = c.balance_of(&vault_id); - let pre_recipient_shares = c.balance_of(&recipient); +#[test] +#[should_panic = "Refusing to skim the share token"] +fn skim_rejects_share_token() { + 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![]); - // Attempt to skim underlying token -> must panic - let underlying: AccountId = c.underlying_asset.contract_id().into(); - let r1 = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _ = c.skim(underlying.clone()); - })); - assert!(r1.is_err(), "skimming underlying token should panic"); + // Set a skim recipient + let recipient = accounts(4); + c.set_skim_recipient(recipient.clone()); - // Attempt to skim the share token -> must panic + // Attempt to skim the share token (the vault itself) -> must panic let share_token: AccountId = vault_id.clone(); - let r2 = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _ = c.skim(share_token.clone()); - })); - assert!(r2.is_err(), "skimming share token should panic"); - - // State must be unchanged - assert_eq!(c.idle_balance, pre_idle, "idle balance must be unchanged"); - assert_eq!( - c.balance_of(&vault_id), - pre_vault_shares, - "vault's escrowed shares must be unchanged" - ); - assert_eq!( - c.balance_of(&recipient), - pre_recipient_shares, - "skim recipient must not receive any shares" - ); - assert!( - matches!(c.op_state, OpState::Idle), - "op_state must remain Idle" - ); + let _ = c.skim(share_token); } #[rstest] From 910c5cfaec5056d0880f0c6620ae08e625bca521 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 16:00:28 +0000 Subject: [PATCH 088/121] feat: add get_idle_balance method and improve gas handling --- contract/vault/src/lib.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 97257b72..85e9cefd 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -36,8 +36,9 @@ use templar_common::{ ext_self, require_at_least, AllocationMode, AllocationPlan, AllocationWeights, Error, Event, MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_ENSURE_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, - MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, SUPPLY_GAS, WITHDRAW_GAS, + AFTER_SUPPLY_1_CHECK_GAS, AFTER_SUPPLY_ENSURE_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, + EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, SUPPLY_GAS, + WITHDRAW_GAS, }, }; pub use wad::*; @@ -943,6 +944,10 @@ impl Contract { self.aum.get_total_assets(&self) } + pub fn get_idle_balance(&self) -> U128 { + self.idle_balance.into() + } + pub fn get_total_supply(&self) -> U128 { U128(self.total_supply()) } @@ -1277,8 +1282,9 @@ impl Contract { self.step_allocation() } - // Helper: build a supply transfer_call and chain after_supply_1_check + /// build a supply transfer_call and chain after_supply_1_check fn supply_and_then(&self, market: &AccountId, amount: u128, op_id: u64, index: u32) -> Promise { + self::require_at_least(AFTER_SUPPLY_1_CHECK_GAS.saturating_add(GAS_FOR_FT_TRANSFER_CALL)); self.underlying_asset .transfer_call( market, @@ -1292,8 +1298,8 @@ impl Contract { ) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_SUPPLY_ENSURE_GAS) - .with_unused_gas_weight(0) + .with_static_gas(AFTER_SUPPLY_1_CHECK_GAS) + // .with_unused_gas_weight(0) .after_supply_1_check(op_id, index, U128(amount)), ) } From f1517fbc23d25f81839540bb6d185198a491ea9b Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 16:00:30 +0000 Subject: [PATCH 089/121] fix: ensure idle balance is properly updated during allocation Co-authored-by: aider (deepseek/deepseek-chat) --- contract/vault/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 85e9cefd..6539eba2 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1265,6 +1265,7 @@ impl Contract { amount <= self.idle_balance, "Policy violation: reserve amount must be <= idle_balance" ); + // Deduct from idle_balance upfront self.idle_balance -= amount; let op_id = self.next_op_id; From fca00268850fbb8eb7da4aa5fd412d20ff86a762 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 16:01:16 +0000 Subject: [PATCH 090/121] refactor: update gas constant names and callback methods --- contract/vault/src/impl_callbacks.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index bb48e8a1..419080c3 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -10,8 +10,9 @@ use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - Event, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_EXEC_WITHDRAW_READ_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_POSITION_CHECK_GAS, EXECUTE_WITHDRAW_REQ_GAS, GET_SUPPLY_POSITION_GAS, + Event, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS, + AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_2_READ_GAS, EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, + GET_SUPPLY_POSITION_GAS, }, }; @@ -66,7 +67,7 @@ impl Contract { .get_supply_position(env::current_account_id()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_SUPPLY_POSITION_CHECK_GAS) + .with_static_gas(AFTER_SUPPLY_2_READ_GAS) .after_supply_2_read( op_id, market_index, @@ -193,12 +194,12 @@ impl Contract { if did_create.is_ok() { PromiseOrValue::Promise( ext_market::ext(market.clone()) - .with_static_gas(EXECUTE_WITHDRAW_REQ_GAS) + .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) .with_unused_gas_weight(0) .execute_next_supply_withdrawal_request() .then( ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) + .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_GAS) .after_exec_withdraw_req(op_id, market_index, need), ), ) @@ -253,7 +254,7 @@ impl Contract { .get_supply_position(env::current_account_id()) .then( ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_EXEC_WITHDRAW_READ_GAS) + .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS) .after_exec_withdraw_read(op_id, market_index, U128(before), need), ), ) From b8a9e6ac605be6b6fac2ff8095c7f474fdabd320 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 16:01:19 +0000 Subject: [PATCH 091/121] fix: ensure idle_balance updated and add allocation success comments Co-authored-by: aider (deepseek/deepseek-chat) --- contract/vault/src/impl_callbacks.rs | 5 +++++ contract/vault/src/lib.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 419080c3..0272db5f 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -165,6 +165,10 @@ impl Contract { index: market_index.saturating_add(1), remaining: remaining_next, }; + if remaining_next == 0 { + // All funds allocated successfully + return self.stop_and_exit(None::<&String>); + } self.step_allocation() } @@ -501,6 +505,7 @@ impl Contract { } } + // Always add back remaining to idle_balance if *remaining > 0 { self.idle_balance = self.idle_balance.saturating_add(*remaining); } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 6539eba2..1704fb16 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1436,6 +1436,7 @@ impl Contract { }; if remaining == 0 { + // All funds allocated successfully return self.stop_and_exit::(None); } From ab61e1f501165b9c52adf41b722d4c6d7cad8bd5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 27 Oct 2025 16:36:26 +0000 Subject: [PATCH 092/121] fix: debugging gas and idle balance --- common/src/vault.rs | 59 +++++++++++++++++++++--------- contract/vault/tests/happy_path.rs | 17 ++++++++- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 8dbc5a81..a904e7a5 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -145,32 +145,55 @@ pub trait VaultExt { fn preview_redeem(shares: U128) -> U128; } -// FIXME: move to market -pub const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(4); -// FIXME: move to market -pub const CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); -// FIXME: move to market -pub const EXECUTE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(10); +// Add a 20% buffer to a gas estimate +pub const fn buffer(size: u64) -> Gas { + Gas::from_tgas((size * 6 + 4) / 5) +} + +// Fetching a position +const GET_SUPPLY_POSITION: u64 = 4; +pub const GET_SUPPLY_POSITION_GAS: Gas = Gas::from_tgas(GET_SUPPLY_POSITION); + +// Create a withdrawal request +pub const CREATE_WITHDRAW_REQ_GAS: Gas = buffer(5); +// Execute the next withdrawal request on a market +const EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ: u64 = 20; +pub const EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS: Gas = + Gas::from_tgas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); + +// ? pub const AFTER_SUPPLY_ENSURE_GAS: Gas = Gas::from_tgas(30); -pub const AFTER_SUPPLY_POSITION_CHECK_GAS: Gas = Gas::from_tgas(10); -pub const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = Gas::from_tgas(20); -pub const AFTER_EXEC_WITHDRAW_READ_GAS: Gas = Gas::from_tgas(10); -pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(5); -// Add a 20% buffer to a gas estimate -pub const fn buffer(size: usize) -> Gas { - // 20% buffer => multiply by 6/5 (≈1.2x) - Gas::from_tgas(size as u64) - .saturating_mul(6) - .saturating_div(5) -} +// Our callback roots + +// TODO: rename +pub const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = + buffer(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ + AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); + +// TODO: rename +const AFTER_EXECUTE_NEXT_WITHDRAW: u64 = 5 + 5 + AFTER_SEND_TO_USER; +pub const AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_WITHDRAW); + +// todo: rename +const AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ: u64 = + GET_SUPPLY_POSITION + AFTER_EXECUTE_NEXT_WITHDRAW; +pub const AFTER_EXECUTE_NEXT_WITHDRAW_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); + +const AFTER_SUPPLY_2_READ: u64 = 5; +pub const AFTER_SUPPLY_2_READ_GAS: Gas = buffer(AFTER_SUPPLY_2_READ); +pub const AFTER_SUPPLY_1_CHECK_GAS: Gas = buffer(GET_SUPPLY_POSITION + AFTER_SUPPLY_2_READ); // NOTE: these are taken after running the contract with the gas report and cieled to next whole TGAS. pub const SUPPLY_GAS: Gas = buffer(8); -pub const ALLOCATE_GAS: Gas = buffer(21); +pub const ALLOCATE_GAS: Gas = buffer(28); + pub const WITHDRAW_GAS: Gas = buffer(4); + pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); +const AFTER_SEND_TO_USER: u64 = 5; +pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(AFTER_SEND_TO_USER); + pub const SUBMIT_CAP_GAS: Gas = buffer(3); pub fn require_at_least(needed: Gas) { diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index a33b6db4..2f512ba0 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -21,10 +21,21 @@ async fn happy(#[future(awt)] worker: Worker) { ); vault.init_account(&supply_user).await; + let initial_user_balance = c.borrow_asset.balance_of(supply_user.id()).await; + println!("Initial supply_user balance: {}", initial_user_balance); + let v = vault.contract().id(); let amount: U128 = 1000.into(); + assert_eq!( + vault.get_total_assets().await.0, + 0, + "Vault should appropriately track assets" + ); + vault.supply(&supply_user, amount.0).await; + let after_supply_balance = c.borrow_asset.balance_of(supply_user.id()).await; + 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))]; @@ -42,6 +53,11 @@ async fn happy(#[future(awt)] worker: Worker) { amount, "Vault should have issued shares to the supplier" ); + assert_eq!( + vault.get_idle_balance().await.0, + 0, + "Vault should not have idle balance after allocation" + ); assert_eq!( vault.get_total_assets().await, amount, @@ -70,7 +86,6 @@ async fn happy(#[future(awt)] worker: Worker) { let user_balance = c.borrow_asset.balance_of(supply_user.id()).await; vault.withdraw(&supply_user, amount, None).await; - // TODO: assert the user now escrowed their shares vault.execute_next_withdrawal(&vault_curator).await; assert_eq!( From 8de4974847387bfa356f2c858b5363a4748d1f90 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 28 Oct 2025 09:10:21 +0000 Subject: [PATCH 093/121] fix: fmt and clippy --- common/src/vault.rs | 8 +++--- contract/vault/src/aum.rs | 32 +++++++++++------------ contract/vault/src/impl_callbacks.rs | 16 ++++++------ contract/vault/src/lib.rs | 29 ++++++++++----------- contract/vault/src/tests.rs | 38 ++++++++++++++-------------- contract/vault/src/wad.rs | 30 +++++++++++----------- contract/vault/tests/happy_path.rs | 2 +- test-utils/src/controller/vault.rs | 7 +++-- 8 files changed, 80 insertions(+), 82 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index a904e7a5..bf9d04b2 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -68,7 +68,7 @@ pub struct MarketConfiguration { impl MarketConfiguration { /// Size of the market configuration in borsh encoded bytes. - pub const fn encoded_size() -> usize { + #[must_use] pub const fn encoded_size() -> usize { 16 + 1 + 8 } } @@ -146,7 +146,7 @@ pub trait VaultExt { } // Add a 20% buffer to a gas estimate -pub const fn buffer(size: u64) -> Gas { +#[must_use] pub const fn buffer(size: u64) -> Gas { Gas::from_tgas((size * 6 + 4) / 5) } @@ -344,7 +344,7 @@ pub struct PendingWithdrawal { } impl PendingWithdrawal { - pub const fn encoded_size() -> usize { + #[must_use] pub const fn encoded_size() -> usize { storage_bytes_for_account_id() as usize + storage_bytes_for_account_id() as usize + 16 // escrow_shares: u128 @@ -354,7 +354,7 @@ impl PendingWithdrawal { } // Worst case size encoded for AccountId -pub const fn storage_bytes_for_account_id() -> u64 { +#[must_use] pub const fn storage_bytes_for_account_id() -> u64 { // 4 bytes for length prefix + worst case size encoded for AccountId 4 + AccountId::MAX_LEN as u64 } diff --git a/contract/vault/src/aum.rs b/contract/vault/src/aum.rs index f6961f4f..1226a33a 100644 --- a/contract/vault/src/aum.rs +++ b/contract/vault/src/aum.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Contract, MarketConfiguration, U128, env, near, require}; /// AUM (Assets Under Management) module /// @@ -147,15 +147,15 @@ impl AUM { /// Compute total assets according to the selected AUM model. /// /// Invariants and expectations: - /// - GovernanceAbandonment: - /// * Sums over withdraw_queue only. This is an intentional filter; it encodes + /// - `GovernanceAbandonment`: + /// * Sums over `withdraw_queue` only. This is an intentional filter; it encodes /// governance's current support set and excludes written-down markets. /// * If you re-add a market that still holds principal, you must pair this with - /// a last_total_assets bump elsewhere (see paper_aum_undercounting) to avoid + /// a `last_total_assets` bump elsewhere (see `paper_aum_undercounting`) to avoid /// spurious fee minting on reclassification. /// - /// - BalanceSheet: - /// * Sums over all markets that still have principal. Here we assume supply_queue + /// - `BalanceSheet`: + /// * Sums over all markets that still have principal. Here we assume `supply_queue` /// enumerates all configured/held markets. If it does not, replace with an /// iteration over the authoritative positions map (e.g., `config` or `positions`). /// * AUM changes only when principal changes or idle balance changes. @@ -173,18 +173,18 @@ impl AUM { }) } - /// Enforce removal policy for omitting a market from the withdraw_queue. + /// Enforce removal policy for omitting a market from the `withdraw_queue`. /// /// This function should be called at the point where an operator attempts to - /// remove a market from the withdraw_queue. It enforces model-specific invariants. + /// remove a market from the `withdraw_queue`. It enforces model-specific invariants. /// - /// - GovernanceAbandonment: + /// - `GovernanceAbandonment`: /// * If the market still has principal, removal requires that a removal timelock - /// was scheduled (removable_at > 0) and has elapsed (now >= removable_at). + /// was scheduled (`removable_at` > 0) and has elapsed (now >= `removable_at`). /// * Additional guards external to this function are typically required: /// cap == 0 and no pending cap. Enforce those where caps are managed. /// - /// - BalanceSheet: + /// - `BalanceSheet`: /// * Removal is prohibited while any principal remains (> 0). /// * Passing a timelock is necessary but not sufficient; ownership hasn't changed. pub fn policy_removal(&self, cfg: &MarketConfiguration, has_supply: &bool) { @@ -208,19 +208,19 @@ impl AUM { /// Handle accounting around potential AUM undercounting when re-adding markets. /// /// Context: - /// - Under GovernanceAbandonment, a market removed from the withdraw_queue is excluded + /// - Under `GovernanceAbandonment`, a market removed from the `withdraw_queue` is excluded /// from AUM even if it still holds principal. When that market is later re-added, /// its principal "reappears" in reported AUM. To prevent accidental performance fee - /// minting due purely to reclassification (not economic gain), we bump last_total_assets + /// minting due purely to reclassification (not economic gain), we bump `last_total_assets` /// by the previously excluded principal at re-add time. /// - /// - Under BalanceSheet, AUM was never reduced during removal attempts, so no bump is + /// - Under `BalanceSheet`, AUM was never reduced during removal attempts, so no bump is /// necessary. Fees accrue naturally on realized growth only. /// /// Safety notes: - /// - Only add before_principal that was actually excluded by the prior write-down. + /// - Only add `before_principal` that was actually excluded by the prior write-down. /// - This adjustment assumes your fee module mints fees on positive delta of - /// (current_total_assets - last_total_assets). If your fee policy differs, audit this path. + /// (`current_total_assets` - `last_total_assets`). If your fee policy differs, audit this path. pub fn paper_aum_undercounting(&self, c: &mut Contract, before_principal: &u128) { match self { AUM::GovernanceAbandonment => { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 0272db5f..9db0541f 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -212,15 +212,15 @@ impl Contract { op_id: op_id.into(), market: market.clone(), index: i, - need: need, + need, } .emit(); self.op_state = OpState::Withdrawing { op_id, index: market_index.saturating_add(1), - remaining: remaining, + remaining, receiver: received, - collected: collected, + collected, owner, escrow_shares, }; @@ -368,7 +368,7 @@ impl Contract { op_id, index: market_index.saturating_add(1), remaining: remaining_next, - receiver: receiver, + receiver, collected: collected_next, owner, escrow_shares, @@ -652,7 +652,7 @@ impl Contract { } } - /// Resolve a market for allocation by plan (if present) or supply_queue + /// Resolve a market for allocation by plan (if present) or `supply_queue` pub(crate) fn resolve_supply_market(&self, market_index: u32) -> Result { if let Some(plan) = &self.plan { if let Some((m, _)) = plan.get(market_index as usize) { @@ -666,7 +666,7 @@ impl Contract { .ok_or(Error::MissingMarket(market_index)) } - /// Resolve a market for withdraw by withdraw_queue + /// Resolve a market for withdraw by `withdraw_queue` pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result { self.withdraw_queue .get(market_index) @@ -681,7 +681,7 @@ pub struct SupplyReconciliation { pub remaining: u128, } -pub fn reconcile_supply_outcome( +#[must_use] pub fn reconcile_supply_outcome( total_position: &u128, before: &u128, remaining: &u128, @@ -703,7 +703,7 @@ pub struct WithdrawReconciliation { } /// Pure reconciliation for withdraw read outcome to enable unit tests -pub fn reconcile_withdraw_outcome( +#[must_use] pub fn reconcile_withdraw_outcome( before_principal: u128, new_principal: u128, remaining_total: u128, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 1704fb16..5b2924d2 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -18,8 +18,8 @@ use near_sdk::{ env, json_types::{U128, U64}, near, require, serde_json, - store::{IterableMap, LookupMap, Vector}, - AccountId, BorshStorageKey, Gas, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, + store::{IterableMap, Vector}, + AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, }; use near_sdk_contract_tools::{ ft::{ @@ -36,8 +36,8 @@ use templar_common::{ ext_self, require_at_least, AllocationMode, AllocationPlan, AllocationWeights, Error, Event, MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_1_CHECK_GAS, AFTER_SUPPLY_ENSURE_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, - EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, SUPPLY_GAS, + AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, + EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; @@ -405,7 +405,7 @@ impl Contract { "Timelock change already pending" ); require!( - (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&tl), + (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(tl), "Timelock out of bounds" ); if tl > &self.timelock_ns { @@ -512,7 +512,7 @@ impl Contract { ); Event::SupplyCapRaiseSubmitted { market: market.clone(), - new_cap: new_cap, + new_cap, valid_at, } .emit(); @@ -603,7 +603,7 @@ impl Contract { let cfg = self .markets .get_mut(&market) - .unwrap_or_else(|| env::panic_str(&format!("Unknown market: {}", market))); + .unwrap_or_else(|| env::panic_str(&format!("Unknown market: {market}"))); require!( cfg.removable_at == 0, "Removal already pending for this market" @@ -718,8 +718,7 @@ impl Contract { // Not in current queue: must be included if enabled or holding. env::panic_str( &format!( - "Invariant violation: Withdraw queue must include all enabled or holding markets; missing {}", - id + "Invariant violation: Withdraw queue must include all enabled or holding markets; missing {id}" ), ); } @@ -929,7 +928,7 @@ impl Contract { .clone() }), underlying_token: self.underlying_asset.clone(), - initial_timelock_ns: self.timelock_ns.clone().into(), + initial_timelock_ns: self.timelock_ns.into(), fee_recipient: self.fee_recipient.clone(), skim_recipient: self.skim_recipient.clone(), name: meta.name, @@ -941,7 +940,7 @@ impl Contract { /// Returns total assets under management = idle balance + sum of market principals. pub fn get_total_assets(&self) -> U128 { - self.aum.get_total_assets(&self) + self.aum.get_total_assets(self) } pub fn get_idle_balance(&self) -> U128 { @@ -1041,13 +1040,13 @@ impl Contract { fn cfg_mut(&mut self, id: &AccountId) -> &mut MarketConfiguration { self.markets .get_mut(id) - .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {}", id))) + .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {id}"))) } fn cfg(&self, id: &AccountId) -> &MarketConfiguration { self.markets .get(id) - .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {}", id))) + .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {id}"))) } // Principal (vault-supplied) units currently recorded for a market @@ -1283,7 +1282,7 @@ impl Contract { self.step_allocation() } - /// build a supply transfer_call and chain after_supply_1_check + /// build a supply `transfer_call` and chain `after_supply_1_check` fn supply_and_then(&self, market: &AccountId, amount: u128, op_id: u64, index: u32) -> Promise { self::require_at_least(AFTER_SUPPLY_1_CHECK_GAS.saturating_add(GAS_FOR_FT_TRANSFER_CALL)); self.underlying_asset @@ -1419,7 +1418,7 @@ impl Contract { return self.step_allocation(); } - PromiseOrValue::Promise(self.supply_and_then(&market, to_supply, op_id, index)) + PromiseOrValue::Promise(self.supply_and_then(market, to_supply, op_id, index)) } else { self.stop_and_exit::(None) } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index d5ad48ed..d5c8a3ca 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -42,7 +42,7 @@ fn c_owner_env(vault_id_fixture: AccountId) -> Contract { let c = new_test_contract(&vault_id_fixture); let owner = c .own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + .unwrap_or_else(|| env::panic_str("Owner not set")); setup_env(&vault_id_fixture, &owner, vec![]); c } @@ -146,7 +146,7 @@ fn prop_withdraw_queue_mustnt_have_duplicates(len: usize) { } #[rstest] -fn fee_accrues_only_on_growth_unit(mut c_vault_env: Contract) { +fn fee_accrues_only_on_growth_unit(c_vault_env: Contract) { let mut c = c_vault_env; // Seed total supply so fees can mint @@ -181,7 +181,7 @@ fn fee_accrues_only_on_growth_unit(mut c_vault_env: Contract) { } #[rstest] -fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(mut c_vault_env: Contract) { +fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_env: Contract) { let mut c = c_vault_env; let receiver = mk(7); @@ -215,12 +215,12 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(mut c_vau } #[rstest] -fn execute_next_withdrawal_request_skips_holes(mut c_owner_env: Contract) { +fn execute_next_withdrawal_request_skips_holes(c_owner_env: Contract) { let mut c = c_owner_env; let vault_id = accounts(0); let owner = c .own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + .unwrap_or_else(|| env::panic_str("Owner not set")); println!("vault_id: {vault_id}"); println!("owner: {owner}"); @@ -299,7 +299,7 @@ fn set_withdraw_queue_must_include_all_holding() { } #[rstest] -fn execute_supply_wrong_token_refunds_full(mut c_vault_env: Contract) { +fn execute_supply_wrong_token_refunds_full(c_vault_env: Contract) { let mut c = c_vault_env; let sender = accounts(1); @@ -320,7 +320,7 @@ fn set_withdraw_queue_must_include_all_enabled() { setup_env( &vault_id, &c.own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + .unwrap_or_else(|| env::panic_str("Owner not set")), vec![], ); @@ -338,7 +338,7 @@ fn set_withdraw_queue_must_include_all_enabled() { } #[rstest] -fn start_allocation_reserves_only_amount(mut c_vault_env: Contract) { +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 @@ -395,7 +395,7 @@ fn queue_allocation_ignores_stale_plan() { setup_env( &vault_id, &c.own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + .unwrap_or_else(|| env::panic_str("Owner not set")), vec![], ); @@ -433,7 +433,7 @@ fn set_withdraw_queue_disallow_nonzero_position_removal() { setup_env( &vault_id, &c.own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + .unwrap_or_else(|| env::panic_str("Owner not set")), vec![], ); @@ -624,7 +624,7 @@ fn set_withdraw_queue_disallow_nonzero_cap_removal() { setup_env( &vault_id, &c.own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + .unwrap_or_else(|| env::panic_str("Owner not set")), vec![], ); @@ -698,7 +698,7 @@ fn set_withdraw_queue_allows_zero_supply_removal() { setup_env( &vault_id, &c.own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())), + .unwrap_or_else(|| env::panic_str("Owner not set")), vec![], ); @@ -968,7 +968,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates() { let mut c = new_test_contract(&vault_id); let owner = c .own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + .unwrap_or_else(|| env::panic_str("Owner not set")); setup_env(&vault_id, &owner, vec![]); // Seed supply so fee shares can mint @@ -1021,7 +1021,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates_variant() { let mut c = new_test_contract(&vault_id); let owner = c .own_get_owner() - .unwrap_or_else(|| env::panic_str(&"Owner not set".to_string())); + .unwrap_or_else(|| env::panic_str("Owner not set")); setup_env(&vault_id, &owner, vec![]); // Seed supply so fee shares can mint @@ -1107,7 +1107,7 @@ fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { #[rstest] fn ft_on_transfer_supply_accepts_full_and_mints_shares( - mut c_asset_env: Contract, + c_asset_env: Contract, enabled_market_100: (AccountId, MarketConfiguration), ) { let mut c = c_asset_env; @@ -1153,7 +1153,7 @@ fn ft_on_transfer_supply_accepts_full_and_mints_shares( #[rstest] fn ft_on_transfer_supply_partial_refund_when_capped( - mut c_asset_env: Contract, + c_asset_env: Contract, enabled_market_100: (AccountId, MarketConfiguration), ) { let mut c = c_asset_env; @@ -1242,7 +1242,7 @@ fn ft_on_transfer_invalid_msg_panics() { #[rstest] fn ft_on_transfer_zero_amount_returns_zero_refund( - mut c_vault_env: Contract, + c_vault_env: Contract, enabled_market_100: (AccountId, MarketConfiguration), ) { let mut c = c_vault_env; @@ -1273,7 +1273,7 @@ fn ft_on_transfer_zero_amount_returns_zero_refund( #[rstest] fn ft_on_transfer_eager_mode_triggers_allocation( - mut c_asset_env: Contract, + c_asset_env: Contract, enabled_market_100: (AccountId, MarketConfiguration), ) { let mut c = c_asset_env; @@ -1811,7 +1811,7 @@ fn skim_rejects_share_token() { } #[rstest] -fn after_supply_1_check_allocating_not_allocating(mut c_max: Contract) { +fn after_supply_1_check_allocating_not_allocating(c_max: Contract) { let mut c = c_max; c.op_state = OpState::Idle; diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index 236979f4..a20c9f15 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -2,7 +2,7 @@ use core::ops::Div; use std::collections::BTreeMap; use std::ops::{Add, Sub}; -use near_sdk::borsh::schema::{add_definition, Declaration, Definition, Fields}; +use near_sdk::borsh::schema::{add_definition, Declaration, Definition}; use near_sdk::borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; use templar_common::primitive_types::{U256, U512}; @@ -13,23 +13,23 @@ pub struct Number(pub U256); impl Number { #[inline] - pub fn zero() -> Self { + #[must_use] pub fn zero() -> Self { Number(U256::zero()) } #[inline] - pub fn one() -> Self { + #[must_use] pub fn one() -> Self { Number(U256::one()) } #[inline] - pub fn is_zero(&self) -> bool { + #[must_use] pub fn is_zero(&self) -> bool { self.0.is_zero() } #[inline] - pub fn is_one(&self) -> bool { + #[must_use] pub fn is_one(&self) -> bool { self.0 == U256::one() } #[inline] - pub fn as_u128_trunc(self) -> u128 { + #[must_use] pub fn as_u128_trunc(self) -> u128 { let mut b32 = [0u8; 32]; self.0.to_little_endian(&mut b32); let mut b16 = [0u8; 16]; @@ -43,11 +43,11 @@ impl Number { U256::from_little_endian(&b64[..32]) } #[inline] - pub fn saturating_add(self, other: Number) -> Number { + #[must_use] pub fn saturating_add(self, other: Number) -> Number { Number(self.0.saturating_add(other.0)) } #[inline] - pub fn saturating_sub(self, other: Number) -> Number { + #[must_use] pub fn saturating_sub(self, other: Number) -> Number { Number(self.0.saturating_sub(other.0)) } #[inline] @@ -71,10 +71,10 @@ impl Number { let q = prod / d; let r = prod % d; let base = Number(Self::as_u256_trunc(q)); - if !r.is_zero() { - base.saturating_add(Number::one()) - } else { + if r.is_zero() { base + } else { + base.saturating_add(Number::one()) } } } @@ -180,12 +180,12 @@ impl Wad { } #[inline] - pub fn is_zero(&self) -> bool { + #[must_use] pub fn is_zero(&self) -> bool { self.0.is_zero() } #[inline] - pub fn is_one(&self) -> bool { + #[must_use] pub fn is_one(&self) -> bool { self.0 .0 == U256::from(Self::SCALE) } @@ -283,7 +283,7 @@ impl BorshSchema for Wad { /// - `total_supply`: current total share supply /// /// Floors intermediate divisions; returns 0 when no profit, zero fee, zero supply, -/// or when the fee consumes all assets (cur_total_assets == fee_assets). +/// or when the fee consumes all assets (`cur_total_assets` == `fee_assets`). #[inline] #[must_use] pub fn compute_fee_shares( @@ -311,7 +311,7 @@ pub fn compute_fee_shares( Number::mul_div_floor(fee_assets, total_supply, denom) } -/// Multiplies x by y/Wad::SCALE and floors: floor(x * y / 1e24). +/// Multiplies x by `y/Wad::SCALE` and floors: floor(x * y / 1e24). /// y is a WAD-scaled fraction (1e24 = 100%), and x is an unscaled amount. #[inline] #[must_use] diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 2f512ba0..ec048f6f 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -22,7 +22,7 @@ async fn happy(#[future(awt)] worker: Worker) { vault.init_account(&supply_user).await; let initial_user_balance = c.borrow_asset.balance_of(supply_user.id()).await; - println!("Initial supply_user balance: {}", initial_user_balance); + println!("Initial supply_user balance: {initial_user_balance}"); let v = vault.contract().id(); let amount: U128 = 1000.into(); diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index dfb1adc4..bcd74c28 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::*; +use templar_common::vault::{AllocationWeights, DepositMsg, VaultConfiguration, VaultExt}; use tokio::sync::OnceCell; #[derive(Clone)] @@ -209,7 +209,7 @@ impl UnifiedVaultController { } } - pub fn new( + #[must_use] pub fn new( vault: VaultController, configuration: VaultConfiguration, market: UnifiedMarketController, @@ -358,7 +358,6 @@ impl UnifiedVaultController { fn is_debug() -> bool { env::var("RUST_LOG") - .map(|s| s.contains("debug")) - .unwrap_or_default() + .is_ok_and(|s| s.contains("debug")) || env::var("DEBUG").is_ok() } From 2eb6345eb7fdd773e930aad3879999a9827ec2f5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 28 Oct 2025 22:38:53 +0000 Subject: [PATCH 094/121] wip: withdraws(again) --- contract/vault/src/impl_callbacks.rs | 105 +++++++++-------- contract/vault/src/lib.rs | 164 +++++++++++++++++++++------ 2 files changed, 187 insertions(+), 82 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 9db0541f..b5f7ac19 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -196,17 +196,23 @@ impl Contract { }; if did_create.is_ok() { - PromiseOrValue::Promise( - ext_market::ext(market.clone()) - .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) - .with_unused_gas_weight(0) - .execute_next_supply_withdrawal_request() - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_GAS) - .after_exec_withdraw_req(op_id, market_index, need), - ), - ) + if self.defer_market_execute { + // record the created request and pause; executor will pick it up + self.pending_market_exec.push(market_index); + return PromiseOrValue::Value(()); + } else { + return PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) + .with_unused_gas_weight(0) + .execute_next_supply_withdrawal_request() + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_GAS) + .after_exec_withdraw_req(op_id, market_index, need), + ), + ); + } } else { Event::CreateWithdrawalFailed { op_id: op_id.into(), @@ -334,35 +340,33 @@ impl Contract { self.idle_balance = self.idle_balance.saturating_add(idle_delta); } + if let Some(pos) = self + .pending_market_exec + .iter() + .position(|&idx| idx == market_index) + { + self.pending_market_exec.remove(pos); + } + if remaining_next == 0 { - if collected_next > 0 { - self.op_state = OpState::Payout { - op_id, - receiver: receiver.clone(), - amount: collected_next, - owner: owner.clone(), - escrow_shares, - burn_shares: escrow_shares, - }; - PromiseOrValue::Promise( - self.underlying_asset - .clone() - .transfer(receiver.clone(), U128(collected_next).into()) - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, receiver, U128(collected_next)), - ), - ) - } else { - // Nothing collected; refund escrowed shares - let self_id = env::current_account_id(); - // We expect the owner to maintain storage accounts, otherwise they will lose access to their funds - self.transfer_unchecked(&self_id, &owner, escrow_shares) - .expect("Failed to refund escrowed shares"); - self.op_state = OpState::Idle; - PromiseOrValue::Value(()) - } + self.pay_collected( + op_id, + &receiver, + collected_next, + &owner, + escrow_shares, + escrow_shares, + |_self| { + // Nothing collected; refund escrowed shares + let self_id = env::current_account_id(); + // We expect the owner to maintain storage accounts, otherwise they will lose access to their funds + _self + .transfer_unchecked(&self_id, &owner, escrow_shares) + .expect("Failed to refund escrowed shares"); + _self.op_state = OpState::Idle; + PromiseOrValue::Value(()) + }, + ) } else { self.op_state = OpState::Withdrawing { op_id, @@ -388,7 +392,7 @@ impl Contract { op_id: u64, receiver: AccountId, amount: U128, - ) -> bool { + ) { let (owner, escrow_shares, expected_amount, burn_shares) = match &self.op_state { OpState::Payout { op_id: current_op, @@ -407,7 +411,7 @@ impl Contract { amount, } .emit(); - return false; + return; } }; @@ -437,18 +441,17 @@ impl Contract { .unwrap_or_else(|e| env::log_str(&e.to_string())); // TODO: emit Refund event } - - // Pop the withdrawing id and reconcile the primer - self.op_state = OpState::Idle; - true } else { // On payout failure, refund full escrow to owner and leave idle_balance unchanged self.transfer_unchecked(&env::current_account_id(), &owner, escrow_shares) // If this fails, this is a serious issue as above .unwrap_or_else(|e| env::log_str(&e.to_string())); - self.op_state = OpState::Idle; - false } + self.pending_market_exec.clear(); + self.pending_market_exec.clear(); + self.remove_inflight_and_advance_head(); + self.pending_market_exec.clear(); + self.op_state = OpState::Idle; } #[private] @@ -552,6 +555,7 @@ impl Contract { self.transfer_unchecked(&self_id, &owner_acc, escrow) .unwrap_or_else(|e| env::log_str(&e.to_string())); } + self.remove_inflight_and_advance_head(); self.op_state = OpState::Idle; } @@ -592,6 +596,7 @@ impl Contract { .unwrap_or_else(|e| env::log_str(&e.to_string())); } } + self.remove_inflight_and_advance_head(); self.op_state = OpState::Idle; } @@ -681,7 +686,8 @@ pub struct SupplyReconciliation { pub remaining: u128, } -#[must_use] pub fn reconcile_supply_outcome( +#[must_use] +pub fn reconcile_supply_outcome( total_position: &u128, before: &u128, remaining: &u128, @@ -703,7 +709,8 @@ pub struct WithdrawReconciliation { } /// Pure reconciliation for withdraw read outcome to enable unit tests -#[must_use] pub fn reconcile_withdraw_outcome( +#[must_use] +pub fn reconcile_withdraw_outcome( before_principal: u128, new_principal: u128, remaining_total: u128, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 5b2924d2..02b9a084 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -36,9 +36,8 @@ use templar_common::{ ext_self, require_at_least, AllocationMode, AllocationPlan, AllocationWeights, Error, Event, MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, VaultConfiguration, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, - EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, - WITHDRAW_GAS, + AFTER_SUPPLY_1_CHECK_GAS, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, + MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; pub use wad::*; @@ -138,6 +137,7 @@ pub struct Contract { supply_queue: Vector, /// Ordered list of market IDs for withdrawal prioritytr withdraw_queue: Vector, + current_withdraw_inflight: Option, // id of the pending withdrawal being executed, if any /// vault's supplied principal per market (borrow-asset units) market_supply: IterableMap, @@ -151,6 +151,11 @@ pub struct Contract { pending_withdrawals: IterableMap, next_withdraw_id: u64, next_withdraw_to_execute: u64, + + // if true, only create requests during build; executor will run them + defer_market_execute: bool, + // indices of markets with created requests (per withdrawing op) + pending_market_exec: Vec, } #[near] @@ -227,6 +232,10 @@ impl Contract { pending_withdrawals: IterableMap::new(key!(PendingWithdrawals)), next_withdraw_id: 0, next_withdraw_to_execute: 0, + + // Deferred market execution + defer_market_execute: true, // default to “stop executing automatically” per request + pending_market_exec: Vec::new(), }; contract.set_metadata(&ContractMetadata::new(name, symbol, decimals.into())); Owner::init(&mut contract, &owner); @@ -784,26 +793,66 @@ impl Contract { self.ensure_idle(); Self::assert_allocator(); - // Find the next present pending withdrawal by id - let mut id = self.next_withdraw_to_execute; - while id < self.next_withdraw_id { - if let Some(pending) = self.pending_withdrawals.remove(&id) { - // Advance the head pointer and start processing - self.next_withdraw_to_execute = id.saturating_add(1); - return self.start_withdraw( - pending.expected_assets, - pending.receiver, - pending.owner, - pending.escrow_shares, - ); - } - id = id.saturating_add(1); - self.next_withdraw_to_execute = id; + 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 + .pending_withdrawals + .get(&id) + .unwrap_or_else(|| env::panic_str("pending vanished unexpectedly")); + self.current_withdraw_inflight = Some(id); + env::log_str(&format!("WithdrawalExecutionStarted id={id}")); + return self.start_withdraw( + pending.expected_assets, + &pending.receiver, + &pending.owner, + pending.escrow_shares, + ); } PromiseOrValue::Value(()) } + /// Executes one created market withdrawal request in the current Withdrawing op. + pub fn allocator_execute_next_market_withdrawal(&mut self, op_id: u64) -> PromiseOrValue<()> { + require_at_least(EXECUTE_WITHDRAW_GAS); + Self::assert_allocator(); + + // Must be in Withdrawing context for the provided op_id + let _ctx = match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), + }; + + // Ensure we have a created request to execute + let market_index = match self.pending_market_exec.first().copied() { + Some(idx) => idx, + None => { + env::panic_str("No pending market withdrawal request to execute"); + } + }; + + let market = match self.resolve_withdraw_market(market_index) { + Ok(m) => m, + Err(e) => return self.stop_and_exit(Some(&e)), + }; + + PromiseOrValue::Promise( + templar_common::market::ext_market::ext(market.clone()) + .with_static_gas(templar_common::vault::EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) + .with_unused_gas_weight(0) + .execute_next_supply_withdrawal_request() + .then( + ext_self::ext(env::current_account_id()) + .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_GAS) + // `need` here is informational; we do not track it across the defer + .after_exec_withdraw_req(op_id, market_index, U128(0)), + ), + ) + } + /// 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(); @@ -893,6 +942,40 @@ impl Contract { self.start_allocation(total) } + // 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; // head points at a live entry + return Some(id); + } + id = id.saturating_add(1); + } + self.next_withdraw_to_execute = id; // no entries + None + } + + // 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); + env::log_str(&format!("WithdrawalDequeued id={id}")); + } + } + + // Keep the head pending but clear in-flight so it can be retried later + fn park_inflight_head_for_retry(&mut self) { + if self.current_withdraw_inflight.is_some() { + env::log_str(&format!( + "WithdrawalParked id={}", + self.current_withdraw_inflight.unwrap() + )); + } + self.current_withdraw_inflight = None; + // next_withdraw_to_execute remains pointing at the same id + } } /* ----- Views ----- */ @@ -1449,8 +1532,8 @@ impl Contract { fn start_withdraw( &mut self, amount: u128, - receiver: AccountId, - owner: AccountId, + receiver: &AccountId, + owner: &AccountId, escrow_shares: u128, ) -> PromiseOrValue<()> { if amount == 0 { @@ -1465,13 +1548,15 @@ impl Contract { let remaining = amount.saturating_sub(used_idle); let collected = used_idle; + self.pending_market_exec.clear(); + self.op_state = OpState::Withdrawing { op_id, index: Default::default(), remaining, - receiver, + receiver: receiver.clone(), collected, - owner, + owner: owner.clone(), escrow_shares, }; self.step_withdraw() @@ -1548,23 +1633,38 @@ impl Contract { ), ) } else { - self.pay_collected(op_id, remaining, receiver, collected, owner, escrow_shares) + let requested = collected.saturating_add(remaining); + let burn_shares = self.compute_burn_shares(escrow_shares, collected, requested); + + self.pay_collected( + op_id, + &receiver, + collected, + &owner, + escrow_shares, + burn_shares, + |_self| { + // Park the head pending: keep escrowed shares, stay in queue, try again later + _self.op_state = OpState::Idle; + _self.park_inflight_head_for_retry(); + PromiseOrValue::Value(()) + }, + ) } } - /// If we collected something, pay it out now and burn proportional shares or pay directly from idle balance + /// If we collected something, pay it out now and burn proportional shares or do something else fn pay_collected( &mut self, op_id: u64, - remaining: u128, - receiver: AccountId, + receiver: &AccountId, collected: u128, - owner: AccountId, + owner: &AccountId, escrow_shares: u128, + burn_shares: u128, + or_else: impl FnOnce(&mut Self) -> PromiseOrValue<()>, ) -> PromiseOrValue<()> { if collected > 0 { - let requested = collected.saturating_add(remaining); - let burn_shares = self.compute_burn_shares(escrow_shares, collected, requested); self.op_state = OpState::Payout { op_id, receiver: receiver.clone(), @@ -1579,13 +1679,11 @@ impl Contract { .then( ext_self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, receiver, U128(collected)), + .after_send_to_user(op_id, receiver.clone(), U128(collected)), ), ) } else { - // Park the head pending: keep escrowed shares, stay in queue, try again later - self.op_state = OpState::Idle; - PromiseOrValue::Value(()) + or_else(self) } } } From 35d604de3c258ddb6dd87464bccc2e3746cfc7b1 Mon Sep 17 00:00:00 2001 From: Donovan Dall Date: Thu, 30 Oct 2025 13:01:53 +0000 Subject: [PATCH 095/121] chore: pr comments --- common/src/vault.rs | 204 +++++++++++++++++----- contract/vault/src/impl_callbacks.rs | 169 +++++++----------- contract/vault/src/impl_token_receiver.rs | 5 +- contract/vault/src/lib.rs | 138 +++++++-------- contract/vault/src/tests.rs | 111 ++++++------ 5 files changed, 342 insertions(+), 285 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index bf9d04b2..31af397c 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -68,7 +68,8 @@ pub struct MarketConfiguration { impl MarketConfiguration { /// Size of the market configuration in borsh encoded bytes. - #[must_use] pub const fn encoded_size() -> usize { + #[must_use] + pub const fn encoded_size() -> usize { 16 + 1 + 8 } } @@ -146,7 +147,8 @@ pub trait VaultExt { } // Add a 20% buffer to a gas estimate -#[must_use] pub const fn buffer(size: u64) -> Gas { +#[must_use] +pub const fn buffer(size: u64) -> Gas { Gas::from_tgas((size * 6 + 4) / 5) } @@ -227,7 +229,90 @@ pub trait Callbacks { pub struct PendingValue { pub value: T, // Timestamp when this pending value can be finalized - pub valid_at: TimestampNs, + pub valid_at_ns: TimestampNs, +} + +impl PendingValue { + pub fn verify(&self) { + require!( + near_sdk::env::block_timestamp() >= self.valid_at_ns, + "Timelock not elapsed yet" + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// No operation in-flight. The vault is ready to start a new allocation or withdrawal. +pub struct IdleState; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// Supplying idle underlying to markets according to a plan or queue. +/// +/// Transitions: +/// - On completion of allocation: Withdrawing (to satisfy pending user requests) or Idle (if stopped). +/// - On stop/failure: Idle. +pub struct AllocatingState { + /// Unique operation id used to correlate async callbacks and detect drift. + pub op_id: u64, + /// Zero-based position within the allocation plan/queue currently being processed. + pub index: u32, + /// Amount of underlying (in asset units) still to allocate during this operation. + pub remaining: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// Collecting liquidity from markets to satisfy a user withdrawal/redeem request. +/// +/// Transitions: +/// - Advance within queue: Withdrawing (index increments) while collecting funds. +/// - When enough is collected to satisfy the request: Payout. +/// - If the op is stopped or cannot proceed and needs to refund: Idle (escrow_shares refunded). +pub struct WithdrawingState { + /// Unique operation id used to correlate async callbacks and detect drift. + pub op_id: u64, + /// Zero-based position within the withdraw queue currently being processed. + pub index: u32, + /// Remaining assets that must still be collected to satisfy the request. + pub remaining: u128, + /// Assets already collected and held as idle_balance pending payout. + pub collected: u128, + /// Account that should receive the assets during payout. + pub receiver: AccountId, + /// The owner whose shares are being redeemed. + pub owner: AccountId, + /// Shares locked in escrow for this request. + /// - Refunded on stop/failure. + /// - On payout success, a portion is burned (see burn_shares) and any remainder is refunded. + pub escrow_shares: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// Final step that transfers assets to the receiver and settles the share escrow. +/// +/// Transitions: +/// - On success or failure: Idle. +/// +/// Invariant hooks: +/// - idle_balance decreases only on payout success by `amount`. +/// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded. +/// - On failure, all `escrow_shares` are refunded. +pub struct PayoutState { + /// Unique operation id used to correlate async callbacks and detect drift. + pub op_id: u64, + /// Receiver of the asset payout. + pub receiver: AccountId, + /// Amount of assets to transfer out from idle_balance. + pub amount: u128, + /// The owner whose shares were escrowed for this payout. + pub owner: AccountId, + /// Total shares currently held in escrow for this operation. + pub escrow_shares: u128, + /// Portion of `escrow_shares` that will be burned on successful payout. + pub burn_shares: u128, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -251,14 +336,7 @@ pub enum OpState { /// Transitions: /// - On completion of allocation: Withdrawing (to satisfy pending user requests) or Idle (if stopped). /// - On stop/failure: Idle. - Allocating { - /// Unique operation id used to correlate async callbacks and detect drift. - op_id: u64, - /// Zero-based position within the allocation plan/queue currently being processed. - index: u32, - /// Amount of underlying (in asset units) still to allocate during this operation. - remaining: u128, - }, + Allocating(AllocatingState), /// Collecting liquidity from markets to satisfy a user withdrawal/redeem request. /// @@ -266,24 +344,7 @@ pub enum OpState { /// - Advance within queue: Withdrawing (index increments) while collecting funds. /// - When enough is collected to satisfy the request: Payout. /// - If the op is stopped or cannot proceed and needs to refund: Idle (escrow_shares refunded). - Withdrawing { - /// Unique operation id used to correlate async callbacks and detect drift. - op_id: u64, - /// Zero-based position within the withdraw queue currently being processed. - index: u32, - /// Remaining assets that must still be collected to satisfy the request. - remaining: u128, - /// Assets already collected and held as idle_balance pending payout. - collected: u128, - /// Account that should receive the assets during payout. - receiver: AccountId, - /// The owner whose shares are being redeemed. - owner: AccountId, - /// Shares locked in escrow for this request. - /// - Refunded on stop/failure. - /// - On payout success, a portion is burned (see burn_shares) and any remainder is refunded. - escrow_shares: u128, - }, + Withdrawing(WithdrawingState), /// Final step that transfers assets to the receiver and settles the share escrow. /// @@ -294,20 +355,67 @@ pub enum OpState { /// - idle_balance decreases only on payout success by `amount`. /// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded. /// - On failure, all `escrow_shares` are refunded. - Payout { - /// Unique operation id used to correlate async callbacks and detect drift. - op_id: u64, - /// Receiver of the asset payout. - receiver: AccountId, - /// Amount of assets to transfer out from idle_balance. - amount: u128, - /// The owner whose shares were escrowed for this payout. - owner: AccountId, - /// Total shares currently held in escrow for this operation. - escrow_shares: u128, - /// Portion of `escrow_shares` that will be burned on successful payout. - burn_shares: u128, - }, + Payout(PayoutState), +} + +impl From for OpState { + fn from(_: IdleState) -> Self { + OpState::Idle + } +} + +impl From for OpState { + fn from(s: AllocatingState) -> Self { + OpState::Allocating(s) + } +} + +impl From for OpState { + fn from(s: WithdrawingState) -> Self { + OpState::Withdrawing(s) + } +} + +impl From for OpState { + fn from(s: PayoutState) -> Self { + OpState::Payout(s) + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &IdleState { + match self { + OpState::Idle => &IdleState, + _ => panic!("OpState::Idle expected"), + } + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &AllocatingState { + match self { + OpState::Allocating(s) => s, + _ => panic!("OpState::Allocating expected"), + } + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &WithdrawingState { + match self { + OpState::Withdrawing(s) => s, + _ => panic!("OpState::Withdrawing expected"), + } + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &PayoutState { + match self { + OpState::Payout(s) => s, + _ => panic!("OpState::Payout expected"), + } + } } #[derive(Debug)] @@ -344,7 +452,8 @@ pub struct PendingWithdrawal { } impl PendingWithdrawal { - #[must_use] pub const fn encoded_size() -> usize { + #[must_use] + pub const fn encoded_size() -> usize { storage_bytes_for_account_id() as usize + storage_bytes_for_account_id() as usize + 16 // escrow_shares: u128 @@ -354,7 +463,8 @@ impl PendingWithdrawal { } // Worst case size encoded for AccountId -#[must_use] pub const fn storage_bytes_for_account_id() -> u64 { +#[must_use] +pub const fn storage_bytes_for_account_id() -> u64 { // 4 bytes for length prefix + worst case size encoded for AccountId 4 + AccountId::MAX_LEN as u64 } @@ -456,7 +566,7 @@ pub enum Event { #[event_version("1.0.0")] TimelockSet { seconds: U64 }, #[event_version("1.0.0")] - TimelockChangeSubmitted { new_ns: U64, valid_at: U64 }, + TimelockChangeSubmitted { new_ns: U64, valid_at_ns: U64 }, #[event_version("1.0.0")] PendingTimelockRevoked, @@ -467,7 +577,7 @@ pub enum Event { SupplyCapRaiseSubmitted { market: AccountId, new_cap: U128, - valid_at: u64, + valid_at_ns: u64, }, #[event_version("1.0.0")] SupplyCapRaiseRevoked { market: AccountId }, diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index b5f7ac19..0ab99eea 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -5,14 +5,14 @@ use crate::{ }; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{env, json_types::U128, AccountId, NearToken, PromiseError, PromiseOrValue}; -use near_sdk_contract_tools::ft::nep141::GAS_FOR_FT_TRANSFER_CALL; +use near_sdk_contract_tools::ft::{nep141::GAS_FOR_FT_TRANSFER_CALL, Nep141Burn}; use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - Event, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS, - AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_2_READ_GAS, EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, - GET_SUPPLY_POSITION_GAS, + AllocatingState, Event, PayoutState, WithdrawingState, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, + AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_2_READ_GAS, + EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, GET_SUPPLY_POSITION_GAS, }, }; @@ -160,11 +160,11 @@ impl Contract { self.add_market_to_withdraw_queue(&market, before.0); } - self.op_state = OpState::Allocating { + self.op_state = OpState::Allocating(AllocatingState { op_id, index: market_index.saturating_add(1), remaining: remaining_next, - }; + }); if remaining_next == 0 { // All funds allocated successfully return self.stop_and_exit(None::<&String>); @@ -221,7 +221,7 @@ impl Contract { need, } .emit(); - self.op_state = OpState::Withdrawing { + self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: market_index.saturating_add(1), remaining, @@ -229,7 +229,7 @@ impl Contract { collected, owner, escrow_shares, - }; + }); self.step_withdraw() } } @@ -368,7 +368,7 @@ impl Contract { }, ) } else { - self.op_state = OpState::Withdrawing { + self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: market_index.saturating_add(1), remaining: remaining_next, @@ -376,7 +376,7 @@ impl Contract { collected: collected_next, owner, escrow_shares, - }; + }); self.step_withdraw() } } @@ -394,14 +394,14 @@ impl Contract { amount: U128, ) { let (owner, escrow_shares, expected_amount, burn_shares) = match &self.op_state { - OpState::Payout { + OpState::Payout(PayoutState { op_id: current_op, receiver: recv, amount, owner, escrow_shares, burn_shares, - } if *current_op == op_id && *recv == receiver => { + }) if *current_op == op_id && *recv == receiver => { (owner.clone(), *escrow_shares, *amount, *burn_shares) } _ => { @@ -427,9 +427,7 @@ impl Contract { // Burn only the proportional shares and refund the remainder to the owner. if burn_shares > 0 { // Serious issue: this should be infallible - if the withdrawal panics here we have an escrow settlement error - self.withdraw_unchecked(&env::current_account_id(), burn_shares) - .unwrap_or_else(|e| env::log_str(&e.to_string())); - // TODO: emit burn event + self.burn(&Nep141Burn::new(burn_shares, &env::current_account_id())); } // Maybe refund any delta to the owner @@ -487,31 +485,25 @@ impl Contract { &mut self, msg: Option<&T>, ) { - if let OpState::Allocating { - op_id, - index, - remaining, - } = &self.op_state - { - match msg { - None => { - Event::AllocationCompleted { op_id: *op_id }.emit(); - } - Some(m) => { - Event::AllocationStopped { - op_id: (*op_id).into(), - index: *index, - remaining: U128(*remaining), - reason: Some(m.to_string()), - } - .emit(); + let s: &AllocatingState = self.op_state.as_ref(); + match msg { + None => { + Event::AllocationCompleted { op_id: s.op_id }.emit(); + } + Some(m) => { + Event::AllocationStopped { + op_id: (s.op_id).into(), + index: s.index, + remaining: U128(s.remaining), + reason: Some(m.to_string()), } + .emit(); } + } - // Always add back remaining to idle_balance - if *remaining > 0 { - self.idle_balance = self.idle_balance.saturating_add(*remaining); - } + // Always add back remaining to idle_balance + if s.remaining > 0 { + self.idle_balance = self.idle_balance.saturating_add(s.remaining); } self.plan = None; self.op_state = OpState::Idle; @@ -522,39 +514,25 @@ impl Contract { &mut self, msg: Option<&T>, ) { - { - let (op_id, index, remaining, collected) = match &self.op_state { - OpState::Withdrawing { - op_id, - index, - remaining, - collected, - .. - } => (*op_id, *index, *remaining, *collected), - _ => (0, 0, 0, 0), - }; - Event::WithdrawalStopped { - op_id: op_id.into(), - index, - remaining: U128(remaining), - collected: U128(collected), - reason: msg.map(std::string::ToString::to_string), - } - .emit(); + let s: &WithdrawingState = self.op_state.as_ref(); + + Event::WithdrawalStopped { + op_id: s.op_id.into(), + index: s.index, + remaining: U128(s.remaining), + collected: U128(s.collected), + reason: msg.map(std::string::ToString::to_string), } - if let Some((owner_acc, escrow)) = match &self.op_state { - OpState::Withdrawing { - owner, - escrow_shares, - .. - } if *escrow_shares > 0 => Some((owner.clone(), *escrow_shares)), - _ => None, - } { - let self_id = env::current_account_id(); + .emit(); + + let owner = s.owner.clone(); + + if s.escrow_shares > 0 { #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&self_id, &owner_acc, escrow) + self.transfer_unchecked(&env::current_account_id(), &owner, s.escrow_shares) .unwrap_or_else(|e| env::log_str(&e.to_string())); } + self.remove_inflight_and_advance_head(); self.op_state = OpState::Idle; } @@ -564,37 +542,19 @@ impl Contract { &mut self, msg: Option<&T>, ) { - { - if let OpState::Payout { - op_id, - receiver, - amount, - .. - } = &self.op_state - { - Event::PayoutStopped { - op_id: (*op_id).into(), - receiver: receiver.clone(), - amount: U128(*amount), - reason: msg.map(std::string::ToString::to_string), - } - .emit(); - } + let s: &PayoutState = self.op_state.as_ref(); + Event::PayoutStopped { + op_id: (s.op_id).into(), + receiver: s.receiver.clone(), + amount: U128(s.amount), + reason: msg.map(std::string::ToString::to_string), } - if let OpState::Payout { - owner, - escrow_shares, - .. - } = &self.op_state - { - if *escrow_shares > 0 { - let self_id = env::current_account_id(); - let owner_acc = owner.clone(); - let escrow = *escrow_shares; - #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&self_id, &owner_acc, escrow) - .unwrap_or_else(|e| env::log_str(&e.to_string())); - } + .emit(); + + let owner = s.owner.clone(); + if s.escrow_shares > 0 { + self.transfer_unchecked(&env::current_account_id(), &owner, s.escrow_shares) + .unwrap_or_else(|e| env::log_str(&e.to_string())); } self.remove_inflight_and_advance_head(); self.op_state = OpState::Idle; @@ -604,16 +564,15 @@ impl Contract { &mut self, msg: Option<&T>, ) -> PromiseOrValue<()> { - match self.op_state { - OpState::Allocating { .. } => self.stop_and_exit_allocating(msg), - OpState::Withdrawing { .. } => self.stop_and_exit_withdrawing(msg), - OpState::Payout { .. } => self.stop_and_exit_payout(msg), + match &self.op_state { + OpState::Allocating(_) => self.stop_and_exit_allocating(msg), + OpState::Withdrawing(_) => self.stop_and_exit_withdrawing(msg), + OpState::Payout(_) => self.stop_and_exit_payout(msg), OpState::Idle => { Event::OperationStoppedWhileIdle { reason: msg.map(std::string::ToString::to_string), } .emit(); - self.op_state = OpState::Idle; } } PromiseOrValue::Value(()) @@ -622,11 +581,11 @@ impl Contract { /// Validate current op is Allocating and return (index, remaining) pub(crate) fn ctx_allocating(&self, op_id: u64) -> Result<(u32, u128), Error> { match &self.op_state { - OpState::Allocating { + OpState::Allocating(AllocatingState { op_id: cur, index, remaining, - } if *cur == op_id => Ok((*index, *remaining)), + }) if *cur == op_id => Ok((*index, *remaining)), _ => Err(Error::NotAllocating), } } @@ -637,7 +596,7 @@ impl Contract { op_id: u64, ) -> Result<(u32, u128, AccountId, u128, AccountId, u128), Error> { match &self.op_state { - OpState::Withdrawing { + OpState::Withdrawing(WithdrawingState { op_id: cur, index, remaining, @@ -645,7 +604,7 @@ impl Contract { collected, owner, escrow_shares, - } if *cur == op_id => Ok(( + }) if *cur == op_id => Ok(( *index, *remaining, receiver.clone(), diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index e09a5979..6b8a44ee 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -1,6 +1,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, SUPPLY_GAS}; #[allow(clippy::wildcard_imports)] @@ -126,7 +127,8 @@ impl Contract { let refund = deposit - accept; let shares = self.preview_deposit(U128(accept)).0; - self.mint_shares(&sender_id, shares); + self.mint(&Nep141Mint::new(shares, &sender_id)); + Event::MintedShares { amount: shares.into(), receiver: sender_id.clone(), @@ -147,6 +149,7 @@ impl Contract { deposit_accepted: U128(accept), } .emit(); + self.ensure_idle(); self.start_allocation(self.idle_balance); } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 02b9a084..d08cbe85 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -24,7 +24,7 @@ use near_sdk::{ use near_sdk_contract_tools::{ ft::{ nep141::GAS_FOR_FT_TRANSFER_CALL, nep145::Nep145ForceUnregister, ContractMetadata, - FungibleToken, Nep141Controller, Nep145 as _, Nep148Controller, + FungibleToken, Nep141Controller, Nep141Mint, Nep145 as _, Nep148Controller, }, Owner, Rbac, }; @@ -33,10 +33,11 @@ use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ - ext_self, require_at_least, AllocationMode, AllocationPlan, AllocationWeights, Error, - Event, MarketConfiguration, OpState, PendingValue, PendingWithdrawal, TimestampNs, - VaultConfiguration, AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_1_CHECK_GAS, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, + ext_self, require_at_least, AllocatingState, AllocationMode, AllocationPlan, + AllocationWeights, Error, Event, MarketConfiguration, OpState, PayoutState, PendingValue, + PendingWithdrawal, TimestampNs, VaultConfiguration, WithdrawingState, + AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, AFTER_SEND_TO_USER_GAS, + AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, }, }; @@ -110,9 +111,6 @@ pub struct Contract { mode: AllocationMode, plan: Option, - /// configuration per market (market ID -> MarketConfig) - markets: IterableMap, - /// Performance fee performance_fee: wad::Wad, fee_recipient: AccountId, @@ -124,8 +122,14 @@ pub struct Contract { virtual_shares: u128, virtual_assets: u128, + // FIXME: think about merging markets, pending cap and market_supply + /// configuration per market (market ID -> MarketConfig) + markets: IterableMap, /// Any pending change to the vault's cap, TODO: u256 pending_cap: IterableMap>, + /// vault's supplied principal per market (borrow-asset units) + market_supply: IterableMap, + /// Any pending change to the vault's timelock pending_timelock: Option>, /// Any pending change to the vault's guardian @@ -139,9 +143,6 @@ pub struct Contract { withdraw_queue: Vector, current_withdraw_inflight: Option, // id of the pending withdrawal being executed, if any - /// vault's supplied principal per market (borrow-asset units) - market_supply: IterableMap, - /// underlying held by vault idle_balance: u128, op_state: OpState, @@ -227,6 +228,7 @@ impl Contract { next_op_id: 1, mode, plan: None, + current_withdraw_inflight: None, // Pending withdrawals init pending_withdrawals: IterableMap::new(key!(PendingWithdrawals)), @@ -249,7 +251,7 @@ impl Contract { /// Sets the Curator account. Also grants/removes the Allocator role accordingly. pub fn set_curator(&mut self, account: AccountId) { Self::require_owner(); - Self::with_members_of(&Role::Curator, |members| { + Self::with_members_of_mut(&Role::Curator, |members| { require!( members.len() < 2, "Invariant violation: Cannot have more than one Curator" @@ -259,21 +261,16 @@ impl Contract { "Curator already set to this account" ); members.iter().for_each(|m| { - self.remove_role(&m, &Role::Curator); - self.remove_role(&m, &Role::Allocator); + self.set_is_allocator(m, false); }); + members.clear(); }); Self::add_role(self, &account, &Role::Curator); - Self::add_role(self, &account, &Role::Allocator); Event::CuratorSet { account: account.clone(), } .emit(); - Event::AllocatorRoleSet { - account, - allowed: true, - } - .emit(); + self.set_is_allocator(account, true); } /// Grants or revokes the Allocator role for `account`. @@ -305,10 +302,10 @@ impl Contract { "Guardian change already pending" ); if guardian_occupied { - let valid_at = env::block_timestamp() + self.timelock_ns; + let valid_at_ns = env::block_timestamp() + self.timelock_ns; self.pending_guardian = Some(PendingValue { value: new_g, - valid_at, + valid_at_ns, }); } else { Self::add_role(self, &new_g, &Role::Guardian); @@ -326,12 +323,10 @@ impl Contract { let p = self.pending_guardian.clone(); if let Some(p) = &p { - require!(env::block_timestamp() >= p.valid_at, "not yet"); - Self::with_members_of(&Role::Guardian, |members| { - members.iter().for_each(|m| { - self.remove_role(&m, &Role::Guardian); - }); - Self::add_role(self, &p.value, &Role::Guardian); + p.verify(); + Self::with_members_of_mut(&Role::Guardian, |members| { + members.clear(); + members.insert(&p.value); }); Event::GuardianSet { account: p.value.clone(), @@ -424,14 +419,14 @@ impl Contract { } .emit(); } else { - let valid_at = env::block_timestamp() + self.timelock_ns; + let valid_at_ns = env::block_timestamp() + self.timelock_ns; self.pending_timelock = Some(PendingValue { value: *tl, - valid_at, + valid_at_ns, }); Event::TimelockChangeSubmitted { new_ns: new_timelock_ns, - valid_at: valid_at.into(), + valid_at_ns: valid_at_ns.into(), } .emit(); } @@ -441,10 +436,8 @@ impl Contract { pub fn accept_timelock(&mut self) { Self::require_owner(); if let Some(p) = &self.pending_timelock { - require!( - env::block_timestamp() >= p.valid_at, - "Timelock not elapsed yet" - ); + p.verify(); + self.timelock_ns = p.value; Event::TimelockSet { seconds: p.value.into(), @@ -511,18 +504,18 @@ impl Contract { // If lowering the cap, we can apply the delta immediately config.cap = new_cap; } else { - let valid_at = env::block_timestamp() + self.timelock_ns; + let valid_at_ns = env::block_timestamp() + self.timelock_ns; self.pending_cap.insert( market.clone(), PendingValue { value: new_cap.0, - valid_at, + valid_at_ns, }, ); Event::SupplyCapRaiseSubmitted { market: market.clone(), new_cap, - valid_at, + valid_at_ns, } .emit(); } @@ -534,16 +527,14 @@ impl Contract { Self::assert_curator_or_owner(); self.ensure_idle(); - let (pending_value, pending_valid_at) = match self.pending_cap.get(&market) { - Some(p) => (p.value, p.valid_at), + let pending_value = match self.pending_cap.get(&market) { + Some(p) => { + p.verify(); + p.value + } None => env::panic_str("No pending cap change for this market"), }; - require!( - env::block_timestamp() >= pending_valid_at, - "Timelock not elapsed for cap change" - ); - let was_enabled = self.cfg(&market).enabled; let in_queue = self.in_withdraw_queue(&market); let before_principal = self.principal_of(&market); @@ -802,12 +793,14 @@ impl Contract { .pending_withdrawals .get(&id) .unwrap_or_else(|| env::panic_str("pending vanished unexpectedly")); + let owner = pending.owner.clone(); + let receiver = pending.receiver.clone(); self.current_withdraw_inflight = Some(id); env::log_str(&format!("WithdrawalExecutionStarted id={id}")); return self.start_withdraw( pending.expected_assets, - &pending.receiver, - &pending.owner, + &receiver, + &owner, pending.escrow_shares, ); } @@ -1272,16 +1265,6 @@ impl Contract { EscrowSettlement { to_burn, refund } } - /* ----- Internal: fee, shares ----- */ - pub fn mint_shares(&mut self, to: &AccountId, amount: u128) { - if amount == 0 { - return; - } - #[allow(clippy::expect_used, reason = "No side effects")] - self.deposit_unchecked(to, amount) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); - } - 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; @@ -1293,9 +1276,10 @@ impl Contract { ); if fee_shares > Number::zero() { let minted: u128 = fee_shares.into(); - self.mint_shares(&self.fee_recipient.clone(), minted); + let recipient = self.fee_recipient.clone(); + self.mint(&Nep141Mint::new(minted, &recipient)); Event::PerformanceFeeAccrued { - recipient: self.fee_recipient.clone(), + recipient, shares: U128(minted), } .emit(); @@ -1352,11 +1336,11 @@ impl Contract { let op_id = self.next_op_id; self.next_op_id += 1; - self.op_state = OpState::Allocating { + self.op_state = OpState::Allocating(AllocatingState { op_id, index: 0, remaining: amount, - }; + }); Event::AllocationStarted { op_id: op_id.into(), remaining: U128(amount), @@ -1441,11 +1425,11 @@ impl Contract { } .emit(); - self.op_state = OpState::Allocating { + self.op_state = OpState::Allocating(AllocatingState { op_id, index: index + 1, remaining, - }; + }); return self.step_allocation(); } @@ -1493,11 +1477,11 @@ impl Contract { } .emit(); - self.op_state = OpState::Allocating { + self.op_state = OpState::Allocating(AllocatingState { op_id, index: index + 1, remaining, - }; + }); return self.step_allocation(); } @@ -1509,11 +1493,11 @@ impl Contract { fn step_allocation(&mut self) -> PromiseOrValue<()> { let (op_id, index, remaining) = match &self.op_state { - OpState::Allocating { + OpState::Allocating(AllocatingState { op_id, index, remaining, - } => (*op_id, *index, *remaining), + }) => (*op_id, *index, *remaining), _ => return self.stop_and_exit(Some(&Error::NotAllocating)), }; @@ -1550,7 +1534,7 @@ impl Contract { self.pending_market_exec.clear(); - self.op_state = OpState::Withdrawing { + self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: Default::default(), remaining, @@ -1558,14 +1542,14 @@ impl Contract { collected, owner: owner.clone(), escrow_shares, - }; + }); self.step_withdraw() } fn step_withdraw(&mut self) -> PromiseOrValue<()> { let (op_id, index, remaining, receiver, collected, owner, escrow_shares) = match &self.op_state { - OpState::Withdrawing { + OpState::Withdrawing(WithdrawingState { op_id, index, remaining, @@ -1573,7 +1557,7 @@ impl Contract { collected, owner, escrow_shares, - } => ( + }) => ( *op_id, *index, *remaining, @@ -1586,14 +1570,14 @@ impl Contract { }; if remaining == 0 { - self.op_state = OpState::Payout { + self.op_state = OpState::Payout(PayoutState { op_id, receiver: receiver.clone(), amount: collected, owner: owner.clone(), escrow_shares, burn_shares: escrow_shares, - }; + }); return PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) @@ -1608,7 +1592,7 @@ impl Contract { let have = self.market_supply.get(market).unwrap_or(&0); let to_request = have.min(&remaining); if to_request == &0 { - self.op_state = OpState::Withdrawing { + self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: index + 1, remaining, @@ -1616,7 +1600,7 @@ impl Contract { collected, owner, escrow_shares, - }; + }); env::log_str(&format!( "Skipping withdrawal for market {market} (have {have}, remaining {remaining})" )); @@ -1665,14 +1649,14 @@ impl Contract { or_else: impl FnOnce(&mut Self) -> PromiseOrValue<()>, ) -> PromiseOrValue<()> { if collected > 0 { - self.op_state = OpState::Payout { + self.op_state = OpState::Payout(PayoutState { op_id, receiver: receiver.clone(), amount: collected, owner: owner.clone(), escrow_shares, burn_shares, - }; + }); PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index d5c8a3ca..ed0c6768 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -21,9 +21,12 @@ use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::mt::Nep245Receiver as _; use near_sdk_contract_tools::owner::OwnerExternal; use rstest::{fixture, rstest}; +use templar_common::vault::AllocatingState; use templar_common::vault::Error; use templar_common::vault::MarketConfiguration; use templar_common::vault::OpState; +use templar_common::vault::PayoutState; +use templar_common::vault::WithdrawingState; use templar_common::vault::{AllocationMode, DepositMsg}; #[fixture] @@ -194,18 +197,17 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e c.idle_balance = 1_000; // Partial payout scenario: collected/requested = 200/500 => burn 40% of escrowed shares - c.op_state = OpState::Payout { + c.op_state = OpState::Payout(PayoutState { op_id: 1, receiver: receiver.clone(), amount: 200, owner: owner.clone(), escrow_shares: 100, burn_shares: 40, // precomputed proportional burn for test - }; + }); let supply_before = c.total_supply(); - let ok = c.after_send_to_user(Ok(()), 1, receiver, U128(200)); - assert!(ok, "payout must report success"); + c.after_send_to_user(Ok(()), 1, receiver, U128(200)); // Idle decreased by payout assert_eq!(c.idle_balance, 800); // Only burn_shares are burned from total supply @@ -363,12 +365,12 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { c.withdraw_queue.push(m1.clone()); } // Force completion and exit op - if let crate::OpState::Allocating { op_id, index, .. } = c.op_state.clone() { - c.op_state = crate::OpState::Allocating { + if let crate::OpState::Allocating(AllocatingState { op_id, index, .. }) = c.op_state.clone() { + c.op_state = crate::OpState::Allocating(AllocatingState { op_id, index, remaining: 0, - }; + }); } else { panic!("expected Allocating state"); } @@ -659,7 +661,7 @@ fn set_withdraw_queue_disallow_pending_cap_removal() { m.clone(), templar_common::vault::PendingValue { value: 1, - valid_at: env::block_timestamp() + 1, + valid_at_ns: env::block_timestamp() + 1, }, ); @@ -1839,11 +1841,11 @@ fn after_supply_1_check_allocating_not_allocating_index() { let op_id = 1; let receiver = mk(7); - c.op_state = OpState::Allocating { + c.op_state = OpState::Allocating(AllocatingState { op_id, index: 0u32, remaining: 0u128, - }; + }); c.after_supply_1_check(Ok(U128(1)), op_id + 1, 0, Default::default()); @@ -1868,11 +1870,11 @@ fn after_supply_1_check_allocating() { let op_id = 1; let receiver = mk(7); - c.op_state = OpState::Allocating { + c.op_state = OpState::Allocating(AllocatingState { op_id, index: 0u32, remaining: 0u128, - }; + }); c.after_supply_1_check(Ok(U128(1)), op_id, 0, Default::default()); @@ -1890,17 +1892,16 @@ fn after_send_to_user_success_no_escrow() { let receiver = mk(7); c.idle_balance = 1_000; - c.op_state = OpState::Payout { + c.op_state = OpState::Payout(PayoutState { op_id: 1, receiver: receiver.clone(), amount: 200, owner: accounts(1), escrow_shares: 0, burn_shares: 0, - }; + }); - let ok = c.after_send_to_user(Ok(()), 1, receiver.clone(), U128(200)); - assert!(ok, "Payout should report success"); + c.after_send_to_user(Ok(()), 1, receiver.clone(), U128(200)); assert_eq!(c.idle_balance, 800, "Idle balance must decrease by payout"); assert!( matches!(c.op_state, OpState::Idle), @@ -1916,7 +1917,7 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { c.market_supply.insert(market.clone(), 100); // Withdrawing: need 60, already collected 10; expect position None => new_principal = 0, withdrawn = 100, credited = min(100, 60) = 60 - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 42, index: 0, remaining: 60, @@ -1924,7 +1925,7 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { collected: 10, owner: accounts(1), escrow_shares: 50, - }; + }); let res = c.after_exec_withdraw_read(Ok(None), 42, 0, U128(100), U128(60)); @@ -1946,7 +1947,7 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { // State should transition to Payout with amount = collected (10) + credited (60) = 70 match &c.op_state { - OpState::Payout { amount, .. } => { + OpState::Payout(PayoutState { amount, .. }) => { assert_eq!(*amount, 70, "Payout amount must match collected + credited"); } other => panic!("Unexpected state after read: {other:?}"), @@ -2004,16 +2005,15 @@ fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); } - c.idle_balance = idle; - c.op_state = OpState::Payout { + c.op_state = OpState::Payout(PayoutState { op_id: 1, receiver: receiver.clone(), amount, owner: owner.clone(), escrow_shares: escrow, burn_shares: escrow, - }; + }); let before = c.idle_balance; let ok = c.after_send_to_user( @@ -2022,7 +2022,6 @@ fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: receiver.clone(), U128(amount), ); - assert!(!ok, "Payout failure should return false"); assert_eq!( c.idle_balance, before, "idle_balance must stay the same on payout failure" @@ -2048,7 +2047,7 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { c.withdraw_queue.push(market.clone()); c.market_supply.insert(market.clone(), 100); - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 7, index: 0, remaining: need, @@ -2056,7 +2055,7 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { collected, owner: accounts(1), escrow_shares: 0, - }; + }); let res = c.after_create_withdraw_req(Err(near_sdk::PromiseError::Failed), 7, 0, U128(need)); match res { @@ -2065,7 +2064,7 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { } match &c.op_state { - OpState::Payout { amount, .. } => { + OpState::Payout(PayoutState { amount, .. }) => { assert_eq!(*amount, collected, "Payout amount must equal collected"); } other => panic!("Unexpected state: {other:?}"), @@ -2089,7 +2088,7 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect let initial_idle = c.idle_balance; - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 99, index: 0, remaining: need, @@ -2097,7 +2096,7 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect collected, owner: accounts(1), escrow_shares: 0, - }; + }); let res = c.after_exec_withdraw_read( Err(near_sdk::PromiseError::Failed), @@ -2122,7 +2121,7 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect ); match &c.op_state { - OpState::Payout { amount, .. } => { + OpState::Payout(PayoutState { amount, .. }) => { assert_eq!(*amount, collected, "Payout amount must equal collected"); } other => panic!("Unexpected state: {other:?}"), @@ -2146,7 +2145,7 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde let real_op = 5u64; let real_idx = 0u32; - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: real_op, index: real_idx, remaining: 1, @@ -2154,7 +2153,7 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde collected: 1, owner: accounts(1), escrow_shares: 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 }; @@ -2193,7 +2192,7 @@ fn refund_path_consistency() { c.withdraw_queue.push(market); // Withdrawing state with remaining=0 and collected=0 forces refund path - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 77, index: 0, remaining: 0, @@ -2201,7 +2200,7 @@ fn refund_path_consistency() { collected: 0, owner: owner.clone(), escrow_shares: 10, - }; + }); let supply_before = c.total_supply(); let vault_before = c.balance_of(&near_sdk::env::current_account_id()); @@ -2244,11 +2243,11 @@ fn ctx_allocating_ok_and_err() { setup_env(&vault_id, &vault_id, vec![]); let mut c = new_test_contract(&vault_id); - c.op_state = OpState::Allocating { + c.op_state = OpState::Allocating(AllocatingState { op_id: 42, index: 3, remaining: 77, - }; + }); let ok = c.ctx_allocating(42).expect("ctx_allocating should succeed"); assert_eq!(ok, (3, 77)); @@ -2266,7 +2265,7 @@ fn ctx_withdrawing_ok_and_err() { let recv = mk(1); let owner = accounts(1); - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 7, index: 1, remaining: 50, @@ -2274,7 +2273,7 @@ fn ctx_withdrawing_ok_and_err() { collected: 5, owner: owner.clone(), escrow_shares: 10, - }; + }); let (idx, rem, r, coll, o, escrow) = c .ctx_withdrawing(7) @@ -2342,11 +2341,11 @@ fn after_supply_2_read_missing_position_stops() { c.supply_queue.push(market); // Must be in Allocating ctx - c.op_state = OpState::Allocating { + c.op_state = OpState::Allocating(AllocatingState { op_id: 1, index: 0, remaining: 10, - }; + }); // Missing position -> stop_and_exit let res = c.after_supply_2_read(Ok(None), 1, 0, U128(0), U128(5), U128(5)); @@ -2368,11 +2367,11 @@ fn after_supply_2_read_read_failed_stops() { c.supply_queue.push(market); // Must be in Allocating ctx - c.op_state = OpState::Allocating { + c.op_state = OpState::Allocating(AllocatingState { op_id: 7, index: 0, remaining: 100, - }; + }); // Read failure -> stop_and_exit let res = c.after_supply_2_read( @@ -2400,7 +2399,7 @@ fn after_create_withdraw_req_success_returns_promise( c.withdraw_queue.push(market.clone()); c.market_supply.insert(market.clone(), 100); - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 21, index: 0, remaining: 60, @@ -2408,7 +2407,7 @@ fn after_create_withdraw_req_success_returns_promise( collected: 10, owner: owner.clone(), escrow_shares: 5, - }; + }); let res = c.after_create_withdraw_req(Ok(()), 21, 0, U128(60)); match res { @@ -2425,7 +2424,7 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { c.withdraw_queue.push(market.clone()); c.market_supply.insert(market.clone(), 10); - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 33, index: 0, remaining: 5, @@ -2433,14 +2432,17 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { collected: 0, owner: accounts(1), escrow_shares: 0, - }; + }); let res = c.after_exec_withdraw_req(33, 0, U128(5)); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to read supply position after exec"), } - assert!(matches!(c.op_state, OpState::Withdrawing { .. })); + assert!(matches!( + c.op_state, + OpState::Withdrawing(WithdrawingState { .. }) + )); } #[rstest] @@ -2455,8 +2457,7 @@ fn after_exec_withdraw_read_advances_when_remaining( c.withdraw_queue.push(m1.clone()); c.withdraw_queue.push(m2.clone()); c.market_supply.insert(m1.clone(), 10); - - c.op_state = OpState::Withdrawing { + c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 0, index: 0, remaining: 100, @@ -2464,7 +2465,7 @@ fn after_exec_withdraw_read_advances_when_remaining( collected: 0, owner: owner.clone(), escrow_shares: 0, - }; + }); // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 let res = c.after_exec_withdraw_read(Ok(None), 0, 0, U128(10), U128(100)); @@ -2478,14 +2479,14 @@ fn after_exec_withdraw_read_advances_when_remaining( // This works match &c.op_state { - OpState::Payout { + OpState::Payout(PayoutState { op_id, receiver: r, amount, owner: o, escrow_shares, burn_shares, - } => { + }) => { assert_eq!(*op_id, 0); assert_eq!(*amount, 10); assert_eq!(*escrow_shares, 0); @@ -2576,14 +2577,14 @@ fn stop_and_exit_payout_refunds_and_idle(mut c: Contract, owner: AccountId, rece .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); // Enter Payout with non-zero escrow - c.op_state = OpState::Payout { + c.op_state = OpState::Payout(PayoutState { op_id: 123, receiver: receiver.clone(), amount: 77, owner: owner.clone(), escrow_shares: escrow, burn_shares: escrow, - }; + }); let supply_before = c.total_supply(); let vault_before = c.balance_of(&near_sdk::env::current_account_id()); @@ -2615,14 +2616,14 @@ fn stop_and_exit_payout_zero_escrow_just_idle( receiver: AccountId, ) { // Enter Payout with zero escrow; no transfers should occur - c.op_state = OpState::Payout { + c.op_state = OpState::Payout(PayoutState { op_id: 7, receiver, amount: 1, owner: owner.clone(), escrow_shares: 0, burn_shares: 0, - }; + }); let supply_before = c.ft_total_supply(); let vault_before = c.ft_balance_of(near_sdk::env::current_account_id()); From edc4d45daedea15322fd8039806d85a3d0f64af2 Mon Sep 17 00:00:00 2001 From: Donovan Dall Date: Thu, 30 Oct 2025 13:14:17 +0000 Subject: [PATCH 096/121] chore: panic on top level receipts --- common/src/vault.rs | 4 -- contract/vault/src/impl_callbacks.rs | 27 +++++------- contract/vault/src/impl_token_receiver.rs | 33 +++++---------- contract/vault/src/wad.rs | 50 +++++++++++++---------- 4 files changed, 50 insertions(+), 64 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 31af397c..e8ff50f0 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -683,10 +683,6 @@ pub enum Event { token: AccountId, recipient: AccountId, }, - #[event_version("1.0.0")] - DepositRejectedWrongAsset { token: AccountId }, - #[event_version("1.0.0")] - DepositRejectedZeroAmount { sender: AccountId }, } #[cfg(test)] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 0ab99eea..c0e518b5 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -486,25 +486,18 @@ impl Contract { msg: Option<&T>, ) { let s: &AllocatingState = self.op_state.as_ref(); - match msg { - None => { - Event::AllocationCompleted { op_id: s.op_id }.emit(); - } - Some(m) => { - Event::AllocationStopped { - op_id: (s.op_id).into(), - index: s.index, - remaining: U128(s.remaining), - reason: Some(m.to_string()), - } - .emit(); + + msg.map_or(Event::AllocationCompleted { op_id: s.op_id }, |m| { + Event::AllocationStopped { + op_id: s.op_id.into(), + index: s.index, + remaining: U128(s.remaining), + reason: Some(m.to_string()), } - } + }) + .emit(); - // Always add back remaining to idle_balance - if s.remaining > 0 { - self.idle_balance = self.idle_balance.saturating_add(s.remaining); - } + self.idle_balance = self.idle_balance.saturating_add(s.remaining); self.plan = None; self.op_state = OpState::Idle; } diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 6b8a44ee..e76b74cf 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -80,13 +80,10 @@ impl Nep245Receiver for Contract { require_at_least(SUPPLY_GAS); let token_contract = env::predecessor_account_id(); - if !self.underlying_asset.is_nep245(&token_contract, token_id) { - Event::DepositRejectedWrongAsset { - token: token_contract, - } - .emit(); - return PromiseOrValue::Value(vec![amount]); - } + require!( + self.underlying_asset.is_nep245(&token_contract, token_id), + "Invalid token ID" + ); let refund = self.execute_supply(depositor.clone(), token_contract, amount.into()); @@ -104,21 +101,12 @@ impl Contract { deposit: u128, ) -> u128 { // Invariant: Only the underlying token is accepted; others are fully refunded - if asset_id != self.underlying_asset.contract_id() { - Event::DepositRejectedWrongAsset { - token: asset_id.clone(), - } - .emit(); - return deposit; - }; + require!( + asset_id == self.underlying_asset.contract_id(), + "Invalid token ID" + ); - if deposit == 0 { - Event::DepositRejectedZeroAmount { - sender: sender_id.clone(), - } - .emit(); - return 0; - } + require!(deposit > 0, "Deposit amount must be greater than zero"); self.internal_accrue_fee(); @@ -127,7 +115,8 @@ impl Contract { let refund = deposit - accept; let shares = self.preview_deposit(U128(accept)).0; - self.mint(&Nep141Mint::new(shares, &sender_id)); + self.mint(&Nep141Mint::new(shares, &sender_id)) + .expect("Failed to mint shares"); Event::MintedShares { amount: shares.into(), diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index a20c9f15..2cdb9745 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -13,23 +13,28 @@ pub struct Number(pub U256); impl Number { #[inline] - #[must_use] pub fn zero() -> Self { + #[must_use] + pub fn zero() -> Self { Number(U256::zero()) } #[inline] - #[must_use] pub fn one() -> Self { + #[must_use] + pub fn one() -> Self { Number(U256::one()) } #[inline] - #[must_use] pub fn is_zero(&self) -> bool { + #[must_use] + pub fn is_zero(&self) -> bool { self.0.is_zero() } #[inline] - #[must_use] pub fn is_one(&self) -> bool { + #[must_use] + pub fn is_one(&self) -> bool { self.0 == U256::one() } #[inline] - #[must_use] pub fn as_u128_trunc(self) -> u128 { + #[must_use] + pub fn as_u128_trunc(self) -> u128 { let mut b32 = [0u8; 32]; self.0.to_little_endian(&mut b32); let mut b16 = [0u8; 16]; @@ -43,11 +48,13 @@ impl Number { U256::from_little_endian(&b64[..32]) } #[inline] - #[must_use] pub fn saturating_add(self, other: Number) -> Number { + #[must_use] + pub fn saturating_add(self, other: Number) -> Number { Number(self.0.saturating_add(other.0)) } #[inline] - #[must_use] pub fn saturating_sub(self, other: Number) -> Number { + #[must_use] + pub fn saturating_sub(self, other: Number) -> Number { Number(self.0.saturating_sub(other.0)) } #[inline] @@ -157,6 +164,17 @@ impl BorshDeserialize for Number { } } +impl BorshSchema for Number { + fn add_definitions_recursively(definitions: &mut BTreeMap) { + let definition = Definition::Primitive(32); + add_definition(Self::declaration(), definition, definitions); + } + + fn declaration() -> Declaration { + "Number".into() + } +} + /// A 24-decimal fixed-point value (1e24 = 100%), backed by U256. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] pub struct Wad(pub Number); @@ -180,12 +198,14 @@ impl Wad { } #[inline] - #[must_use] pub fn is_zero(&self) -> bool { + #[must_use] + pub fn is_zero(&self) -> bool { self.0.is_zero() } #[inline] - #[must_use] pub fn is_one(&self) -> bool { + #[must_use] + pub fn is_one(&self) -> bool { self.0 .0 == U256::from(Self::SCALE) } @@ -253,18 +273,6 @@ impl BorshDeserialize for Wad { } } -// FIXME: test these -impl BorshSchema for Number { - fn add_definitions_recursively(definitions: &mut BTreeMap) { - let definition = Definition::Primitive(32); - add_definition(Self::declaration(), definition, definitions); - } - - fn declaration() -> Declaration { - "Number".into() - } -} - impl BorshSchema for Wad { fn add_definitions_recursively(definitions: &mut BTreeMap) { let definition = Definition::Primitive(32); From 714926eaffdc5515662552fe624637ba12d96b6f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Sat, 1 Nov 2025 14:11:56 +0000 Subject: [PATCH 097/121] chore: name fee wad --- contract/vault/src/lib.rs | 2 +- contract/vault/src/wad.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index d08cbe85..841f9620 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -385,7 +385,7 @@ impl Contract { fee_wad != self.performance_fee, "Fee already set to this value" ); - require!(fee_wad <= (wad::Wad::one() / 10), "fee too high"); + require!(fee_wad <= Wad::from(MAX_FEE_WAD), "fee too high"); // Accrue any pending fees with old rate before changing self.internal_accrue_fee(); diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index 2cdb9745..f4bc1528 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -175,6 +175,9 @@ impl BorshSchema for Number { } } +/// Represents the maximum performance fee that can be charged. 20% (very high) +pub const MAX_FEE_WAD: u128 = Wad::SCALE / 10 * 2; + /// A 24-decimal fixed-point value (1e24 = 100%), backed by U256. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] pub struct Wad(pub Number); @@ -194,7 +197,7 @@ impl Wad { #[inline] #[must_use] pub fn one() -> Self { - Wad(Number(U256::from(Self::SCALE))) + Wad::from(Self::SCALE) } #[inline] From 6caf49f645583075bf05e8ad92b47631074e8e85 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Sat, 1 Nov 2025 14:27:15 +0000 Subject: [PATCH 098/121] feat: impl deser for wad --- contract/vault/Cargo.toml | 1 + contract/vault/src/lib.rs | 15 +++++---------- contract/vault/src/tests.rs | 5 +++-- contract/vault/src/wad.rs | 17 ++++++++++------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/contract/vault/Cargo.toml b/contract/vault/Cargo.toml index 7c4bbf62..91366206 100644 --- a/contract/vault/Cargo.toml +++ b/contract/vault/Cargo.toml @@ -34,6 +34,7 @@ near-sdk-contract-tools.workspace = true near-contract-standards.workspace = true templar-common.workspace = true itertools.workspace = true +primitive-types = { version = "0.14.0", features = ["serde"] } [dev-dependencies] near-sdk = { workspace = true, features = ["unit-testing"] } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 841f9620..ce89b226 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -376,22 +376,17 @@ impl Contract { } /// Sets the performance fee as a WAD fraction (1e24 = 100%). Accrues fees at the old rate first. - pub fn set_performance_fee(&mut self, fee: U128) { + pub fn set_performance_fee(&mut self, fee: Wad) { Self::require_owner(); - let fee_wad = wad::Wad::from(fee.0); - - require!( - fee_wad != self.performance_fee, - "Fee already set to this value" - ); - require!(fee_wad <= Wad::from(MAX_FEE_WAD), "fee too high"); + require!(fee != self.performance_fee, "Fee already set to this value"); + require!(fee <= Wad::from(MAX_FEE_WAD), "fee too high"); // Accrue any pending fees with old rate before changing self.internal_accrue_fee(); - self.performance_fee = fee_wad; + self.performance_fee = fee; Event::PerformanceFeeSet { - fee: U128(u128::from(fee_wad)), + fee: U128(u128::from(fee)), } .emit(); } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index ed0c6768..caf99152 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -9,6 +9,7 @@ use crate::storage_management::yocto_for_pending_cap; use crate::test_utils::*; use crate::wad::compute_fee_shares; use crate::Contract; +use crate::Wad; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver as _; use near_sdk::env; use near_sdk::serde_json; @@ -994,7 +995,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates() { let recipient = c.fee_recipient.clone(); let bal_before = c.balance_of(&recipient); - c.set_performance_fee(U128(u128::from(crate::wad::Wad::one() / 100))); + c.set_performance_fee(crate::wad::Wad::one() / 100); assert_eq!( c.balance_of(&recipient), @@ -1047,7 +1048,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(U128(u128::from(crate::wad::Wad::one() / 200))); // 0.5% + c.set_performance_fee(Wad::one() / 200); // 0.5% assert_eq!( c.balance_of(&recipient), diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index f4bc1528..b9961d55 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -4,11 +4,13 @@ use std::ops::{Add, Sub}; use near_sdk::borsh::schema::{add_definition, Declaration, Definition}; use near_sdk::borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; -use templar_common::primitive_types::{U256, U512}; +use near_sdk::serde::{Deserialize, Serialize}; +use primitive_types::{U256, U512}; pub type WIDE = U512; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] pub struct Number(pub U256); impl Number { @@ -36,7 +38,7 @@ impl Number { #[must_use] pub fn as_u128_trunc(self) -> u128 { let mut b32 = [0u8; 32]; - self.0.to_little_endian(&mut b32); + self.0.write_as_little_endian(&mut b32); let mut b16 = [0u8; 16]; b16.copy_from_slice(&b32[..16]); u128::from_le_bytes(b16) @@ -44,7 +46,7 @@ impl Number { #[inline] fn as_u256_trunc(q: U512) -> U256 { let mut b64 = [0u8; 64]; - q.to_little_endian(&mut b64); + q.write_as_little_endian(&mut b64); U256::from_little_endian(&b64[..32]) } #[inline] @@ -150,7 +152,7 @@ impl BorshSerialize for Number { #[inline] fn serialize(&self, writer: &mut W) -> std::io::Result<()> { let mut b32 = [0u8; 32]; - self.0.to_little_endian(&mut b32); + self.0.write_as_little_endian(&mut b32); writer.write_all(&b32) } } @@ -179,7 +181,8 @@ impl BorshSchema for Number { pub const MAX_FEE_WAD: u128 = Wad::SCALE / 10 * 2; /// A 24-decimal fixed-point value (1e24 = 100%), backed by U256. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde", transparent)] pub struct Wad(pub Number); impl Wad { @@ -264,7 +267,7 @@ impl Div for Wad { impl BorshSerialize for Wad { #[inline] fn serialize(&self, writer: &mut W) -> std::io::Result<()> { - self.0.serialize(writer) + BorshSerialize::serialize(&self.0, writer) } } From f2b26a9286aa9e733078923640e90ced0307b370 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Sat, 1 Nov 2025 15:43:17 +0000 Subject: [PATCH 099/121] refactor!: simplify adjacent structures --- common/src/vault.rs | 8 +- contract/vault/src/aum.rs | 4 +- contract/vault/src/impl_callbacks.rs | 41 +-- contract/vault/src/lib.rs | 176 +++++++------ contract/vault/src/test_utils.rs | 16 +- contract/vault/src/tests.rs | 357 +++++++++++++++++++-------- 6 files changed, 387 insertions(+), 215 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index e8ff50f0..94025787 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -55,7 +55,7 @@ pub enum DepositMsg { } /// Confrete configuration for a market. -#[derive(Clone, Default)] +#[derive(Clone, Default, Debug)] #[near] pub struct MarketConfiguration { /// Supply cap for this market (in underlying asset units) @@ -224,15 +224,15 @@ pub trait Callbacks { fn after_skim_balance(&mut self, token: AccountId, recipient: AccountId) -> bool; } -#[derive(Clone)] +#[derive(Clone, Debug)] #[near] -pub struct PendingValue { +pub struct PendingValue { pub value: T, // Timestamp when this pending value can be finalized pub valid_at_ns: TimestampNs, } -impl PendingValue { +impl PendingValue { pub fn verify(&self) { require!( near_sdk::env::block_timestamp() >= self.valid_at_ns, diff --git a/contract/vault/src/aum.rs b/contract/vault/src/aum.rs index 1226a33a..938583d3 100644 --- a/contract/vault/src/aum.rs +++ b/contract/vault/src/aum.rs @@ -167,9 +167,9 @@ impl AUM { }) } AUM::BalanceSheet => c - .market_supply + .markets .iter() - .fold(c.idle_balance, |prev, (_, p)| prev.saturating_add(*p)), + .fold(c.idle_balance, |prev, (_, rec)| prev.saturating_add(rec.principal)), }) } diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index c0e518b5..286a7912 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -58,7 +58,7 @@ impl Contract { self.stop_and_exit(Some(&Error::MarketTransferFailed)) } Ok(accepted) => { - let before = self.market_supply.get(&market).unwrap_or(&0); + let before = self.principal_of(market); PromiseOrValue::Promise( ext_market::ext(market.clone()) @@ -71,7 +71,7 @@ impl Contract { .after_supply_2_read( op_id, market_index, - U128(*before), + U128(before), attempted, accepted, ), @@ -103,7 +103,8 @@ impl Contract { let market = match self.resolve_supply_market(market_index) { Ok(m) => m, Err(e) => return self.stop_and_exit(Some(&e)), - }; + } + .clone(); let SupplyReconciliation { new_principal, @@ -153,7 +154,9 @@ impl Contract { } .emit(); - self.market_supply.insert(market.clone(), new_principal); + if let Some(rec) = self.markets.get_mut(&market) { + rec.principal = new_principal; + } // Invariant: withdraw_queue gains any market with new_principal > 0 if new_principal > 0 { @@ -256,7 +259,7 @@ impl Contract { }; // Verify actual withdrawal by reading market position after execution - let before = *self.market_supply.get(&market).unwrap_or(&0); + let before = self.principal_of(market); PromiseOrValue::Promise( ext_market::ext(market.clone()) .with_static_gas(GET_SUPPLY_POSITION_GAS) @@ -335,7 +338,9 @@ impl Contract { collected_ctx, ); - self.market_supply.insert(market.clone(), new_principal); + if let Some(rec) = self.markets.get_mut(&market.clone()) { + rec.principal = new_principal; + } if idle_delta > 0 { self.idle_balance = self.idle_balance.saturating_add(idle_delta); } @@ -610,24 +615,22 @@ impl Contract { } /// Resolve a market for allocation by plan (if present) or `supply_queue` - pub(crate) fn resolve_supply_market(&self, market_index: u32) -> Result { - if let Some(plan) = &self.plan { - if let Some((m, _)) = plan.get(market_index as usize) { - return Ok(m.clone()); - } - return Err(Error::MissingMarket(market_index)); - } - self.supply_queue - .get(market_index) - .cloned() + pub(crate) fn resolve_supply_market(&self, market_index: u32) -> Result<&AccountId, Error> { + self.plan + .as_ref() + .and_then(|plan| { + plan.get(market_index as usize) + .map(|(m, _)| m) + .or(self.supply_queue.iter().nth(market_index as usize)) + }) .ok_or(Error::MissingMarket(market_index)) } /// Resolve a market for withdraw by `withdraw_queue` - pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result { + pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result<&AccountId, Error> { self.withdraw_queue - .get(market_index) - .cloned() + .iter() + .nth(market_index as usize) .ok_or(Error::MissingMarket(market_index)) } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index ce89b226..813735ef 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::{HashMap, HashSet}, + collections::{BTreeSet, HashMap, HashSet}, num::NonZeroU8, }; @@ -59,8 +59,6 @@ mod test_utils; pub enum StorageKey { Config, PendingCaps, - SupplyQueue, - WithdrawQueue, MarketSupply, PendingWithdrawals, } @@ -80,6 +78,24 @@ pub enum Role { Allocator, } +#[near(serializers = [borsh])] +#[derive(Debug, Clone, Default)] +pub struct MarketRecord { + pub cfg: MarketConfiguration, + pub pending_cap: Option>, + pub principal: u128, +} + +impl From for MarketRecord { + fn from(cfg: MarketConfiguration) -> Self { + Self { + cfg, + pending_cap: None, + principal: 0, + } + } +} + #[derive(PanicOnDefault, FungibleToken, Owner, Rbac)] #[fungible_token(force_unregister_hook = "Self")] #[rbac(roles = "Role", crate = "crate")] @@ -122,13 +138,8 @@ pub struct Contract { virtual_shares: u128, virtual_assets: u128, - // FIXME: think about merging markets, pending cap and market_supply - /// configuration per market (market ID -> MarketConfig) - markets: IterableMap, - /// Any pending change to the vault's cap, TODO: u256 - pending_cap: IterableMap>, - /// vault's supplied principal per market (borrow-asset units) - market_supply: IterableMap, + // Merged market record: cfg + pending_cap + principal + markets: IterableMap, /// Any pending change to the vault's timelock pending_timelock: Option>, @@ -138,9 +149,10 @@ pub struct Contract { timelock_ns: TimestampNs, /// Ordered list of market IDs for deposit allocation - supply_queue: Vector, - /// Ordered list of market IDs for withdrawal prioritytr - withdraw_queue: Vector, + supply_queue: BTreeSet, + /// Ordered list of market IDs for withdrawal priority + withdraw_queue: BTreeSet, + current_withdraw_inflight: Option, // id of the pending withdrawal being executed, if any /// underlying held by vault @@ -214,12 +226,10 @@ impl Contract { fee_recipient, skim_recipient, markets: IterableMap::new(key!(Config)), - pending_cap: IterableMap::new(key!(PendingCaps)), pending_timelock: None, pending_guardian: None, - supply_queue: Vector::new(key!(SupplyQueue)), - withdraw_queue: Vector::new(key!(WithdrawQueue)), - market_supply: IterableMap::new(key!(MarketSupply)), + supply_queue: Default::default(), + withdraw_queue: Default::default(), last_total_assets: 0, virtual_shares: 1, virtual_assets: 1, @@ -463,30 +473,30 @@ impl Contract { if self.markets.get(&market).is_none() { required_deposit = required_deposit.saturating_add(yocto_for_new_market()); } - let current_cap = self.markets.get(&market).map_or(0, |c| c.cap.0); + let current_cap = self.markets.get(&market).map_or(0, |r| r.cfg.cap.0); if new_cap.0 > current_cap { required_deposit = required_deposit.saturating_add(yocto_for_pending_cap()); } require_attached_at_least(required_deposit, "submit_cap"); require!( - self.pending_cap.get(&market).is_none(), + self.markets + .get(&market) + .and_then(|r| r.pending_cap.as_ref()) + .is_none(), "Policy violation: A cap change is already pending for this market" ); let config = match self.markets.get_mut(&market) { None => { - self.markets - .insert(market.clone(), MarketConfiguration::default()); + self.markets.insert(market.clone(), MarketRecord::default()); Event::MarketCreated { market: market.clone(), } .emit(); - // Pre-allocate a market_supply record (principal=0) so allocations don't create storage later - self.market_supply.insert(market.clone(), 0); self.cfg_mut(&market) } - Some(config) => config, + Some(config) => &mut config.cfg, }; require!( @@ -500,13 +510,12 @@ impl Contract { config.cap = new_cap; } else { let valid_at_ns = env::block_timestamp() + self.timelock_ns; - self.pending_cap.insert( - market.clone(), - PendingValue { + if let Some(rec) = self.markets.get_mut(&market) { + rec.pending_cap = Some(PendingValue { value: new_cap.0, valid_at_ns, - }, - ); + }); + } Event::SupplyCapRaiseSubmitted { market: market.clone(), new_cap, @@ -522,11 +531,14 @@ impl Contract { Self::assert_curator_or_owner(); self.ensure_idle(); - let pending_value = match self.pending_cap.get(&market) { - Some(p) => { - p.verify(); - p.value - } + let pending_value = match self.markets.get(&market) { + Some(rec) => match rec.pending_cap.as_ref() { + Some(p) => { + p.verify(); + p.value + } + None => env::panic_str("No pending cap change for this market"), + }, None => env::panic_str("No pending cap change for this market"), }; @@ -534,16 +546,18 @@ impl Contract { let in_queue = self.in_withdraw_queue(&market); let before_principal = self.principal_of(&market); - let cfg = self.cfg_mut(&market); - cfg.cap = pending_value.into(); + let rec = self + .markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("Config not found")); + rec.cfg.cap = pending_value.into(); if pending_value > 0 { - if !cfg.enabled { - cfg.enabled = true; + if !rec.cfg.enabled { + rec.cfg.enabled = true; } - cfg.removable_at = 0; + rec.cfg.removable_at = 0; } - // If we just enabled the market, ensure it's in the withdraw queue if pending_value > 0 && !was_enabled { Event::MarketEnabled { market: market.clone(), @@ -570,19 +584,19 @@ impl Contract { } .emit(); - // Finally, clear the pending cap record - self.pending_cap.remove(&market); + self.markets.get_mut(&market).unwrap().pending_cap = None; } /// Revokes any pending cap change for `market`. pub fn revoke_pending_cap(&mut self, market: AccountId) { Self::assert_curator_or_owner(); - if self.pending_cap.get(&market).is_some() { - self.pending_cap.remove(&market); - Event::SupplyCapRaiseRevoked { - market: market.clone(), + if let Some(rec) = self.markets.get_mut(&market) { + if rec.pending_cap.take().is_some() { + Event::SupplyCapRaiseRevoked { + market: market.clone(), + } + .emit(); } - .emit(); } } @@ -595,34 +609,35 @@ impl Contract { /// Requires cap == 0 and no pending cap changes; starts a timelock. pub fn submit_market_removal(&mut self, market: AccountId) { Self::assert_curator_or_owner(); - let cfg = self + let rec = self .markets .get_mut(&market) .unwrap_or_else(|| env::panic_str(&format!("Unknown market: {market}"))); require!( - cfg.removable_at == 0, + rec.cfg.removable_at == 0, "Removal already pending for this market" ); require!( - cfg.cap.0 == 0, + rec.cfg.cap.0 == 0, "Cannot remove market with non-zero cap (disable deposits first)" ); - require!(cfg.enabled, "Market not enabled or already removed"); + require!(rec.cfg.enabled, "Market not enabled or already removed"); require!( - self.pending_cap.get(&market).is_none(), + rec.pending_cap.is_none(), "Cap change pending for this market" ); - cfg.removable_at = env::block_timestamp() + self.timelock_ns; + rec.cfg.removable_at = env::block_timestamp() + self.timelock_ns; Event::MarketRemovalSubmitted { market: market.clone(), - removable_at: cfg.removable_at.into(), + removable_at: rec.cfg.removable_at.into(), } .emit(); } + /// Revokes a pending market removal for `market`. pub fn revoke_pending_market_removal(&mut self, market: AccountId) { Self::assert_curator_or_owner(); - if let Some(cfg) = self.markets.get_mut(&market) { + if let Some(cfg) = self.markets.get_mut(&market).map(|c| &mut c.cfg) { cfg.removable_at = 0; } Event::MarketRemovalRevoked { market }.emit(); @@ -645,7 +660,7 @@ impl Contract { } // Validate all markets are authorized (cap > 0) before charging storage for m in &markets { - let cap = self.markets.get(m).map_or(0, |c| c.cap.into()); + let cap = self.markets.get(m).map_or(0, |r| r.cfg.cap.into()); require!(cap > 0, "unauthorized market"); } @@ -657,7 +672,7 @@ impl Contract { self.supply_queue.clear(); for m in &markets { - self.supply_queue.push(m.clone()); + self.supply_queue.insert(m.clone()); } } @@ -695,20 +710,20 @@ impl Contract { ); } - for (id, cfg) in self.markets.iter() { - let has_supply = *self.market_supply.get(id).unwrap_or(&0) > 0; - if (cfg.enabled || has_supply) && !seen.contains(id) { + for (id, rec) in self.markets.iter() { + let has_supply = rec.principal > 0; + if (rec.cfg.enabled || has_supply) && !seen.contains(id) { if current.contains(id) { // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. require!( - cfg.cap.0 == 0, + rec.cfg.cap.0 == 0, "Policy violation: Cannot remove market with non-zero cap" ); require!( - self.pending_cap.get(id).is_none(), + rec.pending_cap.is_none(), "Policy violation: Cannot remove market with pending cap change" ); - self.aum.policy_removal(cfg, &has_supply); + self.aum.policy_removal(&rec.cfg, &has_supply); } else { // Not in current queue: must be included if enabled or holding. env::panic_str( @@ -725,7 +740,7 @@ impl Contract { self.withdraw_queue.clear(); for id in &queue { - self.withdraw_queue.push(id.clone()); + self.withdraw_queue.insert(id.clone()); } Event::WithdrawQueueUpdated { markets: queue.clone(), @@ -1028,10 +1043,7 @@ impl Contract { .supply_queue .iter() .fold(0u128, |acc, m| match self.markets.get(m) { - Some(cfg) if cfg.cap.0 > 0 => { - let cur = *self.market_supply.get(m).unwrap_or(&0); - acc + cfg.cap.0.saturating_sub(cur) - } + Some(rec) if rec.cfg.cap.0 > 0 => acc + rec.cfg.cap.0.saturating_sub(rec.principal), _ => acc, }); U128(total) @@ -1109,24 +1121,28 @@ impl From for (u128, u128) { /* ----- Private Helpers ----- */ impl Contract { fn cfg_mut(&mut self, id: &AccountId) -> &mut MarketConfiguration { - self.markets + &mut self + .markets .get_mut(id) .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {id}"))) + .cfg } fn cfg(&self, id: &AccountId) -> &MarketConfiguration { - self.markets + &self + .markets .get(id) .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {id}"))) + .cfg } // Principal (vault-supplied) units currently recorded for a market fn principal_of(&self, market: &AccountId) -> u128 { - *self.market_supply.get(market).unwrap_or(&0) + self.markets.get(market).map_or(0, |r| r.principal) } fn cap_of(&self, market: &AccountId) -> u128 { - self.markets.get(market).map_or(0, |c| c.cap.0) + self.markets.get(market).map_or(0, |r| r.cfg.cap.0) } // Remaining room until cap for a market @@ -1152,7 +1168,7 @@ impl Contract { .emit(); return; } - self.withdraw_queue.push(market.clone()); + self.withdraw_queue.insert(market.clone()); Event::WithdrawQueueMarketAdded { market: market.clone(), } @@ -1445,7 +1461,7 @@ impl Contract { index: u32, remaining: u128, ) -> PromiseOrValue<()> { - if let Some(market) = self.supply_queue.get(index) { + if let Some(market) = self.supply_queue.iter().nth(index as usize) { let room = self.room_of(market); let to_supply = room.min(remaining); @@ -1583,10 +1599,10 @@ impl Contract { ), ); } - if let Some(market) = self.withdraw_queue.get(index) { - let have = self.market_supply.get(market).unwrap_or(&0); - let to_request = have.min(&remaining); - if to_request == &0 { + if let Some(market) = self.withdraw_queue.iter().nth(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, @@ -1604,11 +1620,11 @@ impl Contract { 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))) + .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(to_request))) .then( ext_self::ext(env::current_account_id()) .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) - .after_create_withdraw_req(op_id, index, U128(*to_request)), + .after_create_withdraw_req(op_id, index, U128(to_request)), ), ) } else { diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 5d3a462f..b2109221 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -86,15 +86,19 @@ pub fn ensure_market( cfg.cap = near_sdk::json_types::U128(cap); cfg.enabled = enabled; cfg.removable_at = removable_at; - c.markets.insert(id.clone(), cfg); - if supply > 0 { - c.market_supply.insert(id.clone(), supply); - } + c.markets.insert( + id.clone(), + crate::MarketRecord { + cfg, + pending_cap: None, + principal: supply, + }, + ); if in_withdraw && !c.withdraw_queue.iter().any(|m| m == &id) { - c.withdraw_queue.push(id.clone()); + c.withdraw_queue.insert(id.clone()); } if in_supply && !c.supply_queue.iter().any(|m| m == &id) { - c.supply_queue.push(id.clone()); + c.supply_queue.insert(id.clone()); } } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index caf99152..7bd84a14 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -9,6 +9,7 @@ use crate::storage_management::yocto_for_pending_cap; use crate::test_utils::*; use crate::wad::compute_fee_shares; use crate::Contract; +use crate::MarketRecord; use crate::Wad; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver as _; use near_sdk::env; @@ -293,9 +294,34 @@ fn set_withdraw_queue_must_include_all_holding() { let m2 = mk(104); // Both known; m1 has supply > 0 - c.markets.insert(m1.clone(), MarketConfiguration::default()); - c.markets.insert(m2.clone(), MarketConfiguration::default()); - c.market_supply.insert(m1.clone(), 10); + c.markets.insert( + m1.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 0, + }, + ); + c.markets.insert( + m2.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 0, + }, + ); + if let Some(rec) = c.markets.get_mut(&m1) { + rec.principal = 10; + } else { + c.markets.insert( + m1.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 10, + }, + ); + } // Missing m1 should panic c.set_withdraw_queue(vec![m2]); @@ -333,8 +359,9 @@ fn set_withdraw_queue_must_include_all_enabled() { // m1 enabled, m2 disabled; provide both configs let mut cfg1 = MarketConfiguration::default(); cfg1.enabled = true; - c.markets.insert(m1.clone(), cfg1); - c.markets.insert(m2.clone(), MarketConfiguration::default()); + c.markets.insert(m1.clone(), cfg1.into()); + c.markets + .insert(m2.clone(), MarketConfiguration::default().into()); // Missing m1 should panic c.set_withdraw_queue(vec![m2]); @@ -349,8 +376,15 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(80); cfg.enabled = true; - c.markets.insert(m1.clone(), cfg); - c.supply_queue.push(m1.clone()); + c.markets.insert( + m1.clone(), + MarketRecord { + cfg, + pending_cap: None, + principal: 0, + }, + ); + c.supply_queue.insert(m1.clone()); // Idle = 100, so max_room (80) should clamp allocation c.idle_balance = 100; @@ -361,9 +395,20 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { c.start_allocation(total); // Emulate allocation completing successfully: 80 moved to market - c.market_supply.insert(m1.clone(), 80); + if let Some(rec) = c.markets.get_mut(&m1) { + rec.principal = 80; + } else { + c.markets.insert( + m1.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 80, + }, + ); + } if !c.withdraw_queue.iter().any(|x| x == &m1) { - c.withdraw_queue.push(m1.clone()); + c.withdraw_queue.insert(m1.clone()); } // Force completion and exit op if let crate::OpState::Allocating(AllocatingState { op_id, index, .. }) = c.op_state.clone() { @@ -409,9 +454,9 @@ fn queue_allocation_ignores_stale_plan() { let mut cfg1 = MarketConfiguration::default(); cfg1.cap = U128(10); cfg1.enabled = true; - c.markets.insert(m1.clone(), cfg1); - c.withdraw_queue.push(m1.clone()); - c.supply_queue.push(m1); + c.markets.insert(m1.clone(), cfg1.into()); + c.withdraw_queue.insert(m1.clone()); + c.supply_queue.insert(m1); // Stale plan (should be ignored for queue-based allocation) c.plan = Some(vec![(m2.clone(), 1u128)]); @@ -445,13 +490,18 @@ fn set_withdraw_queue_disallow_nonzero_position_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(0); // required precondition to attempt removal cfg.enabled = true; - c.markets.insert(m1.clone(), cfg); - - // Market has non-zero position but no removal scheduled - c.market_supply.insert(m1.clone(), 1); + c.markets.insert( + m1.clone(), + MarketRecord { + cfg, + pending_cap: None, + // Market has non-zero position but no removal scheduled + principal: 1, + }, + ); // Present in current withdraw queue so removal logic executes - c.withdraw_queue.push(m1); + c.withdraw_queue.insert(m1); // Attempting to remove should panic due to non-zero position without removal schedule c.set_withdraw_queue(vec![]); @@ -520,11 +570,17 @@ fn removing_holding_market_keeps_config_and_supply_on_writedown() { cfg.cap = U128(0); cfg.enabled = true; cfg.removable_at = 1; // scheduled in the past relative to the block timestamp we set below - c.markets.insert(m.clone(), cfg); - c.market_supply.insert(m.clone(), 10); + c.markets.insert( + m.clone(), + MarketRecord { + cfg, + pending_cap: None, + principal: 10, + }, + ); // Present in current withdraw queue - c.withdraw_queue.push(m.clone()); + c.withdraw_queue.insert(m.clone()); // Advance block timestamp so timelock precondition passes set_block_ts(&vault_id, &owner, 2); @@ -535,7 +591,7 @@ fn removing_holding_market_keeps_config_and_supply_on_writedown() { // Markets removed from queue but the config still exists and the supply assert!(c.markets.get(&m).is_some(), "Config should still exist"); assert_eq!( - *c.market_supply.get(&m).unwrap_or(&0), + c.markets.get(&m).map(|s| s.principal).unwrap_or_default(), 10, "Principal remains in market_supply" ); @@ -562,11 +618,18 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(10); cfg.enabled = true; - c.markets.insert(m.clone(), cfg); + c.markets.insert( + m.clone(), + MarketRecord { + cfg, + pending_cap: None, + principal: 0, + }, + ); // 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"); + 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"); assert!(cfg_after.enabled, "enabled must remain true when cap is 0"); @@ -575,7 +638,7 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { // 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.removable_at > 0, "removal must be scheduled"); + assert!(cfg_after2.cfg.removable_at > 0, "removal must be scheduled"); } #[test] fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { @@ -588,7 +651,14 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { let m = mk(8002); // Start disabled with cap=0 - c.markets.insert(m.clone(), MarketConfiguration::default()); + c.markets.insert( + m.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 0, + }, + ); // Submit raise -> pending let raise = 5u128; @@ -604,7 +674,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { ); c.accept_cap(m.clone()); - let cfg1 = c.markets.get(&m).unwrap(); + let cfg1 = &c.markets.get(&m).unwrap().cfg; assert_eq!(cfg1.cap.0, raise); assert!(cfg1.enabled, "market should be enabled after raise"); assert!( @@ -614,7 +684,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { // Now lower back to 0 (immediate path) and ensure enabled stays true c.submit_cap(m.clone(), U128(0)); - let cfg2 = c.markets.get(&m).unwrap(); + let cfg2 = &c.markets.get(&m).unwrap().cfg; assert_eq!(cfg2.cap.0, 0); assert!(cfg2.enabled, "enabled must remain true on cap=0"); } @@ -635,8 +705,8 @@ fn set_withdraw_queue_disallow_nonzero_cap_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); // non-zero cap cfg.enabled = true; // must be enabled or holding to trigger invariant - c.markets.insert(m.clone(), cfg); - c.withdraw_queue.push(m.clone()); + c.markets.insert(m.clone(), cfg.into()); + c.withdraw_queue.insert(m.clone()); // Attempt to remove from queue should panic due to non-zero cap c.set_withdraw_queue(vec![]); @@ -654,17 +724,21 @@ fn set_withdraw_queue_disallow_pending_cap_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(0); cfg.enabled = true; - c.markets.insert(m.clone(), cfg); - c.withdraw_queue.push(m.clone()); - - // Insert a pending cap change - c.pending_cap.insert( + c.markets.insert( m.clone(), - templar_common::vault::PendingValue { - value: 1, - valid_at_ns: env::block_timestamp() + 1, + MarketRecord { + cfg, + pending_cap: None, + principal: 0, }, ); + c.withdraw_queue.insert(m.clone()); + + // Insert a pending cap change + c.markets.get_mut(&m).unwrap().pending_cap = Some(templar_common::vault::PendingValue { + value: 1, + valid_at_ns: env::block_timestamp() + 1, + }); // Attempt to remove from queue should panic due to pending cap change c.set_withdraw_queue(vec![]); @@ -683,9 +757,18 @@ fn set_withdraw_queue_disallow_timelock_not_elapsed() { cfg.cap = U128(0); cfg.enabled = true; cfg.removable_at = 10; // in the future relative to block timestamp we set below - c.markets.insert(m.clone(), cfg); - c.market_supply.insert(m.clone(), 1); // non-zero supply enforces timelock path - c.withdraw_queue.push(m.clone()); + c.markets.insert( + m.clone(), + MarketRecord { + cfg, + pending_cap: None, + principal: 0, + }, + ); + if let Some(rec) = c.markets.get_mut(&m) { + rec.principal = 1; // non-zero supply enforces timelock path + } + c.withdraw_queue.insert(m.clone()); // Set block timestamp below removable_at so timelock has not elapsed set_block_ts(&vault_id, &owner, 5); @@ -710,8 +793,8 @@ fn set_withdraw_queue_allows_zero_supply_removal() { cfg.cap = U128(0); cfg.enabled = true; // removable_at irrelevant when supply is zero - c.markets.insert(m.clone(), cfg); - c.withdraw_queue.push(m.clone()); + c.markets.insert(m.clone(), cfg.into()); + c.withdraw_queue.insert(m.clone()); // Supply is zero; removal should be allowed immediately c.set_withdraw_queue(vec![]); @@ -828,9 +911,15 @@ fn clamp_allocation_total_matches_min_bounds_cases( let mut cfg = MarketConfiguration::default(); cfg.cap = U128(cap); cfg.enabled = cap > 0; - c.markets.insert(m.clone(), cfg); - c.market_supply.insert(m.clone(), cur); - c.supply_queue.push(m.clone()); + c.markets.insert( + m.clone(), + MarketRecord { + cfg, + pending_cap: None, + principal: cur, + }, + ); + c.supply_queue.insert(m.clone()); c.idle_balance = idle; let room = cap.saturating_sub(cur); @@ -856,8 +945,14 @@ fn total_assets_ignores_offqueue_cases(principal: u128, idle: u128) { let mut c = new_test_contract(&vault_id); let m = mk(7003); - c.markets.insert(m.clone(), MarketConfiguration::default()); - c.market_supply.insert(m.clone(), principal); + c.markets.insert( + m.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal, + }, + ); c.idle_balance = idle; assert_eq!(c.get_total_assets().0, idle); @@ -1118,8 +1213,8 @@ fn ft_on_transfer_supply_accepts_full_and_mints_shares( min_batch: U128(u128::MAX), }; let (m, cfg) = enabled_market_100; - c.markets.insert(m.clone(), cfg); - c.supply_queue.push(m); + c.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(m); let sender = accounts(1); let deposit = 50u128; @@ -1165,8 +1260,8 @@ fn ft_on_transfer_supply_partial_refund_when_capped( }; let (m, mut cfg) = enabled_market_100; cfg.cap = U128(50); // override cap for this case - c.markets.insert(m.clone(), cfg); - c.supply_queue.push(m); + c.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(m); let sender = accounts(2); let deposit = 80u128; @@ -1214,8 +1309,8 @@ fn ft_on_transfer_wrong_token_full_refund_via_receiver() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(100); cfg.enabled = true; - c.markets.insert(m.clone(), cfg); - c.supply_queue.push(m); + c.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(m); let sender = accounts(3); let deposit = 70u128; @@ -1252,8 +1347,8 @@ fn ft_on_transfer_zero_amount_returns_zero_refund( // Setup a valid market let (m, cfg) = enabled_market_100; - c.markets.insert(m.clone(), cfg); - c.supply_queue.push(m); + c.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(m); let sender = accounts(5); let bal_before = c.balance_of(&sender); @@ -1286,8 +1381,8 @@ fn ft_on_transfer_eager_mode_triggers_allocation( // Valid market/cap let (m, cfg) = enabled_market_100; - c.markets.insert(m.clone(), cfg); - c.supply_queue.push(m); + c.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(m); let deposit = 5u128; @@ -1412,7 +1507,7 @@ fn governance_set_curator_grants_allocator() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.markets.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg.into()); let new_cur = accounts(3); c.set_curator(new_cur.clone()); @@ -1426,7 +1521,7 @@ fn governance_set_curator_grants_allocator() { ); c.set_supply_queue(vec![m1.clone()]); assert_eq!(c.supply_queue.len(), 1); - assert_eq!(c.supply_queue.get(0), Some(&m1)); + assert_eq!(c.supply_queue.iter().next(), Some(&m1)); } #[test] @@ -1443,7 +1538,7 @@ fn governance_set_is_allocator_grant_allows_queue_ops() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.markets.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg.into()); // Grant Allocator role c.set_is_allocator(grantee.clone(), true); @@ -1457,7 +1552,7 @@ fn governance_set_is_allocator_grant_allows_queue_ops() { ); c.set_supply_queue(vec![m1.clone()]); assert_eq!(c.supply_queue.len(), 1); - assert_eq!(c.supply_queue.get(0), Some(&m1)); + assert_eq!(c.supply_queue.iter().next(), Some(&m1)); } #[test] @@ -1476,7 +1571,7 @@ fn governance_set_is_allocator_revoke_disallows_queue_ops() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.markets.insert(m1.clone(), cfg); + c.markets.insert(m1.clone(), cfg.into()); // Revoke Allocator role; subsequent queue op by grantee should panic due to lack of rights c.set_is_allocator(grantee.clone(), false); @@ -1490,7 +1585,7 @@ fn governance_set_is_allocator_revoke_disallows_queue_ops() { } #[test] -#[should_panic = "not yet"] +#[should_panic = "Timelock not elapsed yet"] fn governance_accept_guardian_not_yet_panics() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); @@ -1609,11 +1704,11 @@ fn governance_submit_cap_immediate_decrease() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(10); cfg.enabled = true; - c.markets.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg.into()); c.submit_cap(m.clone(), U128(3)); let after = c.markets.get(&m).unwrap(); - assert_eq!(after.cap, U128(3)); + assert_eq!(after.cfg.cap, U128(3)); } #[test] @@ -1643,7 +1738,7 @@ fn governance_submit_and_accept_cap_new_market_creates_and_enables() { ); c.accept_cap(m.clone()); - let cfg = c.markets.get(&m).unwrap(); + let cfg = &c.markets.get(&m).unwrap().cfg; assert_eq!(cfg.cap.0, 5); assert!( cfg.enabled, @@ -1692,17 +1787,17 @@ fn governance_submit_and_revoke_market_removal() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(0); cfg.enabled = true; - c.markets.insert(m.clone(), cfg); + 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.removable_at > 0, "removal must be scheduled"); + 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.removable_at, 0, "removal must be revoked"); + assert_eq!(after2.cfg.removable_at, 0, "removal must be revoked"); } #[test] @@ -1763,7 +1858,7 @@ fn governance_set_withdraw_queue_happy_path() { let mut cfg = MarketConfiguration::default(); cfg.cap = U128(1); cfg.enabled = true; - c.markets.insert(m.clone(), cfg); + c.markets.insert(m.clone(), cfg.into()); } set_ctx( @@ -1775,8 +1870,8 @@ fn governance_set_withdraw_queue_happy_path() { c.set_withdraw_queue(vec![m1.clone(), m2.clone()]); assert_eq!(c.withdraw_queue.len(), 2); - assert_eq!(c.withdraw_queue.get(0), Some(&m1)); - assert_eq!(c.withdraw_queue.get(1), Some(&m2)); + assert_eq!(c.withdraw_queue.iter().next(), Some(&m1)); + assert_eq!(c.withdraw_queue.iter().nth(1), Some(&m2)); } #[test] @@ -1869,7 +1964,6 @@ fn after_supply_1_check_allocating() { let mut c = new_test_contract(&vault_id); let op_id = 1; - let receiver = mk(7); c.op_state = OpState::Allocating(AllocatingState { op_id, @@ -1914,8 +2008,15 @@ fn after_send_to_user_success_no_escrow() { 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_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 100); + c.withdraw_queue.insert(market.clone()); + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 100, + }, + ); // Withdrawing: need 60, already collected 10; expect position None => new_principal = 0, withdrawn = 100, credited = min(100, 60) = 60 c.op_state = OpState::Withdrawing(WithdrawingState { @@ -1931,12 +2032,15 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { let res = c.after_exec_withdraw_read(Ok(None), 42, 0, U128(100), U128(60)); match res { - PromiseOrValue::Promise(p) => {} + PromiseOrValue::Promise(_p) => {} _ => panic!("Expected a Promise to send payout"), } assert_eq!( - *c.market_supply.get(&market).unwrap_or(&u128::MAX), + c.markets + .get(&market) + .map(|r| r.principal) + .unwrap_or(u128::MAX), 0, "Market principal should be updated to 0" ); @@ -2017,7 +2121,7 @@ fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: }); let before = c.idle_balance; - let ok = c.after_send_to_user( + c.after_send_to_user( Err(near_sdk::PromiseError::Failed), 1, receiver.clone(), @@ -2045,8 +2149,15 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { // Single-market queue so advancing index reaches end-of-queue let market = mk(8); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 100); + c.withdraw_queue.insert(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, @@ -2084,8 +2195,15 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect let mut c = new_test_contract(&vault_id); let market = mk(8); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), before); + c.withdraw_queue.insert(market.clone()); + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: before, + }, + ); let initial_idle = c.idle_balance; @@ -2112,7 +2230,10 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect } assert_eq!( - *c.market_supply.get(&market).unwrap_or(&u128::MAX), + c.markets + .get(&market) + .map(|r| r.principal) + .unwrap_or(u128::MAX), before, "principal must remain unchanged on read failure" ); @@ -2140,8 +2261,15 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde let mut c = new_test_contract(&vault_id); let market = mk(8); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 10); + c.withdraw_queue.insert(market.clone()); + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 10, + }, + ); let real_op = 5u64; let real_idx = 0u32; @@ -2190,7 +2318,7 @@ fn refund_path_consistency() { // Single-market withdraw queue (not used functionally here, just to satisfy path) let market = mk(12); - c.withdraw_queue.push(market); + c.withdraw_queue.insert(market); // Withdrawing state with remaining=0 and collected=0 forces refund path c.op_state = OpState::Withdrawing(WithdrawingState { @@ -2302,10 +2430,10 @@ fn resolve_market_helpers_supply_and_withdraw() { // Supply: plan takes precedence c.plan = Some(vec![(m2.clone(), 1u128)]); - c.supply_queue.push(m1.clone()); - c.supply_queue.push(m2.clone()); + c.supply_queue.insert(m1.clone()); + c.supply_queue.insert(m2.clone()); - assert_eq!(c.resolve_supply_market(0).unwrap(), m2); + assert_eq!(c.resolve_supply_market(0).unwrap(), &m2); assert!(matches!( c.resolve_supply_market(1), Err(Error::MissingMarket(1)) @@ -2313,18 +2441,18 @@ fn resolve_market_helpers_supply_and_withdraw() { // Without plan, use queue c.plan = None; - assert_eq!(c.resolve_supply_market(0).unwrap(), m1); - assert_eq!(c.resolve_supply_market(1).unwrap(), m2); + assert_eq!(c.resolve_supply_market(0).unwrap(), &m1); + assert_eq!(c.resolve_supply_market(1).unwrap(), &m2); assert!(matches!( c.resolve_supply_market(2), Err(Error::MissingMarket(2)) )); // Withdraw resolver uses withdraw_queue - c.withdraw_queue.push(m1.clone()); - c.withdraw_queue.push(m2.clone()); - assert_eq!(c.resolve_withdraw_market(0).unwrap(), m1); - assert_eq!(c.resolve_withdraw_market(1).unwrap(), m2); + c.withdraw_queue.insert(m1.clone()); + c.withdraw_queue.insert(m2.clone()); + assert_eq!(c.resolve_withdraw_market(0).unwrap(), &m1); + assert_eq!(c.resolve_withdraw_market(1).unwrap(), &m2); assert!(matches!( c.resolve_withdraw_market(2), Err(Error::MissingMarket(2)) @@ -2339,7 +2467,7 @@ fn after_supply_2_read_missing_position_stops() { // Resolve market via supply_queue let market = mk(42); - c.supply_queue.push(market); + c.supply_queue.insert(market); // Must be in Allocating ctx c.op_state = OpState::Allocating(AllocatingState { @@ -2365,7 +2493,7 @@ fn after_supply_2_read_read_failed_stops() { // Resolve market via supply_queue let market = mk(43); - c.supply_queue.push(market); + c.supply_queue.insert(market); // Must be in Allocating ctx c.op_state = OpState::Allocating(AllocatingState { @@ -2397,8 +2525,15 @@ fn after_create_withdraw_req_success_returns_promise( owner: AccountId, ) { let market = mk(50); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 100); + c.withdraw_queue.insert(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, @@ -2422,8 +2557,15 @@ fn after_create_withdraw_req_success_returns_promise( #[rstest] fn after_exec_withdraw_req_returns_promise(mut c: Contract) { let market = mk(60); - c.withdraw_queue.push(market.clone()); - c.market_supply.insert(market.clone(), 10); + c.withdraw_queue.insert(market.clone()); + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 10, + }, + ); c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 33, @@ -2455,9 +2597,16 @@ fn after_exec_withdraw_read_advances_when_remaining( // Two markets; first has principal to withdraw let m1 = mk(70); let m2 = mk(71); - c.withdraw_queue.push(m1.clone()); - c.withdraw_queue.push(m2.clone()); - c.market_supply.insert(m1.clone(), 10); + c.withdraw_queue.insert(m1.clone()); + c.withdraw_queue.insert(m2.clone()); + c.markets.insert( + m1.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 10, + }, + ); c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 0, index: 0, From e0cc4ce3ee09cce164f67e36b6963c7f11c0644c Mon Sep 17 00:00:00 2001 From: carrion256 Date: Sat, 1 Nov 2025 16:26:51 +0000 Subject: [PATCH 100/121] refactor!: style and move simple governance items to module --- contract/vault/src/governance.rs | 482 +++++++++++++++++++++++++++++ contract/vault/src/lib.rs | 512 +------------------------------ 2 files changed, 485 insertions(+), 509 deletions(-) create mode 100644 contract/vault/src/governance.rs diff --git a/contract/vault/src/governance.rs b/contract/vault/src/governance.rs new file mode 100644 index 00000000..cc4bdcbd --- /dev/null +++ b/contract/vault/src/governance.rs @@ -0,0 +1,482 @@ +use super::*; + +#[near] +impl Contract { + /// Sets the Curator account. Also grants/removes the Allocator role accordingly. + pub fn set_curator(&mut self, account: AccountId) { + Self::require_owner(); + Self::with_members_of_mut(&Role::Curator, |members| { + require!( + members.len() < 2, + "Invariant violation: Cannot have more than one Curator" + ); + require!( + !members.contains(&account), + "Curator already set to this account" + ); + members.iter().for_each(|m| { + self.set_is_allocator(m, false); + }); + members.clear(); + }); + Self::add_role(self, &account, &Role::Curator); + Event::CuratorSet { + account: account.clone(), + } + .emit(); + self.set_is_allocator(account, true); + } + + /// Grants or revokes the Allocator role for `account`. + pub fn set_is_allocator(&mut self, account: AccountId, allowed: bool) { + Self::require_owner(); + if allowed { + Self::add_role(self, &account, &Role::Allocator); + } else { + self.remove_role(&account, &Role::Allocator); + } + Event::AllocatorRoleSet { account, allowed }.emit(); + } + + /// Proposes a new Guardian. If a Guardian already exists, starts a timelock; otherwise sets immediately. + pub fn submit_guardian(&mut self, new_g: AccountId) { + Self::require_owner(); + let mut guardian_occupied = false; + + Self::with_members_of(&Role::Guardian, |members| { + require!( + members.len() < 2, + "Invariant violation: Cannot have more than one Guardian" + ); + require!(!members.contains(&new_g), "Already set to this address"); + guardian_occupied = !members.is_empty(); + }); + require!( + self.pending_guardian.is_none(), + "Guardian change already pending" + ); + if guardian_occupied { + let valid_at_ns = env::block_timestamp() + self.timelock_ns; + self.pending_guardian = Some(PendingValue { + value: new_g, + valid_at_ns, + }); + } else { + Self::add_role(self, &new_g, &Role::Guardian); + Event::GuardianSet { + account: new_g.clone(), + } + .emit(); + } + } + + /// Accepts the pending Guardian change after the timelock has elapsed. + pub fn accept_guardian(&mut self) { + Self::require_owner(); + + let p = self.pending_guardian.clone(); + + if let Some(p) = &p { + p.verify(); + Self::with_members_of_mut(&Role::Guardian, |members| { + members.clear(); + members.insert(&p.value); + }); + Event::GuardianSet { + account: p.value.clone(), + } + .emit(); + self.pending_guardian = None; + } + } + + /// Revokes any pending Guardian change. + pub fn revoke_pending_guardian(&mut self) { + Self::assert_guardian_or_owner(); + self.pending_guardian = None; + } + + /// Sets the recipient account for skimmed tokens. + pub fn set_skim_recipient(&mut self, account: AccountId) { + Self::require_owner(); + require!( + account != self.skim_recipient, + "Already set to this address" + ); + self.skim_recipient = account.clone(); + Event::SkimRecipientSet { + account: account.clone(), + } + .emit(); + } + + /// Sets the performance fee recipient. Accrues pending fees with the current recipient first. + #[payable] + pub fn set_fee_recipient(&mut self, account: AccountId) { + Self::require_owner(); + require!(account != self.fee_recipient, "Already set to this address"); + + if self.performance_fee != wad::Wad::zero() { + // Accrue any pending fees to current recipient before changing (so current recipient gets up to now) + self.internal_accrue_fee(); + } + Event::FeeRecipientSet { + account: account.clone(), + } + .emit(); + self.storage_deposit(Some(account.clone()), Some(true)); + + self.fee_recipient = account; + } + + /// Sets the performance fee as a WAD fraction (1e24 = 100%). Accrues fees at the old rate first. + pub fn set_performance_fee(&mut self, fee: Wad) { + Self::require_owner(); + + require!(fee != self.performance_fee, "Fee already set to this value"); + require!(fee <= Wad::from(MAX_FEE_WAD), "fee too high"); + + // Accrue any pending fees with old rate before changing + self.internal_accrue_fee(); + self.performance_fee = fee; + Event::PerformanceFeeSet { + fee: U128(u128::from(fee)), + } + .emit(); + } + + /* ----- Timelocks / Pending ----- */ + /// Proposes a new governance timelock in nanoseconds. + /// If increasing, applies immediately; if decreasing, starts a timelock equal to the current duration. + pub fn submit_timelock(&mut self, new_timelock_ns: U64) { + Self::require_owner(); + let tl = &new_timelock_ns.0; + + require!(tl != &self.timelock_ns, "Already set to this value"); + require!( + self.pending_timelock.is_none(), + "Timelock change already pending" + ); + require!( + (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(tl), + "Timelock out of bounds" + ); + if tl > &self.timelock_ns { + self.timelock_ns = *tl; + Event::TimelockSet { + seconds: new_timelock_ns, + } + .emit(); + } else { + let valid_at_ns = env::block_timestamp() + self.timelock_ns; + self.pending_timelock = Some(PendingValue { + value: *tl, + valid_at_ns, + }); + Event::TimelockChangeSubmitted { + new_ns: new_timelock_ns, + valid_at_ns: valid_at_ns.into(), + } + .emit(); + } + } + + /// Accepts a pending timelock change after it becomes valid. + pub fn accept_timelock(&mut self) { + Self::require_owner(); + if let Some(p) = &self.pending_timelock { + p.verify(); + + self.timelock_ns = p.value; + Event::TimelockSet { + seconds: p.value.into(), + } + .emit(); + self.pending_timelock = None; + } else { + env::panic_str("No pending timelock change"); + } + } + + /// Revokes any pending timelock change. + pub fn revoke_pending_timelock(&mut self) { + Self::assert_guardian_or_owner(); + self.pending_timelock = None; + Event::PendingTimelockRevoked {}.emit(); + } + + /// Submits a change to a market's supply cap. + /// Decreases apply immediately; increases are subject to the governance timelock. + #[payable] + pub fn submit_cap(&mut self, market: AccountId, new_cap: U128) { + Self::assert_curator_or_owner(); + self.ensure_idle(); + + let mkt = match self.markets.get_mut(&market) { + None => { + let _ = require_attached_at_least(yocto_for_new_market(), "submit_cap"); + self.markets.insert(market.clone(), MarketRecord::default()); + Event::MarketCreated { + market: market.clone(), + } + .emit(); + self.markets.get_mut(&market).unwrap() + } + Some(m) => m, + }; + + require!( + &mkt.pending_cap.is_none(), + "Policy violation: A cap change is already pending for this market" + ); + + require!( + mkt.cfg.removable_at == 0, + "Market removal pending, cannot change cap" + ); + + require!(new_cap != mkt.cfg.cap, "New cap is same as current"); + + if new_cap < mkt.cfg.cap { + // If lowering the cap, we can apply the delta immediately + mkt.cfg.cap = new_cap; + } else { + let valid_at_ns = env::block_timestamp() + self.timelock_ns; + if let Some(rec) = self.markets.get_mut(&market) { + rec.pending_cap = Some(PendingValue { + value: new_cap.0, + valid_at_ns, + }); + } + Event::SupplyCapRaiseSubmitted { + market: market.clone(), + new_cap, + valid_at_ns, + } + .emit(); + } + } + + /// Accepts a pending cap increase for `market` once the timelock has elapsed. + #[payable] + pub fn accept_cap(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + self.ensure_idle(); + + let in_queue = self.in_withdraw_queue(&market); + + let m = self + .markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("Config not found")); + + let was_enabled = m.cfg.enabled; + let before_principal = m.principal; + + let pending_value = m + .pending_cap + .as_ref() + .map(|pending_cap| { + pending_cap.verify(); + pending_cap.value + }) + .unwrap_or_else(|| env::panic_str("No pending cap change for this market")); + + m.cfg.cap = pending_value.into(); + + if pending_value > 0 { + if !m.cfg.enabled { + m.cfg.enabled = true; + } + m.cfg.removable_at = 0; + } + + if pending_value > 0 && !was_enabled { + Event::MarketEnabled { + market: market.clone(), + } + .emit(); + + if in_queue { + Event::MarketAlreadyInWithdrawQueue { + market: market.clone(), + } + .emit(); + } else { + let _ = require_attached_at_least( + yocto_for_bytes(storage_bytes_for_queue_account_id()), + "withdraw queue entry", + ); + self.add_market_to_withdraw_queue(&market, before_principal); + } + } + + Event::SupplyCapSet { + market: market.clone(), + new_cap: U128(pending_value), + } + .emit(); + + self.markets.get_mut(&market).unwrap().pending_cap = None; + } + + /// Revokes any pending cap change for `market`. + pub fn revoke_pending_cap(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + if let Some(rec) = self.markets.get_mut(&market) { + if rec.pending_cap.take().is_some() { + Event::SupplyCapRaiseRevoked { + market: market.clone(), + } + .emit(); + } + } + } + + /// To remove a market entirely, the curator: + ///- first sets its cap to 0 (disabling new deposits) + ///- then calls submit_market_removal. + /// > This starts a timelock (using the vault’s timelock) + /// - after which the market can be removed from the withdraw_queue (assuming any funds have been withdrawn) + /// Begins the process to remove `market` from the withdraw queue. + /// Requires cap == 0 and no pending cap changes; starts a timelock. + pub fn submit_market_removal(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + let rec = self + .markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str(&format!("Unknown market: {market}"))); + require!( + rec.cfg.removable_at == 0, + "Removal already pending for this market" + ); + require!( + rec.cfg.cap.0 == 0, + "Cannot remove market with non-zero cap (disable deposits first)" + ); + require!(rec.cfg.enabled, "Market not enabled or already removed"); + require!( + rec.pending_cap.is_none(), + "Cap change pending for this market" + ); + rec.cfg.removable_at = env::block_timestamp() + self.timelock_ns; + Event::MarketRemovalSubmitted { + market: market.clone(), + removable_at: rec.cfg.removable_at.into(), + } + .emit(); + } + + /// Revokes a pending market removal for `market`. + pub fn revoke_pending_market_removal(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + if let Some(cfg) = self.markets.get_mut(&market).map(|c| &mut c.cfg) { + cfg.removable_at = 0; + } + Event::MarketRemovalRevoked { market }.emit(); + } + + /// Sets the ordered supply queue. + /// Rejects duplicates and markets without a positive cap. Requires the vault to be idle. + #[payable] + pub fn set_supply_queue(&mut self, markets: Vec) { + Self::assert_allocator(); + self.ensure_idle(); + require!(markets.len() <= MAX_QUEUE_LEN, "too long"); + + // Invariant: supply_queue has no duplicates + let mut seen = HashSet::new(); + for m in &markets { + if !seen.insert(m.clone()) { + env::panic_str(&format!("Duplicate market {m}")); + } + } + // Validate all markets are authorized (cap > 0) before charging storage + for m in &markets { + let cap = self.markets.get(m).map_or(0, |r| r.cfg.cap.into()); + require!(cap > 0, "unauthorized market"); + } + + // Compute and require storage for additions (no refunds for removals in this pass) + let current: HashSet = self.supply_queue.iter().cloned().collect(); + let required_yocto = storage_management::yocto_for_queue_additions(¤t, &markets); + let _ = require_attached_at_least(required_yocto, "supply queue update"); + + self.supply_queue.clear(); + + for m in &markets { + self.supply_queue.insert(m.clone()); + } + } + /// For each removed market, we enforce the conditions: + /// Cap is 0 (no new deposits). + /// + /// No pending cap change. + /// + /// If the vault still has a supply in that market (vault_shares_in_market > 0), the market must have had submit_market_removal called (removable_at set) and the timelock must have passed. + /// Sets the ordered withdraw queue. + /// Enforces safety invariants and the policy that all enabled/holding markets must be present. + #[payable] + pub fn set_withdraw_queue(&mut self, queue: Vec) { + Self::assert_allocator(); + self.ensure_idle(); + require!( + queue.len() <= MAX_QUEUE_LEN, + "Withdraw queue length exceeds max" + ); + + let mut seen = HashSet::new(); + for id in &queue { + if !seen.insert(id.clone()) { + env::panic_str(&format!("Duplicate market {id}")); + } + } + + // Snapshot current withdraw queue into a set for membership checks + let current: HashSet = self.withdraw_queue.iter().cloned().collect(); + + for id in &queue { + require!( + self.markets.get(id).is_some(), + "Policy violation: Unknown market in new queue" + ); + } + + for (id, rec) in self.markets.iter() { + let has_supply = rec.principal > 0; + if (rec.cfg.enabled || has_supply) && !seen.contains(id) { + if current.contains(id) { + // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. + require!( + rec.cfg.cap.0 == 0, + "Policy violation: Cannot remove market with non-zero cap" + ); + require!( + rec.pending_cap.is_none(), + "Policy violation: Cannot remove market with pending cap change" + ); + self.aum.policy_removal(&rec.cfg, &has_supply); + } else { + // Not in current queue: must be included if enabled or holding. + env::panic_str( + &format!( + "Invariant violation: Withdraw queue must include all enabled or holding markets; missing {id}" + ), + ); + } + } + } + + let required_yocto = storage_management::yocto_for_queue_additions(¤t, &queue); + let _ = require_attached_at_least(required_yocto, "withdraw queue update"); + + self.withdraw_queue.clear(); + for id in &queue { + self.withdraw_queue.insert(id.clone()); + } + Event::WithdrawQueueUpdated { + markets: queue.clone(), + } + .emit(); + } +} diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 813735ef..2977c0e7 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -10,7 +10,6 @@ use crate::{ storage_management::{ require_attached_at_least, require_attached_for_pending_withdrawal, storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, - yocto_for_pending_cap, }, }; use near_contract_standards::fungible_token::core::ext_ft_core; @@ -18,7 +17,7 @@ use near_sdk::{ env, json_types::{U128, U64}, near, require, serde_json, - store::{IterableMap, Vector}, + store::IterableMap, AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, }; use near_sdk_contract_tools::{ @@ -44,6 +43,7 @@ use templar_common::{ pub use wad::*; pub mod aum; +pub mod governance; pub mod impl_callbacks; pub mod impl_token_receiver; pub mod storage_management; @@ -258,497 +258,6 @@ impl Contract { contract } - /// Sets the Curator account. Also grants/removes the Allocator role accordingly. - pub fn set_curator(&mut self, account: AccountId) { - Self::require_owner(); - Self::with_members_of_mut(&Role::Curator, |members| { - require!( - members.len() < 2, - "Invariant violation: Cannot have more than one Curator" - ); - require!( - !members.contains(&account), - "Curator already set to this account" - ); - members.iter().for_each(|m| { - self.set_is_allocator(m, false); - }); - members.clear(); - }); - Self::add_role(self, &account, &Role::Curator); - Event::CuratorSet { - account: account.clone(), - } - .emit(); - self.set_is_allocator(account, true); - } - - /// Grants or revokes the Allocator role for `account`. - pub fn set_is_allocator(&mut self, account: AccountId, allowed: bool) { - Self::require_owner(); - if allowed { - Self::add_role(self, &account, &Role::Allocator); - } else { - self.remove_role(&account, &Role::Allocator); - } - Event::AllocatorRoleSet { account, allowed }.emit(); - } - - /// Proposes a new Guardian. If a Guardian already exists, starts a timelock; otherwise sets immediately. - pub fn submit_guardian(&mut self, new_g: AccountId) { - Self::require_owner(); - let mut guardian_occupied = false; - - Self::with_members_of(&Role::Guardian, |members| { - require!( - members.len() < 2, - "Invariant violation: Cannot have more than one Guardian" - ); - require!(!members.contains(&new_g), "Already set to this address"); - guardian_occupied = !members.is_empty(); - }); - require!( - self.pending_guardian.is_none(), - "Guardian change already pending" - ); - if guardian_occupied { - let valid_at_ns = env::block_timestamp() + self.timelock_ns; - self.pending_guardian = Some(PendingValue { - value: new_g, - valid_at_ns, - }); - } else { - Self::add_role(self, &new_g, &Role::Guardian); - Event::GuardianSet { - account: new_g.clone(), - } - .emit(); - } - } - - /// Accepts the pending Guardian change after the timelock has elapsed. - pub fn accept_guardian(&mut self) { - Self::require_owner(); - - let p = self.pending_guardian.clone(); - - if let Some(p) = &p { - p.verify(); - Self::with_members_of_mut(&Role::Guardian, |members| { - members.clear(); - members.insert(&p.value); - }); - Event::GuardianSet { - account: p.value.clone(), - } - .emit(); - self.pending_guardian = None; - } - } - - /// Revokes any pending Guardian change. - pub fn revoke_pending_guardian(&mut self) { - Self::assert_guardian_or_owner(); - self.pending_guardian = None; - } - - /// Sets the recipient account for skimmed tokens. - pub fn set_skim_recipient(&mut self, account: AccountId) { - Self::require_owner(); - require!( - account != self.skim_recipient, - "Already set to this address" - ); - self.skim_recipient = account.clone(); - Event::SkimRecipientSet { - account: account.clone(), - } - .emit(); - } - - /// Sets the performance fee recipient. Accrues pending fees with the current recipient first. - #[payable] - pub fn set_fee_recipient(&mut self, account: AccountId) { - Self::require_owner(); - require!(account != self.fee_recipient, "Already set to this address"); - - if self.performance_fee != wad::Wad::zero() { - // Accrue any pending fees to current recipient before changing (so current recipient gets up to now) - self.internal_accrue_fee(); - } - Event::FeeRecipientSet { - account: account.clone(), - } - .emit(); - self.storage_deposit(Some(account.clone()), Some(true)); - - self.fee_recipient = account; - } - - /// Sets the performance fee as a WAD fraction (1e24 = 100%). Accrues fees at the old rate first. - pub fn set_performance_fee(&mut self, fee: Wad) { - Self::require_owner(); - - require!(fee != self.performance_fee, "Fee already set to this value"); - require!(fee <= Wad::from(MAX_FEE_WAD), "fee too high"); - - // Accrue any pending fees with old rate before changing - self.internal_accrue_fee(); - self.performance_fee = fee; - Event::PerformanceFeeSet { - fee: U128(u128::from(fee)), - } - .emit(); - } - - /* ----- Timelocks / Pending ----- */ - /// Proposes a new governance timelock in nanoseconds. - /// If increasing, applies immediately; if decreasing, starts a timelock equal to the current duration. - pub fn submit_timelock(&mut self, new_timelock_ns: U64) { - Self::require_owner(); - let tl = &new_timelock_ns.0; - - require!(tl != &self.timelock_ns, "Already set to this value"); - require!( - self.pending_timelock.is_none(), - "Timelock change already pending" - ); - require!( - (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(tl), - "Timelock out of bounds" - ); - if tl > &self.timelock_ns { - self.timelock_ns = *tl; - Event::TimelockSet { - seconds: new_timelock_ns, - } - .emit(); - } else { - let valid_at_ns = env::block_timestamp() + self.timelock_ns; - self.pending_timelock = Some(PendingValue { - value: *tl, - valid_at_ns, - }); - Event::TimelockChangeSubmitted { - new_ns: new_timelock_ns, - valid_at_ns: valid_at_ns.into(), - } - .emit(); - } - } - - /// Accepts a pending timelock change after it becomes valid. - pub fn accept_timelock(&mut self) { - Self::require_owner(); - if let Some(p) = &self.pending_timelock { - p.verify(); - - self.timelock_ns = p.value; - Event::TimelockSet { - seconds: p.value.into(), - } - .emit(); - self.pending_timelock = None; - } else { - env::panic_str("No pending timelock change"); - } - } - - /// Revokes any pending timelock change. - pub fn revoke_pending_timelock(&mut self) { - Self::assert_guardian_or_owner(); - self.pending_timelock = None; - Event::PendingTimelockRevoked {}.emit(); - } - - /* ----- Market config / queues ----- */ - /// Submits a change to a market's supply cap. - /// Decreases apply immediately; increases are subject to the governance timelock. - #[payable] - pub fn submit_cap(&mut self, market: AccountId, new_cap: U128) { - Self::assert_curator_or_owner(); - self.ensure_idle(); - - let mut required_deposit: u128 = 0; - if self.markets.get(&market).is_none() { - required_deposit = required_deposit.saturating_add(yocto_for_new_market()); - } - let current_cap = self.markets.get(&market).map_or(0, |r| r.cfg.cap.0); - if new_cap.0 > current_cap { - required_deposit = required_deposit.saturating_add(yocto_for_pending_cap()); - } - require_attached_at_least(required_deposit, "submit_cap"); - - require!( - self.markets - .get(&market) - .and_then(|r| r.pending_cap.as_ref()) - .is_none(), - "Policy violation: A cap change is already pending for this market" - ); - - let config = match self.markets.get_mut(&market) { - None => { - self.markets.insert(market.clone(), MarketRecord::default()); - Event::MarketCreated { - market: market.clone(), - } - .emit(); - self.cfg_mut(&market) - } - Some(config) => &mut config.cfg, - }; - - require!( - config.removable_at == 0, - "Market removal pending, cannot change cap" - ); - require!(new_cap != config.cap, "New cap is same as current"); - - if new_cap < config.cap { - // If lowering the cap, we can apply the delta immediately - config.cap = new_cap; - } else { - let valid_at_ns = env::block_timestamp() + self.timelock_ns; - if let Some(rec) = self.markets.get_mut(&market) { - rec.pending_cap = Some(PendingValue { - value: new_cap.0, - valid_at_ns, - }); - } - Event::SupplyCapRaiseSubmitted { - market: market.clone(), - new_cap, - valid_at_ns, - } - .emit(); - } - } - - /// Accepts a pending cap increase for `market` once the timelock has elapsed. - #[payable] - pub fn accept_cap(&mut self, market: AccountId) { - Self::assert_curator_or_owner(); - self.ensure_idle(); - - let pending_value = match self.markets.get(&market) { - Some(rec) => match rec.pending_cap.as_ref() { - Some(p) => { - p.verify(); - p.value - } - None => env::panic_str("No pending cap change for this market"), - }, - None => env::panic_str("No pending cap change for this market"), - }; - - let was_enabled = self.cfg(&market).enabled; - let in_queue = self.in_withdraw_queue(&market); - let before_principal = self.principal_of(&market); - - let rec = self - .markets - .get_mut(&market) - .unwrap_or_else(|| env::panic_str("Config not found")); - rec.cfg.cap = pending_value.into(); - if pending_value > 0 { - if !rec.cfg.enabled { - rec.cfg.enabled = true; - } - rec.cfg.removable_at = 0; - } - - if pending_value > 0 && !was_enabled { - Event::MarketEnabled { - market: market.clone(), - } - .emit(); - - if in_queue { - Event::MarketAlreadyInWithdrawQueue { - market: market.clone(), - } - .emit(); - } else { - let _ = require_attached_at_least( - yocto_for_bytes(storage_bytes_for_queue_account_id()), - "withdraw queue entry", - ); - self.add_market_to_withdraw_queue(&market, before_principal); - } - } - - Event::SupplyCapSet { - market: market.clone(), - new_cap: U128(pending_value), - } - .emit(); - - self.markets.get_mut(&market).unwrap().pending_cap = None; - } - - /// Revokes any pending cap change for `market`. - pub fn revoke_pending_cap(&mut self, market: AccountId) { - Self::assert_curator_or_owner(); - if let Some(rec) = self.markets.get_mut(&market) { - if rec.pending_cap.take().is_some() { - Event::SupplyCapRaiseRevoked { - market: market.clone(), - } - .emit(); - } - } - } - - /// To remove a market entirely, the curator: - ///- first sets its cap to 0 (disabling new deposits) - ///- then calls submit_market_removal. - /// > This starts a timelock (using the vault’s timelock) - /// - after which the market can be removed from the withdraw_queue (assuming any funds have been withdrawn) - /// Begins the process to remove `market` from the withdraw queue. - /// Requires cap == 0 and no pending cap changes; starts a timelock. - pub fn submit_market_removal(&mut self, market: AccountId) { - Self::assert_curator_or_owner(); - let rec = self - .markets - .get_mut(&market) - .unwrap_or_else(|| env::panic_str(&format!("Unknown market: {market}"))); - require!( - rec.cfg.removable_at == 0, - "Removal already pending for this market" - ); - require!( - rec.cfg.cap.0 == 0, - "Cannot remove market with non-zero cap (disable deposits first)" - ); - require!(rec.cfg.enabled, "Market not enabled or already removed"); - require!( - rec.pending_cap.is_none(), - "Cap change pending for this market" - ); - rec.cfg.removable_at = env::block_timestamp() + self.timelock_ns; - Event::MarketRemovalSubmitted { - market: market.clone(), - removable_at: rec.cfg.removable_at.into(), - } - .emit(); - } - - /// Revokes a pending market removal for `market`. - pub fn revoke_pending_market_removal(&mut self, market: AccountId) { - Self::assert_curator_or_owner(); - if let Some(cfg) = self.markets.get_mut(&market).map(|c| &mut c.cfg) { - cfg.removable_at = 0; - } - Event::MarketRemovalRevoked { market }.emit(); - } - - /// Sets the ordered supply (allocation) queue. - /// Rejects duplicates and markets without a positive cap. Requires the vault to be idle. - #[payable] - pub fn set_supply_queue(&mut self, markets: Vec) { - Self::assert_allocator(); - self.ensure_idle(); - require!(markets.len() <= MAX_QUEUE_LEN, "too long"); - - // Invariant: supply_queue has no duplicates; allocation order remains meaningful - let mut seen = HashSet::new(); - for m in &markets { - if !seen.insert(m.clone()) { - env::panic_str(&format!("Duplicate market {m}")); - } - } - // Validate all markets are authorized (cap > 0) before charging storage - for m in &markets { - let cap = self.markets.get(m).map_or(0, |r| r.cfg.cap.into()); - require!(cap > 0, "unauthorized market"); - } - - // Compute and require storage for additions (no refunds for removals in this pass) - let current: HashSet = self.supply_queue.iter().cloned().collect(); - let required_yocto = storage_management::yocto_for_queue_additions(¤t, &markets); - require_attached_at_least(required_yocto, "supply queue update"); - - self.supply_queue.clear(); - - for m in &markets { - self.supply_queue.insert(m.clone()); - } - } - - /// For each removed market, we enforce the conditions: - /// Cap is 0 (no new deposits). - /// - /// No pending cap change. - /// - /// If the vault still has a supply in that market (vault_shares_in_market > 0), the market must have had submit_market_removal called (removable_at set) and the timelock must have passed. - /// Sets the ordered withdraw queue. - /// Enforces safety invariants and the policy that all enabled/holding markets must be present. - #[payable] - pub fn set_withdraw_queue(&mut self, queue: Vec) { - Self::assert_allocator(); - self.ensure_idle(); - require!( - queue.len() <= MAX_QUEUE_LEN, - "Withdraw queue length exceeds max" - ); - - let mut seen = HashSet::new(); - for id in &queue { - if !seen.insert(id.clone()) { - env::panic_str(&format!("Duplicate market {id}")); - } - } - - // Snapshot current withdraw queue into a set for membership checks - let current: HashSet = self.withdraw_queue.iter().cloned().collect(); - - for id in &queue { - require!( - self.markets.get(id).is_some(), - "Policy violation: Unknown market in new queue" - ); - } - - for (id, rec) in self.markets.iter() { - let has_supply = rec.principal > 0; - if (rec.cfg.enabled || has_supply) && !seen.contains(id) { - if current.contains(id) { - // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. - require!( - rec.cfg.cap.0 == 0, - "Policy violation: Cannot remove market with non-zero cap" - ); - require!( - rec.pending_cap.is_none(), - "Policy violation: Cannot remove market with pending cap change" - ); - self.aum.policy_removal(&rec.cfg, &has_supply); - } else { - // Not in current queue: must be included if enabled or holding. - env::panic_str( - &format!( - "Invariant violation: Withdraw queue must include all enabled or holding markets; missing {id}" - ), - ); - } - } - } - - let required_yocto = storage_management::yocto_for_queue_additions(¤t, &queue); - let _ = require_attached_at_least(required_yocto, "withdraw queue update"); - - self.withdraw_queue.clear(); - for id in &queue { - self.withdraw_queue.insert(id.clone()); - } - Event::WithdrawQueueUpdated { - markets: queue.clone(), - } - .emit(); - } - - /* ----- Withdraw / Redeem ----- */ /// Burns the necessary shares to withdraw `amount` of underlying to `receiver`. /// Internally calls `redeem` after computing the share amount. #[payable] @@ -945,6 +454,7 @@ impl Contract { self.start_allocation(total) } + // 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; @@ -1120,22 +630,6 @@ impl From for (u128, u128) { /* ----- Private Helpers ----- */ impl Contract { - fn cfg_mut(&mut self, id: &AccountId) -> &mut MarketConfiguration { - &mut self - .markets - .get_mut(id) - .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {id}"))) - .cfg - } - - fn cfg(&self, id: &AccountId) -> &MarketConfiguration { - &self - .markets - .get(id) - .unwrap_or_else(|| env::panic_str(&format!("Config not found for market {id}"))) - .cfg - } - // Principal (vault-supplied) units currently recorded for a market fn principal_of(&self, market: &AccountId) -> u128 { self.markets.get(market).map_or(0, |r| r.principal) From 381ef0af37d4fd862118e2a0365ba189cac4af8f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Sat, 1 Nov 2025 17:23:10 +0000 Subject: [PATCH 101/121] refactor!: queueless withdrawals --- contract/vault/README.md | 224 +++++++-------- contract/vault/examples/gas_report.rs | 4 +- contract/vault/src/aum.rs | 231 +-------------- contract/vault/src/governance.rs | 96 +------ contract/vault/src/impl_callbacks.rs | 65 ++--- contract/vault/src/lib.rs | 106 +++---- contract/vault/src/test_utils.rs | 4 - contract/vault/src/tests.rs | 393 ++------------------------ contract/vault/tests/happy_path.rs | 22 +- 9 files changed, 224 insertions(+), 921 deletions(-) diff --git a/contract/vault/README.md b/contract/vault/README.md index f52a0a0b..985f7111 100644 --- a/contract/vault/README.md +++ b/contract/vault/README.md @@ -5,26 +5,35 @@ This document explains how the vault works end-to-end: roles and permissions, da ## High-level overview - The vault issues shares over an underlying asset and allocates liquidity into configured markets. -- Two ordered queues drive behavior: - - supply_queue: allocation order for deposits/idle funds to be supplied to markets. - - withdraw_queue: priority order to pull liquidity back from markets. +- Allocation uses a supply_queue for ordering deposits/idle funds into markets. +- Withdrawals are queue-less (keeper-routed): + - Order is chosen per withdrawal execution, not stored. + - A keeper/executor (an off-chain bot) or caller-provided hints picks which markets to tap first, based on live conditions. + - The contract enforces safety (caps, enabled flags, timelocks) but does not hardcode a single global withdraw order. - Operations are asynchronous and guarded by a single state machine (OpState): - Idle -> Allocating -> Idle - Idle -> Withdrawing -> Payout -> Idle - Performance fees accrue by minting fee shares on growth only. -- Strict invariants ensure queue correctness and safe removal of markets. +- Strict invariants ensure safety and correct accounting. + +## AUM model + +- The vault uses a BalanceSheet model by default. +- Total assets = idle balance + sum of all market principals. +- Accounting is independent of any withdraw order; price only changes when cash actually moves. ## Codebase map - src/lib.rs - Main contract entrypoint and storage. Declares the NEP-141 share token via FungibleToken, Owner, and Rbac derives. - - Core public API: governance (owner/curator/guardian/timelock), queue setters, allocation entrypoint (allocate), user flows (withdraw/redeem), and utility views (totals, previews, conversions). - - Storage: market configs, queues, market_supply, idle_balance, fee config, pending timelocks/guardian, and pending withdrawal FIFO. + - Core public API: governance (owner/curator/guardian/timelock), supply_queue setter, allocation entrypoint (allocate), user flows (withdraw/redeem), queue-less withdraw execution (execute_next_withdrawal_request(route), execute_next_market_withdrawal(op_id)), and utility views (totals, previews, conversions). + - Storage: market configs, supply_queue (only), market_supply, idle_balance, fee config, pending timelocks/guardian, and pending withdrawal FIFO. There is no on-chain global withdraw order. - Op state machine (OpState) and orchestration for allocation and withdraw/payout. - src/impl_callbacks.rs - - All async callback handlers (after_supply_*, after_create_withdraw_req, after_exec_withdraw_* and after_send_to_user). - - Context guards (ctx_allocating/ctx_withdrawing), market resolvers, reconciliation helpers, and stop_and_exit* helpers. - - Gas constants for cross-contract calls (GET_SUPPLY_POSITION_GAS, AFTER_*_GAS). + - All async callback handlers (after*supply*_, after*create_withdraw_req, after_exec_withdraw*_ and after_send_to_user). + - Supports deferred market withdrawal execution via execute_next_market_withdrawal(op_id) when deferment is enabled (default). + - Context guards (ctx_allocating/ctx_withdrawing), market resolvers, reconciliation helpers, and stop_and_exit\* helpers. + - Gas constants for cross-contract calls (GET*SUPPLY_POSITION_GAS, AFTER*\*\_GAS). - src/impl_token_receiver.rs - NEP-141 token receiver for deposits. Mints shares on correct token; fully refunds on wrong token (see test execute_supply_wrong_token_refunds_full). - Updates idle_balance on deposit; allocation remains separate/async. @@ -33,7 +42,7 @@ This document explains how the vault works end-to-end: roles and permissions, da - src/aux.rs - Small helpers and shared utilities used across the contract (kept minimal). - src/tests.rs and src/impl_callbacks.rs tests - - Invariants and property tests for flows, queues, conversions, and payout correctness. + - Invariants and property tests for flows, supply_queue, conversions, queue-less withdrawal routing, and payout correctness. - templar_common (external crate) - Shared types and cross-contract interfaces: BorrowAsset/FungibleAsset, market::ext_market and messages, vault types (Error, Event, OpState, MarketConfiguration, etc.). @@ -41,25 +50,13 @@ This document explains how the vault works end-to-end: roles and permissions, da Roles are enforced via RBAC. The Curator is also granted the Allocator role at init. -- Owner - - set_curator(account) - - set_is_allocator(account, allowed) - - submit_guardian(new_g), accept_guardian(), revoke_pending_guardian() - - submit_timelock(seconds), accept_timelock(), revoke_pending_timelock() - - set_fee_recipient(account), set_performance_fee(fee) - - set_skim_recipient(account), skim(token) -- Curator (Curator also has Allocator) - - submit_cap(market, new_cap), accept_cap(market), revoke_pending_cap(market) - - submit_market_removal(market), revoke_pending_market_removal(market) -- Allocator - - set_supply_queue(markets) - - set_withdraw_queue(markets) - - allocate(weights, amount) - - execute_next_withdrawal_request() -- Guardian - - revoke_pending_timelock() +- Owner: full control; can act in place of any role. +- Curator: manages markets and policy (caps/timelocks/enable/disable). Curator is also implicitly granted Allocator. +- Guardian: can revoke/cancel pending governance actions (timelock/guardian changes, etc.). +- Allocator (operational role): allowed to run allocation and withdrawal execution. This is the role your off-chain keeper bot should hold. Note + - All mutating ops require the vault to be Idle (single-op-at-a-time). Methods enforce this via ensure_idle(). ## External integrations and interfaces @@ -88,17 +85,17 @@ Note - execute_next_supply_withdrawal_request() - Deposit message and units - Underlying allocation uses DepositMsg::Supply with underlying units. -- Queue membership - - Ensure the market is in withdraw_queue whenever principal > 0; the vault also enforces this on its own after allocation steps. +- Withdraw routing + - There is no withdraw_queue. Routing is provided per withdrawal execution by the keeper/caller; design your adapter to accurately report positions and withdrawability. - Safety - - The vault tolerates failures by stopping/retrying or refunding escrow; design market adapters to fail fast and re-entrant safe. + - The vault tolerates failures by stopping/retrying or refunding escrow; design market adapters to fail fast and be re-entrancy safe. ## Key storage and concepts - MarketConfiguration per market: { cap, enabled, removable_at } - market_supply[market] = current principal supplied to that market - idle_balance = underlying tokens held by the vault -- supply_queue and withdraw_queue (ordered lists of market AccountIds) +- supply_queue (ordered list of market AccountIds) for allocation only - pending_cap, pending_timelock, pending_guardian with timelock semantics - pending_withdrawals FIFO queue (id -> {owner, receiver, escrow_shares, expected_assets, requested_at}) - Fee/virtual offsets for conversions: @@ -109,7 +106,7 @@ Note ## Conversions and fees - Views: - - get_total_assets() = idle + sum(principal across withdraw_queue markets) + - get_total_assets() = idle + sum(principal across all markets) - get_total_supply() - get_max_deposit() aggregates per-market remaining caps in supply_queue order - convert_to_shares(assets), convert_to_assets(shares) @@ -129,11 +126,11 @@ Note - Single-operation state machine, enforced by ensure_idle() on all mutating entrypoints: - Idle -> Allocating -> Idle - Idle -> Withdrawing -> Payout -> Idle -- Queue-driven orchestration - - supply_queue defines allocation order; withdraw_queue defines liquidity pull priority. +- Orchestration + - Allocation uses supply_queue order; withdrawals are keeper-routed using a per-op route and do not rely on a global on-chain order. - Weighted allocation mode uses a temporary in-memory plan (plan) for proportional steps. - Consistent stop behavior - - Any index/op_id drift or cross-contract error stops the op, reconciles remaining (for allocation), or refunds escrow (for withdrawal), then returns to Idle. + - Any index/op_id drift or cross-contract error stops the op, reconciles remaining (for allocation), or refunds/parks escrow (for withdrawal), then returns to Idle. ## Deposit and mint flow @@ -161,18 +158,21 @@ User deposits underlying and receives vault shares. Allocation into markets is s ## Allocation pipeline (Idle -> Allocating -> Idle) Triggered by Allocator: + - allocate(weights=[], amount=None) - Queue-based if weights empty; weighted if provided. - total reserved = clamp_allocation_total(requested or idle), subject to get_max_deposit(). - start_allocation(total) reserves from idle (idle_balance -= total), sets OpState::Allocating { remaining=total, index=0 }, emits AllocationStarted. Async loop (step_allocation): + - Picks the next market from plan (weighted) or supply_queue (queue-based). - Computes room and to_supply, emits AllocationStepPlanned. - If to_supply == 0, skips and advances index. - Else transfers underlying to market via transfer_call(..., DepositMsg::Supply) and awaits after_supply_1_check. Callbacks: + - after_supply_1_check: - Validates current op and resolves market. - If transfer failed, stops and returns remaining back to idle (stop_and_exit_allocating). @@ -180,10 +180,10 @@ Callbacks: - after_supply_2_read: - Reads new_principal, computes accepted_event = new_principal - before. - Updates market_supply, emits AllocationStepSettled. - - Ensures market is in withdraw_queue if principal > 0. - Advances index and remaining; loops or exits. Exit: + - stop_and_exit_allocating(None) emits AllocationCompleted and returns any remaining to idle. - Any error stops, returns remaining to idle, clears plan, and goes Idle. @@ -193,14 +193,12 @@ Exit: - Reservation and reconciliation - start_allocation reserves only the planned amount (idle_balance -= amount). - On completion or on any failure, remaining is returned to idle_balance. -- Market (re)inclusion - - If a market’s principal becomes > 0, it is ensured to be present in withdraw_queue. Re-including a market with pre-existing principal adjusts last_total_assets to avoid fee-on-reinclude. -## Withdrawal and redeem flow +## Withdrawal and redeem flow (queue-less, keeper-routed) -Two phases: user requests (escrow) and allocator executes (pull liquidity, pay out). +Two phases: user requests (escrow) and keeper-routed execution (pull liquidity, pay out). -1) User request (escrow shares) +1. User request (escrow shares) - withdraw(amount, receiver) - Computes shares_needed via preview_withdraw and defers to redeem. @@ -208,88 +206,69 @@ Two phases: user requests (escrow) and allocator executes (pull liquidity, pay o - Transfers shares from owner to the vault (escrow) without burning. - Converts shares to assets via convert_to_assets (estimated). - Emits WithdrawQueued; enqueues pending withdrawal (owner, receiver, escrow_shares, expected_assets). - - Does NOT start withdrawal; allocator must call execute_next_withdrawal_request(). - -2) Allocator executes (Idle -> Withdrawing -> Payout -> Idle) - -- execute_next_withdrawal_request(): - - Pops the next pending withdrawal by id and calls start_withdraw(expected_assets, receiver, owner, escrow_shares). - -start_withdraw: -- Uses idle-first: collected = min(idle_balance, amount), remaining = amount - collected. -- Sets OpState::Withdrawing { index=0, remaining, receiver, collected, owner, escrow_shares }. - -step_withdraw: -- If remaining == 0: - - Switches to OpState::Payout and transfers collected to receiver; after_send_to_user burns escrow proportionally and refunds unused escrow. -- Else: - - Iterates withdraw_queue[index]: - - If market principal is zero, skip (advance index). - - Else create_supply_withdrawal_request(to_request) -> after_create_withdraw_req -> execute_next_supply_withdrawal_request() -> after_exec_withdraw_req -> read position -> after_exec_withdraw_read. - -Callbacks: -- after_create_withdraw_req: - - On failure: advance index; if end-of-queue, transition to Payout/Refund based on collected. -- after_exec_withdraw_req: - - Reads position afterwards to verify change. -- after_exec_withdraw_read: - - Computes credited and updates: - - credited = min(before - new, need), remaining_next = rem - credited, collected_next = coll + credited, idle += credited. - - If remaining_next == 0: - - If collected_next > 0 => Payout - - Else refund full escrow and go Idle. - - Else advance to next market and continue. - -Payout finalization: -- after_send_to_user: + - Does NOT start withdrawal; keeper (Allocator) must call execute_next_withdrawal_request(route). + +2. Execution by Allocator/keeper (Idle -> Withdrawing -> Payout -> Idle) + +- execute_next_withdrawal_request(route: Vec): + - Pops the next pending withdrawal by id and calls start_withdraw(expected_assets, receiver, owner, escrow_shares) with the provided per-op route. + - Idle-first: collected = min(idle_balance, amount), remaining = amount - collected. + - Sets OpState::Withdrawing { index=0, remaining, receiver, collected, owner, escrow_shares }. + +- For each market in route: + - If remaining == 0, skip to payout. + - If market principal is zero, skip to next. + - The vault creates a market withdrawal request up to min(remaining, principal) via create_supply_withdrawal_request(...). + - By default, requests are created with deferment (defer_market_execute = true). The keeper then calls execute_next_market_withdrawal(op_id) to execute created requests (may be called multiple times). + - After execution, the vault queries get_supply_position(...) and reconciles: + - credited = min(before - after, remaining) + - idle_balance += credited + - remaining -= credited; collected += credited + +- Completion/parking: + - If remaining hits zero, the vault pays the receiver and burns the proportional escrowed shares. + - If the route is exhausted before need is satisfied, the vault parks the request (escrow remains). The keeper can retry later with a new route. + +- Payout finalization (after_send_to_user): - On success: - idle_balance -= payout_amount - - Burn only the proportional shares and refund the remainder: - - burn_shares = compute_burn_shares(escrow_shares, collected, requested_total) - - (to_burn, refund_shares) = compute_escrow_settlement(escrow_shares, burn_shares) - - burn to_burn from escrow; transfer refund_shares back to owner + - Burn only the proportional shares and refund the remainder to the owner. - Go Idle. - On failure: - Refund full escrow to owner; leave idle unchanged; go Idle. -Stop behavior: -- Any callback receiving stale op_id or mismatched index will gracefully stop the op, refunding escrow (for withdraw) or reconciling remaining (for allocation), and return to Idle. +Important -- Two-phase withdrawal - - User redeem/withdraw: shares are escrowed in the vault account (not burned yet) and a pending withdrawal is queued with an expected_assets estimate. - - Operator execute_next_withdrawal_request(): drives the async pipeline to collect assets and pay out. -- Idle-first payout - - The vault first uses idle_balance. Any remaining amount is pulled from markets in withdraw_queue order. -- On-market withdrawal - - For each market: create request, execute next request, then read position to verify principal reduction. Credited amounts increase idle_balance. -- Payout finalization - - On success: idle_balance -= paid amount; burn only the proportional fraction of escrow_shares corresponding to the paid fraction; refund remaining escrow to the owner. - - On failure: refund full escrow; idle_balance unchanged. +- The route applies only to the current withdrawal op and is not stored. There is no persistent withdraw order on-chain. +- The vault will skip markets with zero principal; it will not exceed principal, and it reconciles actual results after each market call. + +## Typical routing policies (off-chain) + +- Liquidity-first: withdraw from markets that can return funds immediately (max withdrawable now). +- Cheapest-first: minimize gas/calls or on-market fees. +- Risk-aware: prefer healthiest positions; avoid stressed ones unless necessary. +- Pro-rata: take proportionally from all markets holding principal. +- Round-robin/aging: fairness over time across markets. +- Don’t grow risk: prefer markets with cap=0 (being wound down) before touching growth markets. ## Queues and market management - set_supply_queue(markets): - Requires Idle; rejects duplicates; each market must have cap > 0. -- set_withdraw_queue(queue): - - Requires Idle; rejects duplicates; every enabled or holding market must be present. - - Removing a market requires: - - cap == 0 - - no pending cap change - - if principal > 0: removable_at set and timelock elapsed - - Removing a market also removes its configuration. +- Note: + - There is no withdraw_queue. Withdrawals are routed per operation by the keeper/caller. - submit_cap(market, new_cap), accept_cap(market): - Lowering cap applies immediately (and may disable the market if cap == 0). - Raising cap is timelocked; accept after timelock. - - Enabling a market ensures it’s present in withdraw_queue. + - Enabling/disabling does not affect any on-chain withdraw order (there is none). - submit_market_removal(market), revoke_pending_market_removal(market): - - Start/stop a removal timelock; actual removal occurs via set_withdraw_queue. - -- Before removing a market from withdraw_queue: - - cap == 0 and no pending cap raise. + - Start/stop a removal timelock; actual removal occurs once conditions are met by governance. +- Removing a market + - Requires cap == 0 and no pending cap raise. - If principal > 0: removable_at set via submit_market_removal and timelock elapsed. -- Removing a market deletes its configuration but does not clear market_supply; off-queue principal is intentionally ignored by get_total_assets(). + - Removing a market deletes its configuration but does not clear market_supply; total assets continue to include remaining principal until withdrawn. ## Fee policy @@ -305,16 +284,22 @@ Stop behavior: - Allocator: allocate(weights, amount) - Withdrawals: - User: redeem(shares, receiver) or withdraw(amount, receiver) - - Allocator: execute_next_withdrawal_request() + - Allocator: execute_next_withdrawal_request(route), execute_next_market_withdrawal(op_id) - Governance: - Owner/Curator/Guardian as listed above. +## API changes (for integrators/keepers) + +- execute_next_withdrawal_request now requires a route: Vec (ordered preference for this withdrawal). +- allocator_execute_next_market_withdrawal(op_id) executes the next created market request when deferment is enabled (default). +- Curator is granted Allocator by default at initialization; keepers must use an account that has the Allocator role (or be the Curator/Owner). + ## Error handling and stop semantics - Allocation - Any transfer/position read error or state mismatch stops the operation, returns remaining to idle, clears plan, and returns to Idle. - Withdrawal - - Any state mismatch or market call failure advances to the next market; reaching end-of-queue triggers payout-if-collected or escrow refund. + - Any state mismatch or market call failure advances to the next market; reaching end-of-route parks the request for later retries or triggers payout-if-collected. - Payout - On success: burn proportional escrow and refund the rest; on failure: refund full escrow; in both cases the vault returns to Idle. - All stop paths emit structured events for indexing and debugging. @@ -322,7 +307,7 @@ Stop behavior: ## Key invariants - Single op in flight; ensure_idle() on all mutating entrypoints. -- Withdraw queue must contain every enabled or holding market. +- No global withdraw order is stored on-chain; withdrawals are routed per execution. - Allocation reservation never exceeds idle or available cap (clamp_allocation_total). - Payout success always reduces idle by paid amount and burns only proportional escrow. - Fees mint only on positive growth. @@ -330,14 +315,14 @@ Stop behavior: ## Testing and local development - Unit/property tests cover: - - Queue invariants, cap/timelock and market removal rules. - - Allocation/withdraw pipelines, payout success/failure, and escrow settlement math. + - Cap/timelock rules and market removal. + - Allocation pipeline, queue-less withdraw routing, payout success/failure, and escrow settlement math. - Fee accrual on growth only, and conversion/preview bounds with virtual offsets. - Token receiver behavior (wrong token refund). - Running tests: - cargo test -p templar-vault - Tips: - - When integrating a new market, first wire get_supply_position and dry-run the withdraw path to validate reconciliation. + - When integrating a new market, first wire get_supply_position and dry-run the withdraw path with a short route to validate reconciliation. ## Storage management @@ -346,33 +331,32 @@ create new storage entries. We size entries conservatively using AccountId::MAX_ to avoid relying on runtime storage usage “diffs”. What the contract pays for + - RBAC storage: role membership (Owner/RBAC lists) is paid by the contract. Callers are not charged -storage deposits for set_curator, set_is_allocator, or guardian role changes. + storage deposits for set_curator, set_is_allocator, or guardian role changes. Conservative sizing + - AccountId bytes are charged at MAX_LEN to keep pricing simple and deterministic. - Map/queue overheads are charged with fixed constants. - PendingWithdrawal size is a fixed upper bound of its fields. When a deposit is required + - submit_cap(market, new_cap) - If market is new: config entry + market_supply entry. - If raising cap above current: pending_cap entry. - accept_cap(market) - - If enabling (cap > 0) and the market is not in withdraw_queue: 1 queue slot. + - If enabling (cap > 0): no extra storage for withdraw order (none exists). - set_supply_queue(markets) - Storage for markets added that were not previously in the queue. -- set_withdraw_queue(queue) - - Storage for markets added that were not previously in the queue. - allocate(weights, amount) - - Up-front deposit to cover potential withdraw_queue insertions for any candidate market in the -allocation run (supply_queue for queue mode; weighted plan markets for weighted mode). + - No storage deposit for withdraw routing (route is ephemeral and provided per execution). - withdraw/redeem - PendingWithdrawal queue entry per request (escrowed shares are held until payout/refund). Refund policy -- For simplicity and in line with many Ethereum contracts, we do not refund storage on removals (e.g., -queue removals, consumed pending withdrawals, deleted configs). This avoids complexity and edge cases -around attribution. - +- For simplicity and in line with many Ethereum contracts, we do not refund storage on removals (e.g., + queue removals, consumed pending withdrawals, deleted configs). This avoids complexity and edge cases + around attribution. diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index 72030c1f..420e8960 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -83,10 +83,12 @@ async fn main() { withdraw_gas_average += w / ITERATIONS as f64; } + let withdraw_route = vec![c.market.contract().id().clone()]; + let mut execute_withdraw_gas_average = 0f64; for _ in 0..ITERATIONS { let execute_gas = vault - .execute_next_withdrawal(&vault_curator) + .execute_next_withdrawal(&vault_curator, withdraw_route.clone()) .await .total_gas_burnt .as_gas() as f64; diff --git a/contract/vault/src/aum.rs b/contract/vault/src/aum.rs index 938583d3..7687315f 100644 --- a/contract/vault/src/aum.rs +++ b/contract/vault/src/aum.rs @@ -1,234 +1,23 @@ -use super::{Contract, MarketConfiguration, U128, env, near, require}; +use near_sdk::near; -/// AUM (Assets Under Management) module -/// -/// This module encodes two coherent accounting models for a vault: -/// - GovernanceAbandonment (MetaMorpho-style): AUM counts only markets that are currently -/// "active" in the withdraw_queue. Governance may perform a timelocked write-down by -/// removing a market from the withdraw_queue even if principal remains. Later, governance -/// may perform a timelocked write-up by re-adding that market. Pricing reflects only the -/// set of markets governance currently stands behind. -/// - BalanceSheet (strict accounting): AUM includes every position that still belongs to -/// the vault until assets actually move (principal decreases or funds are paid out). -/// Queue membership is an operational detail; it must not change AUM. Markets cannot be -/// removed from the withdraw_queue while principal > 0. -/// -/// Choose exactly one model and apply it consistently across: -/// - How total assets are computed (get_total_assets), -/// - When markets may be omitted from the withdraw queue (policy_removal), -/// - How last_total_assets is adjusted around write-down/write-up boundaries (paper_aum_undercounting), -/// - How fees are minted and previews are computed. -/// -/// DO NOT mix semantics (e.g., queue-scoped AUM + removal blocked while principal > 0), -/// as that creates mispricing and attack surface. -/// -/// High-level tradeoffs -/// 1) Pricing integrity for mints/redeems -/// - GovernanceAbandonment: Prices reflect only active/supported markets. New depositors -/// are shielded from legacy/stuck risk. Governance actions create price jumps independent -/// of cashflows (write-down/write-up). -/// - BalanceSheet: Price moves only with actual cashflows. No policy-driven price jumps. -/// New depositors buy exposure to legacy risk unless deposits are gated/paused. -/// -/// 2) Fee accrual correctness -/// - GovernanceAbandonment: Must protect against spurious fee mint/burn caused by -/// write-down/write-up reclassification. Common pattern: mint fees only on positive delta -/// and bump last_total_assets when re-adding a previously written-down market that still -/// holds principal (see paper_aum_undercounting). -/// - BalanceSheet: Fees accrue strictly on realized growth. No special handling around -/// policy events. Simpler reasoning. -/// -/// 3) Cohort fairness (who bears losses/who captures recovery) -/// - GovernanceAbandonment: Existing holders bear loss at the write-down cutover. -/// Post-write-down entrants can capture recovery when/if the market is re-added. -/// Timelocks + events are the fairness mechanism. -/// - BalanceSheet: Losses/recovery remain within the continuous cohort. No cohort transfer -/// at policy boundaries; new depositors buy the bag unless you gate deposits. -/// -/// 4) Manipulation/attack surface -/// - GovernanceAbandonment: Potential "yo-yo" price via queue changes. Mitigated by -/// meaningful timelocks, public events, and possibly deposit pauses around effective times. -/// Be explicit about timelock durations and eventing. -/// - BalanceSheet: Risk of "optimistic NAV" if operators keep distressed positions in AUM -/// while accepting deposits. Mitigate by pausing/capping deposits and surfacing a -/// "distressed fraction" metric. -/// -/// 5) Liquidity realism in previews -/// - GovernanceAbandonment: Previews align with supported/active markets. Recovery later -/// causes price discontinuity when re-added. -/// - BalanceSheet: NAV reflects all claims, but previews for withdraw may overstate immediacy. -/// Provide a liquidity-aware maxWithdraw estimator along the queue for UIs/policy. -/// -/// 6) Operational UX and liveness -/// - GovernanceAbandonment: Clean lever to amputate toxic limbs and keep the product usable -/// for new money. Requires disciplined governance, communications, and auditable events. -/// - BalanceSheet: No governance-driven price shocks, but product can feel "stuck" if assets -/// are illiquid. UIs must handle long-running withdrawals gracefully. -/// -/// 7) Complexity -/// - GovernanceAbandonment: More policy code (timelocks, queue-scoped AUM, last_total_assets -/// adjustments, explicit events). -/// - BalanceSheet: Simpler accounting; needs better liquidity simulation and deposit gating. -/// -/// Numeric example (to reason about share effects; do not execute) -/// - t0: AUM = 1,000 (100 idle + 900 in Market M), totalSupply = 1,000 shares, price = 1.00. -/// - Model GovernanceAbandonment: -/// t1 write-down: remove M (timelock elapsed), AUM -> 100, price -> 0.10. Existing holders internalize loss. -/// t2 deposit 100: mints 1,000 shares (NAV 100), totalSupply = 2,000. -/// t3 write-up/recovery: re-add M after timelock and bump last_total_assets; AUM -> 1,100, price -> 0.55. -/// Post t1 entrants capture part of recovery. This is intentional under this model. -/// - Model BalanceSheet: -/// t1 acknowledge distress but keep M in AUM. Price stays 1.00. -/// t2 deposit 100: mints 100 shares. New depositors buy distressed exposure. -/// t3 recovery only changes AUM if cash actually moves; no policy jump. -/// -/// Eventing (strongly recommended) -/// - Emit MarketWriteDown(id, principal_at_cutover, when) on removal with principal > 0. -/// - Emit MarketWriteUp(id, principal_at_readd, when) on re-add of a market with principal > 0. -/// - Emit WithdrawQueueUpdated, CapChanged, PendingCapAccepted. -/// Clear, auditable events are essential for both fairness and downstream analytics. -/// -/// Guardrails per model (must-have) -/// - GovernanceAbandonment: -/// * total assets = sum over withdraw_queue only. -/// * allow omission from queue if cap == 0, no pending cap, and (if principal > 0) removable_at set and elapsed. -/// * on re-add of a market that still holds principal, bump last_total_assets by the principal -/// to avoid accidental fee minting. -/// * consider short deposit pauses around write-down/write-up effective times. -/// - BalanceSheet: -/// * total assets = idle + sum principal across all markets (independent of queue). -/// * cannot remove from queue while principal > 0 (timelock is necessary but not sufficient). -/// * publish staged/receivable metrics for ops visibility; do not feed them into pricing. -/// * implement liquidity-aware maxWithdraw/maxRedeem simulators. -/// -/// Testing checklist -/// - Write-down with principal > 0: -/// * GovernanceAbandonment: price drops, no fee minted on loss, re-add bump adjusts last_total_assets. -/// * BalanceSheet: removal blocked; price unchanged until cash moves. -/// - Re-add with principal > 0: -/// * GovernanceAbandonment: last_total_assets bump prevents fee mint; price jumps as intended. -/// * BalanceSheet: re-add is a no-op for accounting; price continuous. -/// - Deposit/withdraw previews across cutovers: no reentrancy or preview mispricing. -/// - Timelock enforcement: cannot write-down or write-up without elapsed timelock. -/// - Attack simulations: attempt to yo-yo the queue within timelocks; ensure protections hold. -/// -/// Migration notes -/// - Changing models after deployment is a breaking policy change. If unavoidable, perform with -/// long lead-time, explicit events, and optionally paused deposits during the switchover. -/// -/// Terminology -/// - "Staged"/"Primed": operational intent to withdraw; does not change AUM by itself. -/// - "Write-down": governance removal from queue (GovernanceAbandonment) => AUM exclusion. -/// - "Write-up": governance re-add to queue (GovernanceAbandonment) => AUM inclusion with last_total_assets bump. -/// AUM model selector. -/// -/// GovernanceAbandonment (MetaMorpho-style): -/// - AUM is withdraw_queue-scoped by design: if a market is not in the withdraw_queue, -/// it does not contribute to AUM. -/// - Removing a market with non-zero principal is allowed, but only after a timelock: -/// cap == 0, no pending cap, removable_at set, and block time >= removable_at. -/// - Effect: governance "writes down" that position for AUM purposes even if tokens -/// remain or recovery is possible. +use super::{Contract, U128}; + +//// AUM (Assets Under Management) /// -/// BalanceSheet (strict accounting): -/// - AUM includes all positions that still belong to the vault until assets actually -/// move. Queue membership must not change accounting. -/// - Markets cannot be removed from the withdraw_queue while principal > 0. +/// BalanceSheet model only: total assets are the sum of idle_balance and all market principals. +/// There is no governance-scoped AUM filtering; accounting changes only when cash actually moves. #[near(serializers = [borsh, json])] #[derive(Debug, Clone)] pub enum AUM { - /// GovernanceAbandonment: queue = truth for AUM. See module docs for tradeoffs. - GovernanceAbandonment, /// BalanceSheet: balance sheet = truth for AUM. See module docs for tradeoffs. BalanceSheet, } impl AUM { - /// Compute total assets according to the selected AUM model. - /// - /// Invariants and expectations: - /// - `GovernanceAbandonment`: - /// * Sums over `withdraw_queue` only. This is an intentional filter; it encodes - /// governance's current support set and excludes written-down markets. - /// * If you re-add a market that still holds principal, you must pair this with - /// a `last_total_assets` bump elsewhere (see `paper_aum_undercounting`) to avoid - /// spurious fee minting on reclassification. - /// - /// - `BalanceSheet`: - /// * Sums over all markets that still have principal. Here we assume `supply_queue` - /// enumerates all configured/held markets. If it does not, replace with an - /// iteration over the authoritative positions map (e.g., `config` or `positions`). - /// * AUM changes only when principal changes or idle balance changes. + /// Compute total assets (BalanceSheet): idle balance + sum of all market principals. pub fn get_total_assets(&self, c: &Contract) -> U128 { - U128(match self { - AUM::GovernanceAbandonment => { - c.withdraw_queue.iter().fold(c.idle_balance, |prev, m| { - prev.saturating_add(c.principal_of(m)) - }) - } - AUM::BalanceSheet => c - .markets - .iter() - .fold(c.idle_balance, |prev, (_, rec)| prev.saturating_add(rec.principal)), - }) - } - - /// Enforce removal policy for omitting a market from the `withdraw_queue`. - /// - /// This function should be called at the point where an operator attempts to - /// remove a market from the `withdraw_queue`. It enforces model-specific invariants. - /// - /// - `GovernanceAbandonment`: - /// * If the market still has principal, removal requires that a removal timelock - /// was scheduled (`removable_at` > 0) and has elapsed (now >= `removable_at`). - /// * Additional guards external to this function are typically required: - /// cap == 0 and no pending cap. Enforce those where caps are managed. - /// - /// - `BalanceSheet`: - /// * Removal is prohibited while any principal remains (> 0). - /// * Passing a timelock is necessary but not sufficient; ownership hasn't changed. - pub fn policy_removal(&self, cfg: &MarketConfiguration, has_supply: &bool) { - match self { - AUM::GovernanceAbandonment => { - if *has_supply { - require!( - cfg.removable_at > 0, - "Policy violation: Market still has supply but no removal scheduled" - ); - require!( - env::block_timestamp() >= cfg.removable_at, - "Policy violation: Removal timelock not elapsed for market" - ); - } - } - AUM::BalanceSheet => require!(!has_supply, "Policy violation: Supply shares exist"), - } - } - - /// Handle accounting around potential AUM undercounting when re-adding markets. - /// - /// Context: - /// - Under `GovernanceAbandonment`, a market removed from the `withdraw_queue` is excluded - /// from AUM even if it still holds principal. When that market is later re-added, - /// its principal "reappears" in reported AUM. To prevent accidental performance fee - /// minting due purely to reclassification (not economic gain), we bump `last_total_assets` - /// by the previously excluded principal at re-add time. - /// - /// - Under `BalanceSheet`, AUM was never reduced during removal attempts, so no bump is - /// necessary. Fees accrue naturally on realized growth only. - /// - /// Safety notes: - /// - Only add `before_principal` that was actually excluded by the prior write-down. - /// - This adjustment assumes your fee module mints fees on positive delta of - /// (`current_total_assets` - `last_total_assets`). If your fee policy differs, audit this path. - pub fn paper_aum_undercounting(&self, c: &mut Contract, before_principal: &u128) { - match self { - AUM::GovernanceAbandonment => { - if *before_principal > 0 { - c.last_total_assets = c.last_total_assets.saturating_add(*before_principal); - } - } - AUM::BalanceSheet => {} - } + U128(c.markets.iter().fold(c.idle_balance, |prev, (_, rec)| { + prev.saturating_add(rec.principal) + })) } } diff --git a/contract/vault/src/governance.rs b/contract/vault/src/governance.rs index cc4bdcbd..4261126f 100644 --- a/contract/vault/src/governance.rs +++ b/contract/vault/src/governance.rs @@ -263,7 +263,6 @@ impl Contract { Self::assert_curator_or_owner(); self.ensure_idle(); - let in_queue = self.in_withdraw_queue(&market); let m = self .markets @@ -271,7 +270,6 @@ impl Contract { .unwrap_or_else(|| env::panic_str("Config not found")); let was_enabled = m.cfg.enabled; - let before_principal = m.principal; let pending_value = m .pending_cap @@ -296,19 +294,6 @@ impl Contract { market: market.clone(), } .emit(); - - if in_queue { - Event::MarketAlreadyInWithdrawQueue { - market: market.clone(), - } - .emit(); - } else { - let _ = require_attached_at_least( - yocto_for_bytes(storage_bytes_for_queue_account_id()), - "withdraw queue entry", - ); - self.add_market_to_withdraw_queue(&market, before_principal); - } } Event::SupplyCapSet { @@ -334,11 +319,11 @@ impl Contract { } /// To remove a market entirely, the curator: - ///- first sets its cap to 0 (disabling new deposits) - ///- then calls submit_market_removal. - /// > This starts a timelock (using the vault’s timelock) - /// - after which the market can be removed from the withdraw_queue (assuming any funds have been withdrawn) - /// Begins the process to remove `market` from the withdraw queue. + /// - first sets its cap to 0 (disabling new deposits) + /// - then calls submit_market_removal. + /// This starts a timelock (using the vault’s timelock), + /// after which the market may be disabled/removed once funds have been withdrawn, if any. + /// Begins the process to remove `market`. /// Requires cap == 0 and no pending cap changes; starts a timelock. pub fn submit_market_removal(&mut self, market: AccountId) { Self::assert_curator_or_owner(); @@ -408,75 +393,4 @@ impl Contract { self.supply_queue.insert(m.clone()); } } - /// For each removed market, we enforce the conditions: - /// Cap is 0 (no new deposits). - /// - /// No pending cap change. - /// - /// If the vault still has a supply in that market (vault_shares_in_market > 0), the market must have had submit_market_removal called (removable_at set) and the timelock must have passed. - /// Sets the ordered withdraw queue. - /// Enforces safety invariants and the policy that all enabled/holding markets must be present. - #[payable] - pub fn set_withdraw_queue(&mut self, queue: Vec) { - Self::assert_allocator(); - self.ensure_idle(); - require!( - queue.len() <= MAX_QUEUE_LEN, - "Withdraw queue length exceeds max" - ); - - let mut seen = HashSet::new(); - for id in &queue { - if !seen.insert(id.clone()) { - env::panic_str(&format!("Duplicate market {id}")); - } - } - - // Snapshot current withdraw queue into a set for membership checks - let current: HashSet = self.withdraw_queue.iter().cloned().collect(); - - for id in &queue { - require!( - self.markets.get(id).is_some(), - "Policy violation: Unknown market in new queue" - ); - } - - for (id, rec) in self.markets.iter() { - let has_supply = rec.principal > 0; - if (rec.cfg.enabled || has_supply) && !seen.contains(id) { - if current.contains(id) { - // Omission is allowed only when removing an existing queued market AND all safety preconditions hold. - require!( - rec.cfg.cap.0 == 0, - "Policy violation: Cannot remove market with non-zero cap" - ); - require!( - rec.pending_cap.is_none(), - "Policy violation: Cannot remove market with pending cap change" - ); - self.aum.policy_removal(&rec.cfg, &has_supply); - } else { - // Not in current queue: must be included if enabled or holding. - env::panic_str( - &format!( - "Invariant violation: Withdraw queue must include all enabled or holding markets; missing {id}" - ), - ); - } - } - } - - let required_yocto = storage_management::yocto_for_queue_additions(¤t, &queue); - let _ = require_attached_at_least(required_yocto, "withdraw queue update"); - - self.withdraw_queue.clear(); - for id in &queue { - self.withdraw_queue.insert(id.clone()); - } - Event::WithdrawQueueUpdated { - markets: queue.clone(), - } - .emit(); - } } diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 286a7912..83987b29 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -5,14 +5,14 @@ use crate::{ }; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{env, json_types::U128, AccountId, NearToken, PromiseError, PromiseOrValue}; -use near_sdk_contract_tools::ft::{nep141::GAS_FOR_FT_TRANSFER_CALL, Nep141Burn}; +use near_sdk_contract_tools::ft::{nep141::GAS_FOR_FT_TRANSFER_CALL, Nep141Burn, Nep141Transfer}; use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ AllocatingState, Event, PayoutState, WithdrawingState, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_2_READ_GAS, - EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, GET_SUPPLY_POSITION_GAS, + GET_SUPPLY_POSITION_GAS, }, }; @@ -158,11 +158,6 @@ impl Contract { rec.principal = new_principal; } - // Invariant: withdraw_queue gains any market with new_principal > 0 - if new_principal > 0 { - self.add_market_to_withdraw_queue(&market, before.0); - } - self.op_state = OpState::Allocating(AllocatingState { op_id, index: market_index.saturating_add(1), @@ -199,23 +194,9 @@ impl Contract { }; if did_create.is_ok() { - if self.defer_market_execute { - // record the created request and pause; executor will pick it up - self.pending_market_exec.push(market_index); - return PromiseOrValue::Value(()); - } else { - return PromiseOrValue::Promise( - ext_market::ext(market.clone()) - .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) - .with_unused_gas_weight(0) - .execute_next_supply_withdrawal_request() - .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_GAS) - .after_exec_withdraw_req(op_id, market_index, need), - ), - ); - } + // Always defer execution: record the created request; keeper must call allocator_execute_next_market_withdrawal(op_id) + self.pending_market_exec.push(market_index); + return PromiseOrValue::Value(()); } else { Event::CreateWithdrawalFailed { op_id: op_id.into(), @@ -366,7 +347,7 @@ impl Contract { let self_id = env::current_account_id(); // We expect the owner to maintain storage accounts, otherwise they will lose access to their funds _self - .transfer_unchecked(&self_id, &owner, escrow_shares) + .transfer(&Nep141Transfer::new(escrow_shares, &self_id, &owner)) .expect("Failed to refund escrowed shares"); _self.op_state = OpState::Idle; PromiseOrValue::Value(()) @@ -437,23 +418,28 @@ impl Contract { // Maybe refund any delta to the owner if refund > 0 { - // Serious issue: this should be infallible - if the transfer panics here we have an escrow settlement error // Note: this should be infallible since we are transferring to an existing owner, and they are unable to unregister from storage - #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&env::current_account_id(), &owner, refund) - .unwrap_or_else(|e| env::log_str(&e.to_string())); - // TODO: emit Refund event + self.transfer(&Nep141Transfer::new( + refund, + &env::current_account_id(), + &owner, + )) + // Serious issue: this should be infallible - if the transfer panics here we have an escrow settlement error + .unwrap_or_else(|e| env::log_str(&e.to_string())); } } else { // On payout failure, refund full escrow to owner and leave idle_balance unchanged - self.transfer_unchecked(&env::current_account_id(), &owner, escrow_shares) - // If this fails, this is a serious issue as above - .unwrap_or_else(|e| env::log_str(&e.to_string())); + self.transfer(&Nep141Transfer::new( + escrow_shares, + &env::current_account_id(), + &owner, + )) + // 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.pending_market_exec.clear(); self.remove_inflight_and_advance_head(); - self.pending_market_exec.clear(); + self.withdraw_route.clear(); self.op_state = OpState::Idle; } @@ -532,6 +518,7 @@ impl Contract { } self.remove_inflight_and_advance_head(); + self.withdraw_route.clear(); self.op_state = OpState::Idle; } @@ -555,6 +542,7 @@ impl Contract { .unwrap_or_else(|e| env::log_str(&e.to_string())); } self.remove_inflight_and_advance_head(); + self.withdraw_route.clear(); self.op_state = OpState::Idle; } @@ -626,11 +614,10 @@ impl Contract { .ok_or(Error::MissingMarket(market_index)) } - /// Resolve a market for withdraw by `withdraw_queue` + /// Resolve a market for withdraw by `withdraw_route` pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result<&AccountId, Error> { - self.withdraw_queue - .iter() - .nth(market_index as usize) + self.withdraw_route + .get(market_index as usize) .ok_or(Error::MissingMarket(market_index)) } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 2977c0e7..0cf026dd 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -9,7 +9,7 @@ use crate::{ aum::AUM, storage_management::{ require_attached_at_least, require_attached_for_pending_withdrawal, - storage_bytes_for_queue_account_id, yocto_for_bytes, yocto_for_new_market, + yocto_for_new_market, }, }; use near_contract_standards::fungible_token::core::ext_ft_core; @@ -23,7 +23,7 @@ use near_sdk::{ use near_sdk_contract_tools::{ ft::{ nep141::GAS_FOR_FT_TRANSFER_CALL, nep145::Nep145ForceUnregister, ContractMetadata, - FungibleToken, Nep141Controller, Nep141Mint, Nep145 as _, Nep148Controller, + FungibleToken, Nep141Controller, Nep141Mint, Nep141Transfer, Nep145 as _, Nep148Controller, }, Owner, Rbac, }; @@ -71,10 +71,10 @@ pub enum Role { /// Can submit/accept cap changes and market removals, and is implicitly granted the Allocator role. Curator, /// Safety backstop that can revoke pending governance changes (e.g., timelock/guardian). - /// Has no authority to change caps or queues on its own. + /// Has no authority to change caps or the supply queue on its own. Guardian, - /// Operational role for queue maintenance. - /// May set the supply/withdraw queues while the vault is Idle; cannot modify caps/timelocks/guardian. + /// Operational role for allocation and withdrawal execution. + /// May set the supply_queue while the vault is Idle; cannot modify caps/timelocks/guardian. Allocator, } @@ -105,13 +105,13 @@ impl From for MarketRecord { /// /// What this contract does (high-level mental model) /// - Issues a share token (NEP-141) that represents a vault over an underlying NEP-141 “BorrowAsset”. -/// - Allocates deposits across “markets” (external contracts) via a supply queue, and withdraws via a withdraw queue. +/// - Allocates deposits across “markets” (external contracts) via a supply queue; withdrawals are keeper-routed (queue-less). /// - Governance uses Owner + RBAC (Curator/Guardian/Allocator) with a timelock for certain changes. /// - Withdraw flow escrows shares, builds market-side withdrawal requests, then pays out and burns proportional escrow. /// - Performance fees accrue by minting fee shares based on increases in total assets. /// Critical invariants the code intends to keep /// - Assets accounting is correct: total_assets = idle_balance + sum(all principals in markets). -/// - Withdraw queue contains every market that either is enabled or still holds principal (until that principal is zero). +/// - Withdrawals are queue-less and routed per-execution using an ephemeral, keeper-provided route. /// - Only one op in flight (op_state); mutating ops require Idle. /// - Governance changes obey timelocks; Guardian may revoke pending changes. /// @@ -150,8 +150,6 @@ pub struct Contract { /// Ordered list of market IDs for deposit allocation supply_queue: BTreeSet, - /// Ordered list of market IDs for withdrawal priority - withdraw_queue: BTreeSet, current_withdraw_inflight: Option, // id of the pending withdrawal being executed, if any @@ -165,10 +163,11 @@ pub struct Contract { next_withdraw_id: u64, next_withdraw_to_execute: u64, - // if true, only create requests during build; executor will run them - defer_market_execute: bool, // indices of markets with created requests (per withdrawing op) pending_market_exec: Vec, + + // Keeper-provided withdraw route for the current Withdrawing op + withdraw_route: Vec, } #[near] @@ -220,7 +219,7 @@ impl Contract { let mut contract = Self { underlying_asset: underlying_token, - aum: AUM::GovernanceAbandonment, + aum: AUM::BalanceSheet, timelock_ns: initial_timelock_ns.0, performance_fee: Default::default(), fee_recipient, @@ -229,7 +228,6 @@ impl Contract { pending_timelock: None, pending_guardian: None, supply_queue: Default::default(), - withdraw_queue: Default::default(), last_total_assets: 0, virtual_shares: 1, virtual_assets: 1, @@ -245,9 +243,10 @@ impl Contract { next_withdraw_id: 0, next_withdraw_to_execute: 0, - // Deferred market execution - defer_market_execute: true, // default to “stop executing automatically” per request pending_market_exec: Vec::new(), + + // Keeper-provided withdraw route for the current Withdrawing op + withdraw_route: Vec::new(), }; contract.set_metadata(&ContractMetadata::new(name, symbol, decimals.into())); Owner::init(&mut contract, &owner); @@ -280,9 +279,12 @@ impl Contract { let _ = require_attached_for_pending_withdrawal(); // Move shares into escrow - #[allow(clippy::expect_used, reason = "No side effects")] - self.transfer_unchecked(&sender, &env::current_account_id(), shares) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + self.transfer(&Nep141Transfer::new( + shares, + &sender, + env::current_account_id(), + )) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); self.internal_accrue_fee(); @@ -298,7 +300,7 @@ impl Contract { /// Executes the next pending withdrawal request, if any, using the existing withdraw pipeline. /// This defers creating market-side withdrawal requests until explicitly invoked. - pub fn execute_next_withdrawal_request(&mut self) -> PromiseOrValue<()> { + pub fn execute_next_withdrawal_request(&mut self, route: Vec) -> PromiseOrValue<()> { require_at_least(EXECUTE_WITHDRAW_GAS); self.ensure_idle(); Self::assert_allocator(); @@ -321,6 +323,7 @@ impl Contract { &receiver, &owner, pending.escrow_shares, + route, ); } @@ -645,33 +648,6 @@ impl Contract { .saturating_sub(self.principal_of(market)) } - fn in_withdraw_queue(&self, market: &AccountId) -> bool { - self.withdraw_queue.iter().any(|m| m == market) - } - - // Add market to withdraw_queue and adjust last_total_assets if re-adding with existing principal - pub(crate) fn add_market_to_withdraw_queue( - &mut self, - market: &AccountId, - before_principal: u128, - ) { - if self.in_withdraw_queue(market) { - Event::MarketAlreadyInWithdrawQueue { - market: market.clone(), - } - .emit(); - return; - } - self.withdraw_queue.insert(market.clone()); - Event::WithdrawQueueMarketAdded { - market: market.clone(), - } - .emit(); - self.aum - .clone() - .paper_aum_undercounting(self, &before_principal); - } - /// Enqueue a vault-level pending withdrawal request (escrow already taken). fn enqueue_pending_withdrawal( &mut self, @@ -1024,6 +1000,7 @@ impl Contract { receiver: &AccountId, owner: &AccountId, escrow_shares: u128, + route: Vec, ) -> PromiseOrValue<()> { if amount == 0 { return self.stop_and_exit(Some(&Error::ZeroAmount)); @@ -1038,6 +1015,7 @@ impl Contract { let collected = used_idle; self.pending_market_exec.clear(); + self.withdraw_route = route; self.op_state = OpState::Withdrawing(WithdrawingState { op_id, @@ -1052,27 +1030,18 @@ impl Contract { } fn step_withdraw(&mut self) -> PromiseOrValue<()> { - let (op_id, index, remaining, receiver, collected, owner, escrow_shares) = - match &self.op_state { - OpState::Withdrawing(WithdrawingState { - op_id, - index, - remaining, - receiver, - collected, - owner, - escrow_shares, - }) => ( - *op_id, - *index, - *remaining, - receiver.clone(), - *collected, - owner.clone(), - *escrow_shares, - ), - _ => return self.stop_and_exit(Some(&Error::NotWithdrawing)), - }; + let OpState::Withdrawing(WithdrawingState { + op_id, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + }) = self.op_state.clone() + else { + return self.stop_and_exit(Some(&Error::NotWithdrawing)); + }; if remaining == 0 { self.op_state = OpState::Payout(PayoutState { @@ -1093,7 +1062,7 @@ impl Contract { ), ); } - if let Some(market) = self.withdraw_queue.iter().nth(index as usize) { + 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 { @@ -1134,6 +1103,7 @@ impl Contract { burn_shares, |_self| { // Park the head pending: keep escrowed shares, stay in queue, try again later + _self.withdraw_route.clear(); _self.op_state = OpState::Idle; _self.park_inflight_head_for_retry(); PromiseOrValue::Value(()) diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index b2109221..a876f4f1 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -78,7 +78,6 @@ pub fn ensure_market( cap: u128, enabled: bool, supply: u128, - in_withdraw: bool, in_supply: bool, removable_at: u64, ) { @@ -94,9 +93,6 @@ pub fn ensure_market( principal: supply, }, ); - if in_withdraw && !c.withdraw_queue.iter().any(|m| m == &id) { - c.withdraw_queue.insert(id.clone()); - } if in_supply && !c.supply_queue.iter().any(|m| m == &id) { c.supply_queue.insert(id.clone()); } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 7bd84a14..ef0066a7 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -127,29 +127,6 @@ fn prop_supply_queue_mustnt_have_duplicates(len: usize) { c.set_supply_queue(queue); } -#[rstest(len => [2usize, 3, 5])] -#[should_panic = "Duplicate market"] -fn prop_withdraw_queue_mustnt_have_duplicates(len: usize) { - let mut c = new_test_contract(&mk(0)); - setup_env(&accounts(0), &accounts(1), vec![]); - - // Build a queue with a duplicate market id - let base = 200u32; - let dup = mk(base); - let mut queue: Vec = Vec::with_capacity(len); - if len >= 1 { - queue.push(dup.clone()); - } - for i in 1..len.saturating_sub(1) { - queue.push(mk(base + i as u32)); - } - if len >= 2 { - queue.push(dup); - } - - c.set_withdraw_queue(queue); -} - #[rstest] fn fee_accrues_only_on_growth_unit(c_vault_env: Contract) { let mut c = c_vault_env; @@ -260,7 +237,7 @@ fn execute_next_withdrawal_request_skips_holes(c_owner_env: Contract) { .insert(3, make(owner.clone(), recv.clone())); // First call should consume id=1 and advance head to 2 - let _ = c.execute_next_withdrawal_request(); + let _ = c.execute_next_withdrawal_request(vec![]); assert_eq!(c.next_withdraw_to_execute, 2); assert_eq!(c.balance_of(&vault_id), 20); @@ -270,7 +247,7 @@ fn execute_next_withdrawal_request_skips_holes(c_owner_env: Contract) { assert_eq!(c.balance_of(&vault_id), 20); // Second call should consume id=3 and advance head to 4 - let _ = c.execute_next_withdrawal_request(); + let _ = c.execute_next_withdrawal_request(vec![]); assert_eq!(c.next_withdraw_to_execute, 4); } @@ -284,49 +261,6 @@ fn set_supply_queue_rejects_zero_cap() { c.set_supply_queue(vec![mk(100)]); } -#[test] -#[should_panic = "Withdraw queue must include all enabled or holding markets"] -fn set_withdraw_queue_must_include_all_holding() { - let mut c = new_test_contract(&mk(0)); - setup_env(&mk(0), &accounts(1), vec![]); - - let m1 = mk(103); - let m2 = mk(104); - - // Both known; m1 has supply > 0 - c.markets.insert( - m1.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 0, - }, - ); - c.markets.insert( - m2.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 0, - }, - ); - if let Some(rec) = c.markets.get_mut(&m1) { - rec.principal = 10; - } else { - c.markets.insert( - m1.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 10, - }, - ); - } - - // Missing m1 should panic - c.set_withdraw_queue(vec![m2]); -} - #[rstest] fn execute_supply_wrong_token_refunds_full(c_vault_env: Contract) { let mut c = c_vault_env; @@ -341,32 +275,6 @@ fn execute_supply_wrong_token_refunds_full(c_vault_env: Contract) { assert_eq!(c.idle_balance, 0, "idle must remain unchanged"); } -#[test] -#[should_panic = "Withdraw queue must include all enabled or holding markets"] -fn set_withdraw_queue_must_include_all_enabled() { - 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![], - ); - - let m1 = mk(101); - let m2 = mk(102); - - // m1 enabled, m2 disabled; provide both configs - let mut cfg1 = MarketConfiguration::default(); - cfg1.enabled = true; - c.markets.insert(m1.clone(), cfg1.into()); - c.markets - .insert(m2.clone(), MarketConfiguration::default().into()); - - // Missing m1 should panic - c.set_withdraw_queue(vec![m2]); -} - #[rstest] fn start_allocation_reserves_only_amount(c_vault_env: Contract) { let mut c = c_vault_env; @@ -407,9 +315,6 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { }, ); } - if !c.withdraw_queue.iter().any(|x| x == &m1) { - c.withdraw_queue.insert(m1.clone()); - } // Force completion and exit op if let crate::OpState::Allocating(AllocatingState { op_id, index, .. }) = c.op_state.clone() { c.op_state = crate::OpState::Allocating(AllocatingState { @@ -455,7 +360,6 @@ fn queue_allocation_ignores_stale_plan() { cfg1.cap = U128(10); cfg1.enabled = true; c.markets.insert(m1.clone(), cfg1.into()); - c.withdraw_queue.insert(m1.clone()); c.supply_queue.insert(m1); // Stale plan (should be ignored for queue-based allocation) @@ -473,40 +377,6 @@ fn queue_allocation_ignores_stale_plan() { ); } -#[test] -#[should_panic = "Market still has supply but no removal scheduled"] -fn set_withdraw_queue_disallow_nonzero_position_removal() { - 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![], - ); - - let m1 = mk(4001); - - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(0); // required precondition to attempt removal - cfg.enabled = true; - c.markets.insert( - m1.clone(), - MarketRecord { - cfg, - pending_cap: None, - // Market has non-zero position but no removal scheduled - principal: 1, - }, - ); - - // Present in current withdraw queue so removal logic executes - c.withdraw_queue.insert(m1); - - // Attempting to remove should panic due to non-zero position without removal schedule - c.set_withdraw_queue(vec![]); -} - #[rstest( escrow, collected, requested, expect, case(100u128, 200u128, 500u128, 40u128), // 40% @@ -556,54 +426,6 @@ fn compute_escrow_settlement_burns_min_and_refunds_rest() { assert_eq!(s3, (0u128, 0u128)); } -#[test] -fn removing_holding_market_keeps_config_and_supply_on_writedown() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); - let owner = c.own_get_owner().unwrap(); - - let m = mk(7001); - - // Market is known, holding > 0, with cap=0 and removal already scheduled. - // This satisfies current preconditions in set_withdraw_queue for omission. - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(0); - cfg.enabled = true; - cfg.removable_at = 1; // scheduled in the past relative to the block timestamp we set below - c.markets.insert( - m.clone(), - MarketRecord { - cfg, - pending_cap: None, - principal: 10, - }, - ); - - // Present in current withdraw queue - c.withdraw_queue.insert(m.clone()); - - // Advance block timestamp so timelock precondition passes - set_block_ts(&vault_id, &owner, 2); - - // Remove the market from the queue (new queue empty) - c.set_withdraw_queue(vec![]); - - // Markets removed from queue but the config still exists and the supply - assert!(c.markets.get(&m).is_some(), "Config should still exist"); - assert_eq!( - c.markets.get(&m).map(|s| s.principal).unwrap_or_default(), - 10, - "Principal remains in market_supply" - ); - - // Total assets now undercount because get_total_assets sums withdraw_queue only - assert_eq!( - c.get_total_assets().0, - c.idle_balance, // withdraw_queue is empty, so principal is ignored - "Total assets should not silently drop due to queue-based accounting" - ); -} - #[test] fn cap_zero_keeps_enabled_and_submit_removal_works() { let vault_id = accounts(0); @@ -670,17 +492,13 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { &vault_id, &owner, Some(env::block_timestamp() + 1_000_000_000), - Some(yocto_for_bytes(storage_bytes_for_queue_account_id())), + None, ); c.accept_cap(m.clone()); let cfg1 = &c.markets.get(&m).unwrap().cfg; assert_eq!(cfg1.cap.0, raise); assert!(cfg1.enabled, "market should be enabled after raise"); - assert!( - c.withdraw_queue.iter().any(|x| x == &m), - "market must be in withdraw queue after enabling" - ); // Now lower back to 0 (immediate path) and ensure enabled stays true c.submit_cap(m.clone(), U128(0)); @@ -689,140 +507,6 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { assert!(cfg2.enabled, "enabled must remain true on cap=0"); } -#[test] -#[should_panic = "Policy violation: Cannot remove market with non-zero cap"] -fn set_withdraw_queue_disallow_nonzero_cap_removal() { - 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![], - ); - - let m = mk(5000); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(1); // non-zero cap - cfg.enabled = true; // must be enabled or holding to trigger invariant - c.markets.insert(m.clone(), cfg.into()); - c.withdraw_queue.insert(m.clone()); - - // Attempt to remove from queue should panic due to non-zero cap - c.set_withdraw_queue(vec![]); -} - -#[test] -#[should_panic = "Policy violation: Cannot remove market with pending cap change"] -fn set_withdraw_queue_disallow_pending_cap_removal() { - 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![]); - - let m = mk(5001); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(0); - cfg.enabled = true; - c.markets.insert( - m.clone(), - MarketRecord { - cfg, - pending_cap: None, - principal: 0, - }, - ); - c.withdraw_queue.insert(m.clone()); - - // Insert a pending cap change - c.markets.get_mut(&m).unwrap().pending_cap = Some(templar_common::vault::PendingValue { - value: 1, - valid_at_ns: env::block_timestamp() + 1, - }); - - // Attempt to remove from queue should panic due to pending cap change - c.set_withdraw_queue(vec![]); -} - -#[test] -#[should_panic = "Policy violation: Removal timelock not elapsed for market"] -fn set_withdraw_queue_disallow_timelock_not_elapsed() { - 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![]); - - let m = mk(5002); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(0); - cfg.enabled = true; - cfg.removable_at = 10; // in the future relative to block timestamp we set below - c.markets.insert( - m.clone(), - MarketRecord { - cfg, - pending_cap: None, - principal: 0, - }, - ); - if let Some(rec) = c.markets.get_mut(&m) { - rec.principal = 1; // non-zero supply enforces timelock path - } - c.withdraw_queue.insert(m.clone()); - - // Set block timestamp below removable_at so timelock has not elapsed - set_block_ts(&vault_id, &owner, 5); - - // Attempt to remove from queue should panic due to timelock not elapsed - c.set_withdraw_queue(vec![]); -} - -#[test] -fn set_withdraw_queue_allows_zero_supply_removal() { - 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![], - ); - - let m = mk(5003); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(0); - cfg.enabled = true; - // removable_at irrelevant when supply is zero - c.markets.insert(m.clone(), cfg.into()); - c.withdraw_queue.insert(m.clone()); - - // Supply is zero; removal should be allowed immediately - c.set_withdraw_queue(vec![]); - - let ma = c.markets.get(&m); - assert!( - ma.is_some(), - "Config must not be removed for governance writedowns" - ); - // And the queue should be empty - assert!( - !c.withdraw_queue.iter().any(|x| x == &m), - "Withdraw queue must not contain the removed market" - ); -} - -#[test] -#[should_panic = "Policy violation: Unknown market in new queue"] -fn set_withdraw_queue_rejects_unknown_market() { - let vault_id = accounts(0); - let mut c = new_test_contract(&vault_id); - setup_env(&vault_id, &c.own_get_owner().unwrap(), vec![]); - - // No config for this market - let unknown = mk(5999); - c.set_withdraw_queue(vec![unknown]); -} - #[rstest( before, new_principal, @@ -938,7 +622,7 @@ fn clamp_allocation_total_matches_min_bounds_cases( case(0u128, 456u128), case(789u128, 1_011u128) )] -fn total_assets_ignores_offqueue_cases(principal: u128, idle: u128) { +fn total_assets_sums_all_markets_cases(principal: u128, idle: u128) { let vault_id = accounts(0); setup_env(&vault_id, &vault_id, vec![]); @@ -955,7 +639,7 @@ fn total_assets_ignores_offqueue_cases(principal: u128, idle: u128) { ); c.idle_balance = idle; - assert_eq!(c.get_total_assets().0, idle); + assert_eq!(c.get_total_assets().0, idle.saturating_add(principal)); } #[test] @@ -1734,7 +1418,7 @@ fn governance_submit_and_accept_cap_new_market_creates_and_enables() { &vault_id, &owner, Some(env::block_timestamp() + 1_000_000_000), - Some(yocto_for_bytes(storage_bytes_for_queue_account_id())), + None, ); c.accept_cap(m.clone()); @@ -1744,10 +1428,6 @@ fn governance_submit_and_accept_cap_new_market_creates_and_enables() { cfg.enabled, "market should be enabled after accepting raise" ); - assert!( - c.withdraw_queue.iter().any(|x| x == &m), - "market must be in withdraw queue after enabling" - ); } #[test] @@ -1844,36 +1524,6 @@ fn governance_set_fee_recipient_no_fee_does_not_accrue() { assert_eq!(c.fee_recipient, new_recipient); } -#[test] -fn governance_set_withdraw_queue_happy_path() { - 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 enabled markets - let m1 = mk(9201); - let m2 = mk(9202); - for m in [&m1, &m2] { - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(1); - cfg.enabled = true; - c.markets.insert(m.clone(), cfg.into()); - } - - set_ctx( - &vault_id, - &owner, - None, - Some(2 * yocto_for_bytes(storage_bytes_for_queue_account_id())), - ); - c.set_withdraw_queue(vec![m1.clone(), m2.clone()]); - - assert_eq!(c.withdraw_queue.len(), 2); - assert_eq!(c.withdraw_queue.iter().next(), Some(&m1)); - assert_eq!(c.withdraw_queue.iter().nth(1), Some(&m2)); -} - #[test] #[should_panic = "Refusing to skim the underlying token"] fn skim_rejects_underlying_token() { @@ -1935,7 +1585,6 @@ fn after_supply_1_check_allocating_not_allocating_index() { let mut c = new_test_contract(&vault_id); let op_id = 1; - let receiver = mk(7); c.op_state = OpState::Allocating(AllocatingState { op_id, @@ -2008,7 +1657,7 @@ fn after_send_to_user_success_no_escrow() { 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_queue.insert(market.clone()); + c.withdraw_route = vec![market.clone()]; c.markets.insert( market.clone(), MarketRecord { @@ -2147,9 +1796,9 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { setup_env(&vault_id, &vault_id, vec![]); let mut c = new_test_contract(&vault_id); - // Single-market queue so advancing index reaches end-of-queue + // Single-market route so advancing index reaches end-of-route let market = mk(8); - c.withdraw_queue.insert(market.clone()); + c.withdraw_route = vec![market.clone()]; c.markets.insert( market.clone(), MarketRecord { @@ -2195,7 +1844,7 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect let mut c = new_test_contract(&vault_id); let market = mk(8); - c.withdraw_queue.insert(market.clone()); + c.withdraw_route = vec![market.clone()]; c.markets.insert( market.clone(), MarketRecord { @@ -2261,7 +1910,7 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde let mut c = new_test_contract(&vault_id); let market = mk(8); - c.withdraw_queue.insert(market.clone()); + c.withdraw_route = vec![market.clone()]; c.markets.insert( market.clone(), MarketRecord { @@ -2316,10 +1965,6 @@ fn refund_path_consistency() { c.deposit_unchecked(&near_sdk::env::current_account_id(), 10) .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); - // Single-market withdraw queue (not used functionally here, just to satisfy path) - let market = mk(12); - c.withdraw_queue.insert(market); - // Withdrawing state with remaining=0 and collected=0 forces refund path c.op_state = OpState::Withdrawing(WithdrawingState { op_id: 77, @@ -2449,8 +2094,7 @@ fn resolve_market_helpers_supply_and_withdraw() { )); // Withdraw resolver uses withdraw_queue - c.withdraw_queue.insert(m1.clone()); - c.withdraw_queue.insert(m2.clone()); + c.withdraw_route = vec![m1.clone(), m2.clone()]; assert_eq!(c.resolve_withdraw_market(0).unwrap(), &m1); assert_eq!(c.resolve_withdraw_market(1).unwrap(), &m2); assert!(matches!( @@ -2525,7 +2169,7 @@ fn after_create_withdraw_req_success_returns_promise( owner: AccountId, ) { let market = mk(50); - c.withdraw_queue.insert(market.clone()); + c.withdraw_route = vec![market.clone()]; c.markets.insert( market.clone(), MarketRecord { @@ -2547,17 +2191,17 @@ fn after_create_withdraw_req_success_returns_promise( let res = c.after_create_withdraw_req(Ok(()), 21, 0, U128(60)); match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise when create succeeds"), + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) when create succeeds and execution is deferred"), } - // State remains Withdrawing and will continue via the promise chain + // State remains Withdrawing; keeper must call allocator_execute_next_market_withdrawal assert!(matches!(c.op_state, OpState::Withdrawing { .. })); } #[rstest] fn after_exec_withdraw_req_returns_promise(mut c: Contract) { let market = mk(60); - c.withdraw_queue.insert(market.clone()); + c.withdraw_route = vec![market.clone()]; c.markets.insert( market.clone(), MarketRecord { @@ -2597,8 +2241,7 @@ fn after_exec_withdraw_read_advances_when_remaining( // Two markets; first has principal to withdraw let m1 = mk(70); let m2 = mk(71); - c.withdraw_queue.insert(m1.clone()); - c.withdraw_queue.insert(m2.clone()); + c.withdraw_route = vec![m1.clone(), m2.clone()]; c.markets.insert( m1.clone(), MarketRecord { diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index ec048f6f..3d55122e 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -86,7 +86,16 @@ async fn happy(#[future(awt)] worker: Worker) { let user_balance = c.borrow_asset.balance_of(supply_user.id()).await; vault.withdraw(&supply_user, amount, None).await; - vault.execute_next_withdrawal(&vault_curator).await; + // Ensure deposits are activated before we attempt to route and execute the withdrawal + harvest(&c, &vault).await; + // Plan the withdraw route (single market) and execute it via allocator methods + let withdraw_route = vec![c.market.contract().id().clone()]; + let op_id = vault + .execute_next_withdrawal_request(&vault_curator, withdraw_route.clone()) + .await; + vault + .allocator_execute_next_market_withdrawal(&vault_curator, op_id) + .await; assert_eq!( c.borrow_asset.balance_of(supply_user.id()).await, @@ -121,7 +130,16 @@ async fn happy(#[future(awt)] worker: Worker) { .withdraw(&supply_user, (amount.0 - borrowed).into(), None) .await; - vault.execute_next_withdrawal(&vault_curator).await; + // Ensure deposits are activated before we attempt to route and execute the withdrawal + harvest(&c, &vault).await; + // Plan the withdraw route (single market) and execute it via allocator methods + let withdraw_route = vec![c.market.contract().id().clone()]; + let op_id = vault + .execute_next_withdrawal_request(&vault_curator, withdraw_route.clone()) + .await; + vault + .allocator_execute_next_market_withdrawal(&vault_curator, op_id) + .await; } // FIXME: should also do this in allocate on behalf of the vault? From 165c31e87308dac8ae0c374a62338529b959f1b1 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 3 Nov 2025 10:27:30 +0000 Subject: [PATCH 102/121] refactor!: callback names are more descriptive --- common/src/vault.rs | 25 +---- contract/vault/src/impl_callbacks.rs | 134 ++++++++++++--------------- contract/vault/src/lib.rs | 104 +++++++++++---------- contract/vault/src/test_utils.rs | 36 +++++-- contract/vault/src/tests.rs | 54 +++++------ contract/vault/tests/happy_path.rs | 18 +++- test-utils/src/controller/vault.rs | 46 +++++++-- 7 files changed, 226 insertions(+), 191 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 94025787..d3f2f869 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -175,15 +175,16 @@ pub const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = // TODO: rename const AFTER_EXECUTE_NEXT_WITHDRAW: u64 = 5 + 5 + AFTER_SEND_TO_USER; -pub const AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_WITHDRAW); +pub const EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_WITHDRAW); // todo: rename const AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ: u64 = GET_SUPPLY_POSITION + AFTER_EXECUTE_NEXT_WITHDRAW; -pub const AFTER_EXECUTE_NEXT_WITHDRAW_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); +pub const EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS: Gas = + buffer(AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); const AFTER_SUPPLY_2_READ: u64 = 5; -pub const AFTER_SUPPLY_2_READ_GAS: Gas = buffer(AFTER_SUPPLY_2_READ); +pub const SUPPLY_02_POSITION_READ_GAS: Gas = buffer(AFTER_SUPPLY_2_READ); pub const AFTER_SUPPLY_1_CHECK_GAS: Gas = buffer(GET_SUPPLY_POSITION + AFTER_SUPPLY_2_READ); // NOTE: these are taken after running the contract with the gas report and cieled to next whole TGAS. @@ -206,24 +207,6 @@ pub fn require_at_least(needed: Gas) { ); } -#[near_sdk::ext_contract(ext_self)] -pub trait Callbacks { - fn after_supply_1_check(&mut self, op_id: u64, market_index: u32, attempted: U128) -> bool; - fn after_supply_2_read( - &mut self, - op_id: u64, - market_index: u32, - before: U128, - attempted: U128, - accepted: U128, - ) -> bool; - fn after_create_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; - fn after_exec_withdraw_req(&mut self, op_id: u64, market_index: u32, need: U128) -> bool; - fn after_exec_withdraw_read(&mut self, op_id: u64, market_index: u32, before: U128, need: U128); - fn after_send_to_user(&mut self, op_id: u64, receiver: AccountId, amount: U128) -> bool; - fn after_skim_balance(&mut self, token: AccountId, recipient: AccountId) -> bool; -} - #[derive(Clone, Debug)] #[near] pub struct PendingValue { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 83987b29..3e6d87d3 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -1,8 +1,6 @@ use std::fmt::Display; -use crate::{ - ext_self, near, Contract, ContractExt, Error, EscrowSettlement, Nep141Controller, OpState, -}; +use crate::{near, Contract, ContractExt, Error, EscrowSettlement, Nep141Controller, OpState}; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{env, json_types::U128, AccountId, NearToken, PromiseError, PromiseOrValue}; use near_sdk_contract_tools::ft::{nep141::GAS_FOR_FT_TRANSFER_CALL, Nep141Burn, Nep141Transfer}; @@ -10,9 +8,9 @@ use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - AllocatingState, Event, PayoutState, WithdrawingState, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, - AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_2_READ_GAS, - GET_SUPPLY_POSITION_GAS, + AllocatingState, Event, PayoutState, WithdrawingState, + EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS, GET_SUPPLY_POSITION_GAS, + SUPPLY_02_POSITION_READ_GAS, }, }; @@ -28,7 +26,7 @@ use templar_common::{ #[near] impl Contract { #[private] - pub fn after_supply_1_check( + pub fn supply_01_handle_transfer( &mut self, // NOTE: we can't rely on this as a `true` value of accepted, so we are taking a belt-and-braces approach of // querying the supply position @@ -66,9 +64,9 @@ impl Contract { .with_unused_gas_weight(0) .get_supply_position(env::current_account_id()) .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_SUPPLY_2_READ_GAS) - .after_supply_2_read( + Self::ext(env::current_account_id()) + .with_static_gas(SUPPLY_02_POSITION_READ_GAS) + .supply_02_position_read( op_id, market_index, U128(before), @@ -82,7 +80,7 @@ impl Contract { } #[private] - pub fn after_supply_2_read( + pub fn supply_02_position_read( &mut self, #[callback_result] position: Result, PromiseError>, op_id: u64, @@ -171,21 +169,20 @@ impl Contract { } #[private] - pub fn after_create_withdraw_req( + pub fn withdraw_01_handle_create_request( &mut self, #[callback_result] did_create: Result<(), PromiseError>, op_id: u64, market_index: u32, need: U128, ) -> PromiseOrValue<()> { - let (i, remaining, received, collected, owner, escrow_shares) = - match self.ctx_withdrawing(op_id) { - Ok(v) => v, - Err(e) => return self.stop_and_exit(Some(&e)), - }; + let ctx = match self.ctx_withdrawing(op_id) { + Ok(s) => s, + 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 ctx.index != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); } let market = match self.resolve_withdraw_market(market_index) { @@ -194,44 +191,44 @@ impl Contract { }; if did_create.is_ok() { - // Always defer execution: record the created request; keeper must call allocator_execute_next_market_withdrawal(op_id) + // Always defer execution: record the created request; keeper must call execute_next_market_withdrawal(op_id) self.pending_market_exec.push(market_index); return PromiseOrValue::Value(()); } else { Event::CreateWithdrawalFailed { op_id: op_id.into(), market: market.clone(), - index: i, + index: ctx.index, need, } .emit(); self.op_state = OpState::Withdrawing(WithdrawingState { op_id, index: market_index.saturating_add(1), - remaining, - receiver: received, - collected, - owner, - escrow_shares, + remaining: ctx.remaining, + receiver: ctx.receiver.clone(), + collected: ctx.collected.clone(), + owner: ctx.owner.clone(), + escrow_shares: ctx.escrow_shares, }); self.step_withdraw() } } #[private] - pub fn after_exec_withdraw_req( + pub fn execute_withdraw_01_fetch_position( &mut self, op_id: u64, market_index: u32, need: U128, ) -> PromiseOrValue<()> { - let (i, _, _, _, _, _) = match self.ctx_withdrawing(op_id) { + let ctx = match self.ctx_withdrawing(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 ctx.index != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); } let market = match self.resolve_withdraw_market(market_index) { @@ -247,9 +244,14 @@ impl Contract { .with_unused_gas_weight(0) .get_supply_position(env::current_account_id()) .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_READ_GAS) - .after_exec_withdraw_read(op_id, market_index, U128(before), need), + Self::ext(env::current_account_id()) + .with_static_gas(EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS) + .execute_withdraw_02_reconcile_position( + op_id, + market_index, + U128(before), + need, + ), ), ) } @@ -261,7 +263,7 @@ impl Contract { /// - Later in after_send_to_user, idle_balance is decremented on successful transfer to the user. /// - On transfer failure, idle_balance stays unchanged and escrowed shares are refunded to the owner. #[private] - pub fn after_exec_withdraw_read( + pub fn execute_withdraw_02_reconcile_position( &mut self, #[callback_result] position: Result, PromiseError>, op_id: u64, @@ -269,14 +271,14 @@ impl Contract { before: U128, need: U128, ) -> PromiseOrValue<()> { - let (i, remaining_ctx, receiver, collected_ctx, owner, escrow_shares) = - match self.ctx_withdrawing(op_id) { - Ok(v) => v, - Err(e) => return self.stop_and_exit(Some(&e)), - }; + let ctx = match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), + } + .clone(); - if i != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(i, market_index))); + if ctx.index != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); } let market = match self.resolve_withdraw_market(market_index) { @@ -315,8 +317,8 @@ impl Contract { } = reconcile_withdraw_outcome( before_principal, new_principal, - remaining_ctx, - collected_ctx, + ctx.remaining, + ctx.collected, ); if let Some(rec) = self.markets.get_mut(&market.clone()) { @@ -337,17 +339,21 @@ impl Contract { if remaining_next == 0 { self.pay_collected( op_id, - &receiver, + &ctx.receiver, collected_next, - &owner, - escrow_shares, - escrow_shares, + &ctx.owner, + ctx.escrow_shares, + ctx.escrow_shares, |_self| { // Nothing collected; refund escrowed shares let self_id = env::current_account_id(); // We expect the owner to maintain storage accounts, otherwise they will lose access to their funds _self - .transfer(&Nep141Transfer::new(escrow_shares, &self_id, &owner)) + .transfer(&Nep141Transfer::new( + ctx.escrow_shares, + &self_id, + &ctx.owner, + )) .expect("Failed to refund escrowed shares"); _self.op_state = OpState::Idle; PromiseOrValue::Value(()) @@ -358,10 +364,10 @@ impl Contract { op_id, index: market_index.saturating_add(1), remaining: remaining_next, - receiver, + receiver: ctx.receiver, collected: collected_next, - owner, - escrow_shares, + owner: ctx.owner, + escrow_shares: ctx.escrow_shares, }); self.step_withdraw() } @@ -372,7 +378,7 @@ impl Contract { /// - On success: idle_balance -= amount; burn a portion of escrow_shares and refund the rest to the owner. /// - On failure: refund full escrow_shares to the owner and keep idle_balance unchanged (funds remain in vault). #[private] - pub fn after_send_to_user( + pub fn payment_01_reconcile_idle_or_refund( &mut self, #[callback_result] result: Result<(), PromiseError>, op_id: u64, @@ -444,7 +450,7 @@ impl Contract { } #[private] - pub fn after_skim_balance( + pub fn skim_01_read_balance( &mut self, #[callback_result] balance: Result, token: AccountId, @@ -577,27 +583,9 @@ impl Contract { } /// Validate current op is Withdrawing and return context tuple - pub(crate) fn ctx_withdrawing( - &self, - op_id: u64, - ) -> Result<(u32, u128, AccountId, u128, AccountId, u128), Error> { + pub(crate) fn ctx_withdrawing(&self, op_id: u64) -> Result<&WithdrawingState, Error> { match &self.op_state { - OpState::Withdrawing(WithdrawingState { - op_id: cur, - index, - remaining, - receiver, - collected, - owner, - escrow_shares, - }) if *cur == op_id => Ok(( - *index, - *remaining, - receiver.clone(), - *collected, - owner.clone(), - *escrow_shares, - )), + OpState::Withdrawing(s) if s.op_id == op_id => Ok(s), _ => Err(Error::NotWithdrawing), } } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 0cf026dd..da863063 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -8,8 +8,7 @@ use std::{ use crate::{ aum::AUM, storage_management::{ - require_attached_at_least, require_attached_for_pending_withdrawal, - yocto_for_new_market, + require_attached_at_least, require_attached_for_pending_withdrawal, yocto_for_new_market, }, }; use near_contract_standards::fungible_token::core::ext_ft_core; @@ -32,12 +31,12 @@ use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ - ext_self, require_at_least, AllocatingState, AllocationMode, AllocationPlan, - AllocationWeights, Error, Event, MarketConfiguration, OpState, PayoutState, PendingValue, - PendingWithdrawal, TimestampNs, VaultConfiguration, WithdrawingState, - AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_EXECUTE_NEXT_WITHDRAW_GAS, AFTER_SEND_TO_USER_GAS, - AFTER_SUPPLY_1_CHECK_GAS, ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_GAS, - MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, + require_at_least, AllocatingState, AllocationMode, AllocationPlan, AllocationWeights, + Error, Event, 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_NEXT_SUPPLY_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::*; @@ -103,24 +102,23 @@ impl From for MarketRecord { /// Vault contract that issues shares over an underlying fungible asset and allocates liquidity /// across configured markets. Implements 4626-like deposit/withdraw semantics. /// -/// What this contract does (high-level mental model) +/// What this contract does /// - Issues a share token (NEP-141) that represents a vault over an underlying NEP-141 “BorrowAsset”. -/// - Allocates deposits across “markets” (external contracts) via a supply queue; withdrawals are keeper-routed (queue-less). +/// - Allocates deposits across “markets” via a supply queue; withdrawals are keeper-routed via a queueless mechanism. /// - Governance uses Owner + RBAC (Curator/Guardian/Allocator) with a timelock for certain changes. /// - Withdraw flow escrows shares, builds market-side withdrawal requests, then pays out and burns proportional escrow. /// - Performance fees accrue by minting fee shares based on increases in total assets. -/// Critical invariants the code intends to keep +/// Critical invariants /// - Assets accounting is correct: total_assets = idle_balance + sum(all principals in markets). -/// - Withdrawals are queue-less and routed per-execution using an ephemeral, keeper-provided route. /// - Only one op in flight (op_state); mutating ops require Idle. /// - Governance changes obey timelocks; Guardian may revoke pending changes. /// -/// Note: RBAC storage (role membership) is paid by the contract; callers are not charged deposits for RBAC changes. +/// Note: RBAC storage is paid by the contract; callers are not charged deposits for RBAC changes. pub struct Contract { /// The underlying asset that the vault manages underlying_asset: FungibleAsset, - /// The process in which the vault calculates its assets under management (AUM) + /// The process in which the vault calculates its assets under management aum: AUM, /// The mode in which the allocator will operate @@ -151,7 +149,8 @@ pub struct Contract { /// Ordered list of market IDs for deposit allocation supply_queue: BTreeSet, - current_withdraw_inflight: Option, // 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, @@ -237,15 +236,10 @@ impl Contract { mode, plan: None, current_withdraw_inflight: None, - - // Pending withdrawals init pending_withdrawals: IterableMap::new(key!(PendingWithdrawals)), next_withdraw_id: 0, next_withdraw_to_execute: 0, - pending_market_exec: Vec::new(), - - // Keeper-provided withdraw route for the current Withdrawing op withdraw_route: Vec::new(), }; contract.set_metadata(&ContractMetadata::new(name, symbol, decimals.into())); @@ -278,7 +272,6 @@ impl Contract { let _ = require_attached_for_pending_withdrawal(); - // Move shares into escrow self.transfer(&Nep141Transfer::new( shares, &sender, @@ -298,7 +291,7 @@ impl Contract { PromiseOrValue::Value(()) } - /// Executes the next pending withdrawal request, if any, using the existing withdraw pipeline. + /// 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<()> { require_at_least(EXECUTE_WITHDRAW_GAS); @@ -331,17 +324,16 @@ impl Contract { } /// Executes one created market withdrawal request in the current Withdrawing op. - pub fn allocator_execute_next_market_withdrawal(&mut self, op_id: u64) -> PromiseOrValue<()> { + /// Allocator only. + pub fn execute_next_market_withdrawal(&mut self, op_id: U64) -> PromiseOrValue<()> { require_at_least(EXECUTE_WITHDRAW_GAS); Self::assert_allocator(); - // Must be in Withdrawing context for the provided op_id - let _ctx = match self.ctx_withdrawing(op_id) { + let _ctx = match self.ctx_withdrawing(op_id.into()) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), }; - // Ensure we have a created request to execute let market_index = match self.pending_market_exec.first().copied() { Some(idx) => idx, None => { @@ -356,14 +348,13 @@ impl Contract { PromiseOrValue::Promise( templar_common::market::ext_market::ext(market.clone()) - .with_static_gas(templar_common::vault::EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) + .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) .with_unused_gas_weight(0) .execute_next_supply_withdrawal_request() .then( - ext_self::ext(env::current_account_id()) - .with_static_gas(AFTER_EXECUTE_NEXT_WITHDRAW_GAS) - // `need` here is informational; we do not track it across the defer - .after_exec_withdraw_req(op_id, market_index, U128(0)), + Self::ext(env::current_account_id()) + .with_static_gas(EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS) + .execute_withdraw_01_fetch_position(op_id.into(), market_index, U128(0)), ), ) } @@ -388,9 +379,9 @@ impl Contract { .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) .ft_balance_of(env::current_account_id()) .then( - ext_self::ext(env::current_account_id()) + Self::ext(env::current_account_id()) .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) - .after_skim_balance(token, self.skim_recipient.clone()), + .skim_01_read_balance(token, self.skim_recipient.clone()), ) } @@ -430,7 +421,6 @@ impl Contract { return self.start_allocation(total); } - // Non-empty weights: validate and build plan. let weights = weights .into_iter() .map(|(m, w)| (m, u128::from(w))) @@ -463,12 +453,12 @@ impl Contract { 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; // head points at a live entry + self.next_withdraw_to_execute = id; return Some(id); } id = id.saturating_add(1); } - self.next_withdraw_to_execute = id; // no entries + self.next_withdraw_to_execute = id; None } @@ -490,7 +480,6 @@ impl Contract { )); } self.current_withdraw_inflight = None; - // next_withdraw_to_execute remains pointing at the same id } } @@ -617,6 +606,21 @@ impl Contract { pub fn preview_redeem(&self, shares: U128) -> U128 { self.convert_to_assets(shares) } + + pub fn get_withdrawing_op_id(&self) -> Option { + match &self.op_state { + OpState::Withdrawing(WithdrawingState { op_id, .. }) => Some((*op_id).into()), + _ => None, + } + } + + pub fn has_pending_market_withdrawal(&self) -> bool { + !self.pending_market_exec.is_empty() + } + + pub fn get_current_withdraw_request_id(&self) -> Option { + self.current_withdraw_inflight.map(Into::into) + } } #[derive(Debug, Clone, Copy)] @@ -812,7 +816,6 @@ impl Contract { amount <= self.idle_balance, "Policy violation: reserve amount must be <= idle_balance" ); - // Deduct from idle_balance upfront self.idle_balance -= amount; let op_id = self.next_op_id; @@ -845,10 +848,9 @@ impl Contract { ), ) .then( - ext_self::ext(env::current_account_id()) + Self::ext(env::current_account_id()) .with_static_gas(AFTER_SUPPLY_1_CHECK_GAS) - // .with_unused_gas_weight(0) - .after_supply_1_check(op_id, index, U128(amount)), + .supply_01_handle_transfer(op_id, index, U128(amount)), ) } @@ -983,7 +985,6 @@ impl Contract { }; if remaining == 0 { - // All funds allocated successfully return self.stop_and_exit::(None); } @@ -1056,9 +1057,9 @@ impl Contract { self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) .then( - ext_self::ext(env::current_account_id()) + Self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, receiver, U128(collected)), + .payment_01_reconcile_idle_or_refund(op_id, receiver, U128(collected)), ), ); } @@ -1085,9 +1086,9 @@ impl Contract { .with_static_gas(CREATE_WITHDRAW_REQ_GAS) .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(to_request))) .then( - ext_self::ext(env::current_account_id()) + Self::ext(env::current_account_id()) .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) - .after_create_withdraw_req(op_id, index, U128(to_request)), + .withdraw_01_handle_create_request(op_id, index, U128(to_request)), ), ) } else { @@ -1102,7 +1103,6 @@ impl Contract { escrow_shares, burn_shares, |_self| { - // Park the head pending: keep escrowed shares, stay in queue, try again later _self.withdraw_route.clear(); _self.op_state = OpState::Idle; _self.park_inflight_head_for_retry(); @@ -1112,7 +1112,7 @@ impl Contract { } } - /// If we collected something, pay it out now and burn proportional shares or do something else + /// If we collected something, pay it out now and burn proportional shares or do something else fn pay_collected( &mut self, op_id: u64, @@ -1136,9 +1136,13 @@ impl Contract { self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) .then( - ext_self::ext(env::current_account_id()) + Self::ext(env::current_account_id()) .with_static_gas(AFTER_SEND_TO_USER_GAS) - .after_send_to_user(op_id, receiver.clone(), U128(collected)), + .payment_01_reconcile_idle_or_refund( + op_id, + receiver.clone(), + U128(collected), + ), ), ) } else { diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index a876f4f1..8d412486 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -6,6 +6,7 @@ pub use near_sdk::{ test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, }; use near_sdk_contract_tools::ft::Nep141Controller as _; +use near_sdk_contract_tools::ft::Nep145; use test_utils::vault_configuration; pub fn mk(n: u32) -> AccountId { @@ -42,15 +43,36 @@ pub fn new_test_contract(vault_id: &AccountId) -> Contract { let underlying_token_id = mk(6); let cfg = vault_configuration( - owner, - curator, - guardian, - underlying_token_id, - skim_recipient, - fee_recipient, + owner.clone(), + curator.clone(), + guardian.clone(), + underlying_token_id.clone(), + skim_recipient.clone(), + fee_recipient.clone(), ); - Contract::new(cfg) + let mut builder = VMContextBuilder::new(); + builder.current_account_id(vault_id.clone()); + builder.predecessor_account_id(vault_id.clone()); + builder.signer_account_id(vault_id.clone()); + builder.attached_deposit(NearToken::from_near(1)); + testing_env!( + builder.build(), + test_vm_config(), + RuntimeFeesConfig::test(), + Default::default(), + vec![] + ); + let mut c = Contract::new(cfg); + c.storage_deposit(Some(owner), None); + c.storage_deposit(Some(curator), None); + c.storage_deposit(Some(guardian), None); + c.storage_deposit(Some(fee_recipient), None); + c.storage_deposit(Some(skim_recipient), None); + c.storage_deposit(Some(underlying_token_id), None); + + setup_env(vault_id, vault_id, vec![]); + c } /// Set the block timestamp and keep caller/predecessor consistent for tests pub fn set_block_ts(vault_id: &AccountId, signer: &AccountId, ts: u64) { diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index ef0066a7..fe4f7a12 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -186,7 +186,7 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e }); let supply_before = c.total_supply(); - c.after_send_to_user(Ok(()), 1, receiver, U128(200)); + c.payment_01_reconcile_idle_or_refund(Ok(()), 1, receiver, U128(200)); // Idle decreased by payout assert_eq!(c.idle_balance, 800); // Only burn_shares are burned from total supply @@ -1564,7 +1564,7 @@ fn after_supply_1_check_allocating_not_allocating(c_max: Contract) { c.op_state = OpState::Idle; - c.after_supply_1_check(Ok(U128(1)), 0, 2, Default::default()); + c.supply_01_handle_transfer(Ok(U128(1)), 0, 2, Default::default()); assert_eq!(c.op_state, OpState::Idle); assert_eq!(c.plan, None); @@ -1592,7 +1592,7 @@ fn after_supply_1_check_allocating_not_allocating_index() { remaining: 0u128, }); - c.after_supply_1_check(Ok(U128(1)), op_id + 1, 0, Default::default()); + c.supply_01_handle_transfer(Ok(U128(1)), op_id + 1, 0, Default::default()); assert_eq!(c.op_state, OpState::Idle); assert_eq!(c.plan, None); @@ -1620,7 +1620,7 @@ fn after_supply_1_check_allocating() { remaining: 0u128, }); - c.after_supply_1_check(Ok(U128(1)), op_id, 0, Default::default()); + c.supply_01_handle_transfer(Ok(U128(1)), op_id, 0, Default::default()); assert_eq!(c.op_state, OpState::Idle); assert_eq!(c.plan, None); @@ -1645,7 +1645,7 @@ fn after_send_to_user_success_no_escrow() { burn_shares: 0, }); - c.after_send_to_user(Ok(()), 1, receiver.clone(), U128(200)); + c.payment_01_reconcile_idle_or_refund(Ok(()), 1, receiver.clone(), U128(200)); assert_eq!(c.idle_balance, 800, "Idle balance must decrease by payout"); assert!( matches!(c.op_state, OpState::Idle), @@ -1678,7 +1678,7 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { escrow_shares: 50, }); - let res = c.after_exec_withdraw_read(Ok(None), 42, 0, U128(100), U128(60)); + let res = c.execute_withdraw_02_reconcile_position(Ok(None), 42, 0, U128(100), U128(60)); match res { PromiseOrValue::Promise(_p) => {} @@ -1715,7 +1715,7 @@ fn after_skim_balance_zero_noop() { let mut c = new_test_contract(&vault_id); - let res = c.after_skim_balance(Ok(U128(0)), mk(10), mk(11)); + let res = c.skim_01_read_balance(Ok(U128(0)), mk(10), mk(11)); match res { PromiseOrValue::Value(()) => {} _ => panic!("Skim with zero balance must be a no-op"), @@ -1730,7 +1730,7 @@ fn after_skim_balance_positive_returns_promise() { let mut c = new_test_contract(&vault_id); // Positive balance -> Promise to ft_transfer - let res = c.after_skim_balance(Ok(U128(123)), mk(10), mk(11)); + 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 :< @@ -1770,7 +1770,7 @@ fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: }); let before = c.idle_balance; - c.after_send_to_user( + c.payment_01_reconcile_idle_or_refund( Err(near_sdk::PromiseError::Failed), 1, receiver.clone(), @@ -1818,7 +1818,8 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { escrow_shares: 0, }); - let res = c.after_create_withdraw_req(Err(near_sdk::PromiseError::Failed), 7, 0, U128(need)); + 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"), @@ -1866,7 +1867,7 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect escrow_shares: 0, }); - let res = c.after_exec_withdraw_read( + let res = c.execute_withdraw_02_reconcile_position( Err(near_sdk::PromiseError::Failed), 99, 0, @@ -1936,7 +1937,8 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde let call_op = if pass_op { real_op } else { real_op + 1 }; let call_idx = if pass_index { real_idx } else { real_idx + 1 }; - let r = c.after_exec_withdraw_read(Ok(None), call_op, call_idx, U128(10), U128(1)); + let r = + c.execute_withdraw_02_reconcile_position(Ok(None), call_op, call_idx, U128(10), U128(1)); if let (true, true) = (pass_op, pass_index) { assert!( !matches!(c.op_state, OpState::Idle), @@ -1981,7 +1983,7 @@ fn refund_path_consistency() { let owner_before = c.balance_of(&owner); // Read result with need=0 ensures credited=0; triggers refund branch - let res = c.after_exec_withdraw_read(Ok(None), 77, 0, U128(0), U128(0)); + let res = c.execute_withdraw_02_reconcile_position(Ok(None), 77, 0, U128(0), U128(0)); match res { PromiseOrValue::Value(()) => {} _ => panic!("Expected Value(()) on immediate escrow refund"), @@ -2049,15 +2051,15 @@ fn ctx_withdrawing_ok_and_err() { escrow_shares: 10, }); - let (idx, rem, r, coll, o, escrow) = c + let ctx = c .ctx_withdrawing(7) .expect("ctx_withdrawing should succeed"); - assert_eq!(idx, 1); - assert_eq!(rem, 50); - assert_eq!(r, recv); - assert_eq!(coll, 5); - assert_eq!(o, owner); - assert_eq!(escrow, 10); + assert_eq!(ctx.index, 1); + assert_eq!(ctx.remaining, 50); + assert_eq!(ctx.receiver, recv); + assert_eq!(ctx.collected, 5); + assert_eq!(ctx.owner, owner); + assert_eq!(ctx.escrow_shares, 10); // Wrong op_id => error assert!(c.ctx_withdrawing(8).is_err()); @@ -2121,7 +2123,7 @@ fn after_supply_2_read_missing_position_stops() { }); // Missing position -> stop_and_exit - let res = c.after_supply_2_read(Ok(None), 1, 0, U128(0), U128(5), U128(5)); + let res = c.supply_02_position_read(Ok(None), 1, 0, U128(0), U128(5), U128(5)); match res { PromiseOrValue::Value(()) => {} _ => panic!("Expected Value on missing position"), @@ -2147,7 +2149,7 @@ fn after_supply_2_read_read_failed_stops() { }); // Read failure -> stop_and_exit - let res = c.after_supply_2_read( + let res = c.supply_02_position_read( Err(near_sdk::PromiseError::Failed), 7, 0, @@ -2189,12 +2191,12 @@ fn after_create_withdraw_req_success_returns_promise( escrow_shares: 5, }); - let res = c.after_create_withdraw_req(Ok(()), 21, 0, U128(60)); + let res = c.withdraw_01_handle_create_request(Ok(()), 21, 0, U128(60)); match res { PromiseOrValue::Value(()) => {} _ => panic!("Expected Value(()) when create succeeds and execution is deferred"), } - // State remains Withdrawing; keeper must call allocator_execute_next_market_withdrawal + // State remains Withdrawing; keeper must call execute_next_market_withdrawal assert!(matches!(c.op_state, OpState::Withdrawing { .. })); } @@ -2221,7 +2223,7 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { escrow_shares: 0, }); - let res = c.after_exec_withdraw_req(33, 0, U128(5)); + let res = c.execute_withdraw_01_fetch_position(33, 0, U128(5)); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to read supply position after exec"), @@ -2261,7 +2263,7 @@ fn after_exec_withdraw_read_advances_when_remaining( }); // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 - let res = c.after_exec_withdraw_read(Ok(None), 0, 0, U128(10), U128(100)); + let res = c.execute_withdraw_02_reconcile_position(Ok(None), 0, 0, U128(10), U128(100)); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to continue withdraw steps"), diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 3d55122e..248d2972 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -90,11 +90,16 @@ async fn happy(#[future(awt)] worker: Worker) { harvest(&c, &vault).await; // Plan the withdraw route (single market) and execute it via allocator methods let withdraw_route = vec![c.market.contract().id().clone()]; - let op_id = vault + vault .execute_next_withdrawal_request(&vault_curator, withdraw_route.clone()) .await; + let op_id = vault + .vault + .get_withdrawing_op_id() + .await + .expect("Failed to get withdrawing op id"); vault - .allocator_execute_next_market_withdrawal(&vault_curator, op_id) + .execute_next_market_withdrawal(&vault_curator, op_id) .await; assert_eq!( @@ -134,11 +139,16 @@ async fn happy(#[future(awt)] worker: Worker) { harvest(&c, &vault).await; // Plan the withdraw route (single market) and execute it via allocator methods let withdraw_route = vec![c.market.contract().id().clone()]; - let op_id = vault + vault .execute_next_withdrawal_request(&vault_curator, withdraw_route.clone()) .await; + let op_id = vault + .vault + .get_withdrawing_op_id() + .await + .expect("Failed to get withdrawing operation ID"); vault - .allocator_execute_next_market_withdrawal(&vault_curator, op_id) + .execute_next_market_withdrawal(&vault_curator, op_id) .await; } diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index bcd74c28..61513fd9 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -62,8 +62,11 @@ impl VaultController { #[view] pub fn get_total_supply() -> U128; #[view] pub fn get_max_deposit() -> U128; #[view] pub fn get_idle_balance() -> U128; - #[view] pub fn list_supply_queue(offset: Option, count: Option) -> Vec; - #[view] pub fn list_withdraw_queue(offset: Option, count: Option) -> Vec; + #[view] pub fn get_withdrawing_op_id() -> Option; + #[view] pub fn get_current_withdraw_request_id() -> Option; + #[view] pub fn has_pending_market_withdrawal() -> bool; + + #[view] pub fn get_market_supply(market: &AccountId) -> U128; #[view] pub fn get_next_op_id() -> u64; #[view] pub fn convert_to_shares(assets: U128) -> U128; @@ -86,7 +89,10 @@ impl VaultController { pub fn withdraw(amount: U128, receiver: AccountId); #[call(exec, tgas(300))] - pub fn execute_next_withdrawal_request(); + pub fn execute_next_withdrawal_request(route: Vec); + + #[call(exec, tgas(300))] + pub fn execute_next_market_withdrawal(op_id: u64); #[call(exec, tgas(300), deposit(NearToken::from_yoctonear(2560000000000000000000)))] pub fn redeem(shares: U128, receiver: AccountId); @@ -209,7 +215,8 @@ impl UnifiedVaultController { } } - #[must_use] pub fn new( + #[must_use] + pub fn new( vault: VaultController, configuration: VaultConfiguration, market: UnifiedMarketController, @@ -233,7 +240,6 @@ impl UnifiedVaultController { self.vault.storage_deposit(account, bounds.min).await; self.market.storage_deposits(account).await; - // FIXME: we should set the queue for this too! } pub async fn supply(&self, supply_user: &Account, amount: u128) -> ExecutionSuccess { @@ -302,8 +308,30 @@ impl UnifiedVaultController { e } - pub async fn execute_next_withdrawal(&self, allocator: &Account) -> ExecutionSuccess { - let e = self.vault.execute_next_withdrawal_request(allocator).await; + pub async fn execute_next_withdrawal( + &self, + allocator: &Account, + route: Vec, + ) -> ExecutionSuccess { + let e = self + .vault + .execute_next_withdrawal_request(allocator, route) + .await; + if self.debug { + print_execution(&e); + } + e + } + + pub async fn execute_next_market_withdrawal( + &self, + allocator: &Account, + op_id: U64, + ) -> ExecutionSuccess { + let e = self + .vault + .execute_next_market_withdrawal(allocator, op_id) + .await; if self.debug { print_execution(&e); } @@ -357,7 +385,5 @@ impl UnifiedVaultController { } fn is_debug() -> bool { - env::var("RUST_LOG") - .is_ok_and(|s| s.contains("debug")) - || env::var("DEBUG").is_ok() + env::var("RUST_LOG").is_ok_and(|s| s.contains("debug")) || env::var("DEBUG").is_ok() } From e32891d5aaff22230ab5765166e7318494503ad9 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 3 Nov 2025 10:51:08 +0000 Subject: [PATCH 103/121] chore: schemars for numbers --- common/src/lib.rs | 1 + contract/vault/src/wad.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/common/src/lib.rs b/common/src/lib.rs index 0d5b2781..5294b1b6 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -18,6 +18,7 @@ pub mod vault; pub mod withdrawal_queue; pub use primitive_types; +pub use schemars; /// Approximation of `1 / (1000 * 60 * 60 * 24 * 365.2425)`. /// diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index b9961d55..8ab52d4f 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -6,6 +6,7 @@ use near_sdk::borsh::schema::{add_definition, Declaration, Definition}; use near_sdk::borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; use near_sdk::serde::{Deserialize, Serialize}; use primitive_types::{U256, U512}; +use templar_common::schemars::JsonSchema; pub type WIDE = U512; @@ -177,6 +178,21 @@ impl BorshSchema for Number { } } +impl JsonSchema for Number { + fn schema_name() -> String { + "Number".to_string() + } + + fn json_schema( + generator: &mut templar_common::schemars::r#gen::SchemaGenerator, + ) -> templar_common::schemars::schema::Schema { + let mut g = generator.subschema_for::<[u8; 32]>().into_object(); + g.metadata().description = Some("256-bit Unsigned Integer".to_string()); + g.string().pattern = Some("^(0|[1-9][0-9]{0,77})$".to_string()); + g.into() + } +} + /// Represents the maximum performance fee that can be charged. 20% (very high) pub const MAX_FEE_WAD: u128 = Wad::SCALE / 10 * 2; @@ -290,6 +306,22 @@ impl BorshSchema for Wad { } } +impl JsonSchema for Wad { + fn schema_name() -> String { + "Wad".to_string() + } + + fn json_schema( + generator: &mut templar_common::schemars::r#gen::SchemaGenerator, + ) -> templar_common::schemars::schema::Schema { + let mut schema = generator.subschema_for::().into_object(); + schema.metadata().description = + Some("Wad fixed faction back by 256-bit unsigned integer".to_string()); + schema.string().pattern = Some("^(0|[1-9][0-9]{0,77})$".to_string()); + schema.into() + } +} + /// Computes fee shares to mint given: /// - `cur_total_assets`: current total assets under management /// - `last_total_assets`: previous total assets snapshot From 9f61eb7f1e16eb62f7ac792a953ab9cfc1a74213 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 3 Nov 2025 11:53:20 +0000 Subject: [PATCH 104/121] chore: do a minimum storage bounds --- contract/vault/src/lib.rs | 10 ++++++++-- test-utils/src/controller/vault.rs | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index da863063..fef63f00 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -17,12 +17,13 @@ use near_sdk::{ json_types::{U128, U64}, near, require, serde_json, store::IterableMap, - AccountId, BorshStorageKey, IntoStorageKey, PanicOnDefault, Promise, PromiseOrValue, + AccountId, BorshStorageKey, IntoStorageKey, NearToken, PanicOnDefault, Promise, PromiseOrValue, }; use near_sdk_contract_tools::{ ft::{ nep141::GAS_FOR_FT_TRANSFER_CALL, nep145::Nep145ForceUnregister, ContractMetadata, - FungibleToken, Nep141Controller, Nep141Mint, Nep141Transfer, Nep145 as _, Nep148Controller, + FungibleToken, Nep141Controller, Nep141Mint, Nep141Transfer, Nep145 as _, Nep145Controller, + Nep148Controller, StorageBalanceBounds, }, Owner, Rbac, }; @@ -242,12 +243,17 @@ impl Contract { pending_market_exec: Vec::new(), withdraw_route: Vec::new(), }; + contract.set_metadata(&ContractMetadata::new(name, symbol, decimals.into())); Owner::init(&mut contract, &owner); Rbac::add_role(&mut contract, &curator, &Role::Curator); Rbac::add_role(&mut contract, &curator, &Role::Allocator); Rbac::add_role(&mut contract, &guardian, &Role::Guardian); + contract.set_storage_balance_bounds(&StorageBalanceBounds { + min: NearToken::from_millinear(2), + max: None, + }); contract } diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 61513fd9..7fc6e7ab 100644 --- a/test-utils/src/controller/vault.rs +++ b/test-utils/src/controller/vault.rs @@ -92,7 +92,7 @@ impl VaultController { pub fn execute_next_withdrawal_request(route: Vec); #[call(exec, tgas(300))] - pub fn execute_next_market_withdrawal(op_id: u64); + pub fn execute_next_market_withdrawal(op_id: U64); #[call(exec, tgas(300), deposit(NearToken::from_yoctonear(2560000000000000000000)))] pub fn redeem(shares: U128, receiver: AccountId); From 175be2bfeabe471fc22a7ff7df1d45d6d5ca839b Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 3 Nov 2025 12:56:05 +0000 Subject: [PATCH 105/121] refactor: use storageless for market configurations --- common/src/vault.rs | 2 + contract/vault/src/governance.rs | 6 +- contract/vault/src/lib.rs | 14 +- contract/vault/src/storage_management.rs | 51 ++++--- contract/vault/src/tests.rs | 165 ++++++++--------------- 5 files changed, 95 insertions(+), 143 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index d3f2f869..c17e2723 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -574,6 +574,8 @@ pub enum Event { #[event_version("1.0.0")] WithdrawQueueMarketAdded { market: AccountId }, #[event_version("1.0.0")] + WithdrawDequeued { index: U64 }, + #[event_version("1.0.0")] MarketRemovalSubmitted { market: AccountId, removable_at: U64, diff --git a/contract/vault/src/governance.rs b/contract/vault/src/governance.rs index 4261126f..1654e5ef 100644 --- a/contract/vault/src/governance.rs +++ b/contract/vault/src/governance.rs @@ -124,7 +124,9 @@ impl Contract { account: account.clone(), } .emit(); - self.storage_deposit(Some(account.clone()), Some(true)); + if self.storage_balance_of(account.clone()).is_none() { + self.storage_deposit(Some(account.clone()), Some(true)); + } self.fee_recipient = account; } @@ -214,7 +216,6 @@ impl Contract { let mkt = match self.markets.get_mut(&market) { None => { - let _ = require_attached_at_least(yocto_for_new_market(), "submit_cap"); self.markets.insert(market.clone(), MarketRecord::default()); Event::MarketCreated { market: market.clone(), @@ -263,7 +264,6 @@ impl Contract { Self::assert_curator_or_owner(); self.ensure_idle(); - let m = self .markets .get_mut(&market) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index fef63f00..b2e053f9 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1,15 +1,13 @@ #![allow(clippy::needless_pass_by_value)] use std::{ - collections::{BTreeSet, HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, num::NonZeroU8, }; use crate::{ aum::AUM, - storage_management::{ - require_attached_at_least, require_attached_for_pending_withdrawal, yocto_for_new_market, - }, + storage_management::{require_attached_at_least, require_attached_for_pending_withdrawal}, }; use near_contract_standards::fungible_token::core::ext_ft_core; use near_sdk::{ @@ -137,8 +135,8 @@ pub struct Contract { virtual_shares: u128, virtual_assets: u128, - // Merged market record: cfg + pending_cap + principal - markets: IterableMap, + // Merged market record: cfg + pending_cap + principal (single persisted map; no per-entry storage keys) + markets: BTreeMap, /// Any pending change to the vault's timelock pending_timelock: Option>, @@ -224,7 +222,7 @@ impl Contract { performance_fee: Default::default(), fee_recipient, skim_recipient, - markets: IterableMap::new(key!(Config)), + markets: BTreeMap::new(), pending_timelock: None, pending_guardian: None, supply_queue: Default::default(), @@ -473,7 +471,7 @@ impl Contract { if let Some(id) = self.current_withdraw_inflight.take() { let _ = self.pending_withdrawals.remove(&id); self.next_withdraw_to_execute = id.saturating_add(1); - env::log_str(&format!("WithdrawalDequeued id={id}")); + Event::WithdrawDequeued { index: id.into() }.emit(); } } diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 4b4d6eac..23c0696c 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -1,7 +1,6 @@ -use crate::PendingWithdrawal; use near_sdk::{env, require, AccountId}; use std::collections::HashSet; -use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; +use templar_common::vault::{storage_bytes_for_account_id, PendingWithdrawal}; /// Set of hacks because near-sdk does not support borshschema and its overkill to implement /// We do not implement refunds for storage management ops, to avoid any potential issues with @@ -11,33 +10,30 @@ use templar_common::vault::{storage_bytes_for_account_id, MarketConfiguration}; pub const MAP_ENTRY_OVERHEAD: u64 = 64; pub const VEC_ITEM_OVERHEAD: u64 = 16; +pub const U128_BYTES: u64 = 16; +pub const U64_BYTES: u64 = 8; +pub const OPTION_TAG_BYTES: u64 = 1; #[must_use] pub fn storage_bytes_for_queue_account_id() -> u64 { VEC_ITEM_OVERHEAD + storage_bytes_for_account_id() } #[must_use] -pub fn storage_bytes_for_config_entry() -> u64 { +pub fn storage_bytes_for_ft_account_entry() -> u64 { let key = storage_bytes_for_account_id(); - MAP_ENTRY_OVERHEAD + key + MarketConfiguration::encoded_size() as u64 -} - -#[must_use] -pub fn storage_bytes_for_market_supply_entry() -> u64 { - let key = storage_bytes_for_account_id(); - // u128 principal - let val = 16u64; + let val = U128_BYTES; // balance: u128 MAP_ENTRY_OVERHEAD + key + val } #[must_use] -pub fn storage_bytes_for_pending_cap_entry() -> u64 { - let key = storage_bytes_for_account_id(); - // PendingValue { value: u128, valid_at: u64 } - let val = 16u64 + 8u64; - MAP_ENTRY_OVERHEAD + key + val +pub fn yocto_for_ft_account() -> u128 { + yocto_for_bytes(storage_bytes_for_ft_account_entry()) } + + + + #[must_use] pub fn storage_bytes_for_pending_withdrawal() -> u64 { // Key is u64 id -> 8 bytes @@ -52,17 +48,7 @@ pub fn yocto_for_bytes(bytes: u64) -> u128 { u128::from(bytes).saturating_mul(price) } -#[must_use] -pub fn yocto_for_new_market() -> u128 { - yocto_for_bytes( - storage_bytes_for_config_entry().saturating_add(storage_bytes_for_market_supply_entry()), - ) -} -#[must_use] -pub fn yocto_for_pending_cap() -> u128 { - yocto_for_bytes(storage_bytes_for_pending_cap_entry()) -} #[must_use] pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { @@ -91,6 +77,19 @@ pub fn require_attached_for_bytes(bytes: u64, ctx: &str) -> u128 { require_attached_at_least(req, ctx) } +#[must_use] +pub fn require_attached_for_state_delta(ctx: &str, mutate: impl FnOnce() -> R) -> R { + let before = env::storage_usage(); + let out = mutate(); + let after = env::storage_usage(); + let delta = after.saturating_sub(before); + if delta > 0 { + let yocto = yocto_for_bytes(delta); + require_attached_at_least(yocto, ctx); + } + out +} + #[must_use] pub fn require_attached_for_pending_withdrawal() -> u128 { let bytes = storage_bytes_for_pending_withdrawal(); diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index fe4f7a12..77a61179 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -4,17 +4,16 @@ use crate::impl_callbacks::reconcile_supply_outcome; use crate::impl_callbacks::WithdrawReconciliation; use crate::storage_management::storage_bytes_for_queue_account_id; use crate::storage_management::yocto_for_bytes; -use crate::storage_management::yocto_for_new_market; -use crate::storage_management::yocto_for_pending_cap; use crate::test_utils::*; use crate::wad::compute_fee_shares; +use crate::wad::Wad; use crate::Contract; use crate::MarketRecord; -use crate::Wad; use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver as _; use near_sdk::env; use near_sdk::serde_json; use near_sdk::test_utils::accounts; +use near_sdk::NearToken; use near_sdk::PromiseOrValue; use near_sdk::PromiseResult; use near_sdk::{json_types::U128, AccountId}; @@ -23,6 +22,7 @@ use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::mt::Nep245Receiver as _; 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::Error; use templar_common::vault::MarketConfiguration; @@ -138,7 +138,7 @@ fn fee_accrues_only_on_growth_unit(c_vault_env: Contract) { c.idle_balance = 1_000; // Set fee to 10% - c.performance_fee = crate::wad::Wad::one() / 10; + c.performance_fee = Wad::one() / 10; // Baseline: last_total_assets = current, so no profit => no fee c.last_total_assets = c.get_total_assets().0; @@ -195,62 +195,6 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e assert!(matches!(c.op_state, OpState::Idle)); } -#[rstest] -fn execute_next_withdrawal_request_skips_holes(c_owner_env: Contract) { - let mut c = c_owner_env; - let vault_id = accounts(0); - let owner = c - .own_get_owner() - .unwrap_or_else(|| env::panic_str("Owner not set")); - - println!("vault_id: {vault_id}"); - println!("owner: {owner}"); - - // Bob gets 20 shares - c.deposit_unchecked(&owner, 20) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); - // We fake by adding idle to the vault - c.transfer_unchecked(&owner, &vault_id, 10) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); - c.transfer_unchecked(&owner, &vault_id, 10) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); - - // Vault now has 20 shares - assert_eq!(c.balance_of(&vault_id), 20); - - // Queue two requests at ids 1 and 3; head starts at 0 - c.next_withdraw_id = 4; - c.next_withdraw_to_execute = 0; - - let make = |owner: AccountId, receiver: AccountId| super::PendingWithdrawal { - owner, - receiver, - escrow_shares: 10, - expected_assets: 5, - requested_at: 0, - }; - let recv = mk(9); - - c.pending_withdrawals - .insert(1, make(owner.clone(), recv.clone())); - c.pending_withdrawals - .insert(3, make(owner.clone(), recv.clone())); - - // First call should consume id=1 and advance head to 2 - let _ = c.execute_next_withdrawal_request(vec![]); - assert_eq!(c.next_withdraw_to_execute, 2); - - assert_eq!(c.balance_of(&vault_id), 20); - - // Vault does not refund shares on stop_and_exit - // Remaining is 0 - assert_eq!(c.balance_of(&vault_id), 20); - - // Second call should consume id=3 and advance head to 4 - let _ = c.execute_next_withdrawal_request(vec![]); - assert_eq!(c.next_withdraw_to_execute, 4); -} - #[test] #[should_panic = "unauthorized market"] fn set_supply_queue_rejects_zero_cap() { @@ -262,17 +206,20 @@ fn set_supply_queue_rejects_zero_cap() { } #[rstest] +#[should_panic = "Invalid token ID"] fn execute_supply_wrong_token_refunds_full(c_vault_env: Contract) { let mut c = c_vault_env; + setup_env( + &env::current_account_id(), + &c.underlying_asset.contract_id().into(), + vec![], + ); let sender = accounts(1); let wrong_token: AccountId = "wrong.token".parse().unwrap(); let deposit = 1_000u128; - let refund = c.execute_supply(sender.clone(), wrong_token.clone(), deposit); - assert_eq!(refund, deposit, "full refund expected for wrong token"); - assert_eq!(c.total_supply(), 0, "no shares should be minted"); - assert_eq!(c.idle_balance, 0, "idle must remain unchanged"); + let _ = c.execute_supply(sender.clone(), wrong_token.clone(), deposit); } #[rstest] @@ -399,7 +346,7 @@ fn compute_effective_totals_fee_share_and_virtuals() { let cur = 1_500u128.into(); let last = 1_000u128.into(); - let perf = crate::wad::Wad::one() / 10; // 10% + let perf = Wad::one() / 10; // 10% let ts = 1_000u128.into(); let vs = 1u128.into(); let va = 1u128.into(); @@ -484,7 +431,7 @@ fn accept_cap_raise_enables_and_cap_zero_keeps_enabled() { // Submit raise -> pending let raise = 5u128; - set_ctx(&vault_id, &owner, None, Some(yocto_for_new_market())); + 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 @@ -655,7 +602,7 @@ fn set_fee_recipient_accrues_before_switch() { // Simulate profit: last=1000, current=1500 c.idle_balance = 1_500; c.last_total_assets = 1_000; - c.performance_fee = crate::wad::Wad::one() / 10; + c.performance_fee = Wad::one() / 10; let cur = c.get_total_assets().0; let ts_before = c.total_supply(); @@ -706,7 +653,7 @@ fn set_fee_recipient_accrues_before_switch_variant() { // Simulate profit: last=2000, current=2400 c.idle_balance = 2_400; c.last_total_assets = 2_000; - c.performance_fee = crate::wad::Wad::one() / 20; // 5% + c.performance_fee = Wad::one() / 20; // 5% let cur = c.get_total_assets().0; let ts_before = c.total_supply(); @@ -761,7 +708,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates() { c.last_total_assets = 1_000; // Old rate = 10%, new rate = 1% - c.performance_fee = crate::wad::Wad::one() / 10; + c.performance_fee = Wad::one() / 10; let cur = c.get_total_assets().0; let ts_before = c.total_supply(); let expect_old = compute_fee_shares( @@ -774,7 +721,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates() { let recipient = c.fee_recipient.clone(); let bal_before = c.balance_of(&recipient); - c.set_performance_fee(crate::wad::Wad::one() / 100); + c.set_performance_fee(Wad::one() / 100); assert_eq!( c.balance_of(&recipient), @@ -814,7 +761,7 @@ fn set_performance_fee_accrues_with_old_rate_then_updates_variant() { c.last_total_assets = 2_000; // Old rate = 5%, new rate = 0.5% - c.performance_fee = crate::wad::Wad::one() / 20; // 5% + c.performance_fee = Wad::one() / 20; // 5% let cur = c.get_total_assets().0; let ts_before = c.total_supply(); let expect_old = compute_fee_shares( @@ -862,7 +809,7 @@ fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { // Loss scenario: last=1000, current=800 c.idle_balance = 800; c.last_total_assets = 1_000; - c.performance_fee = crate::wad::Wad::one() / 10; + c.performance_fee = Wad::one() / 10; let ts_before = c.total_supply(); let fr = c.fee_recipient.clone(); @@ -978,6 +925,7 @@ 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); @@ -1023,34 +971,31 @@ fn ft_on_transfer_invalid_msg_panics() { } #[rstest] +#[should_panic = "Deposit amount must be greater than zero"] fn ft_on_transfer_zero_amount_returns_zero_refund( c_vault_env: Contract, enabled_market_100: (AccountId, MarketConfiguration), ) { let mut c = c_vault_env; + setup_env( + &env::current_account_id(), + &c.underlying_asset.contract_id().into(), + vec![], + ); // Setup a valid market let (m, cfg) = enabled_market_100; c.markets.insert(m.clone(), cfg.into()); c.supply_queue.insert(m); - let sender = accounts(5); + let sender: AccountId = c.underlying_asset.contract_id().into(); let bal_before = c.balance_of(&sender); - let res = c.ft_on_transfer( + c.ft_on_transfer( sender.clone(), U128(0), serde_json::to_string(&DepositMsg::Supply).unwrap(), ); - match res { - PromiseOrValue::Value(U128(refund)) => assert_eq!(refund, 0), - _ => panic!("expected Value refund"), - } - assert_eq!( - c.balance_of(&sender), - bal_before, - "no shares should be minted" - ); } #[rstest] @@ -1144,16 +1089,21 @@ 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); - setup_env(&vault_id, &vault_id, vec![]); + let old_ft_id = c.underlying_asset.contract_id().into(); + setup_env(&vault_id, &old_ft_id, vec![]); + + let token_id = "token-1".to_string(); + + c.underlying_asset = FungibleAsset::nep245(old_ft_id.clone(), token_id.clone()); let sender = accounts(5); let amount = 25u128; let res = c.mt_on_transfer( - accounts(3), // sender_id (ignored in logic) - vec![sender.clone()], // previous_owner_ids - vec!["token-1".to_string()], // token_ids - vec![U128(amount)], // amounts + accounts(3), + vec![sender.clone()], // previous_owner_ids + vec![token_id], // token_ids + vec![U128(amount)], // amounts serde_json::to_string(&DepositMsg::Supply).unwrap(), ); match res { @@ -1168,15 +1118,15 @@ fn mt_on_transfer_wrong_asset_refunds_full() { } #[test] +#[should_panic = "Deposit amount must be greater than zero"] fn execute_supply_zero_amount_rejected() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); setup_env(&vault_id, &vault_id, vec![]); - let sender = accounts(4); - let refund = c.execute_supply(sender.clone(), vault_id.clone(), 0); - assert_eq!(refund, 0, "zero deposit returns zero refund"); - assert_eq!(c.balance_of(&sender), 0, "no shares should be minted"); + let asset_id = c.underlying_asset.contract_id().into(); + let sender_id = accounts(4); + c.execute_supply(sender_id.clone(), asset_id, 0); } #[test] @@ -1405,12 +1355,7 @@ 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_new_market() + yocto_for_pending_cap()), - ); + 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 @@ -1441,12 +1386,7 @@ 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_new_market() + yocto_for_pending_cap()), - ); + set_ctx(&vault_id, &owner, None, Some(yocto_for_bytes(20_000))); c.submit_cap(m.clone(), U128(7)); // Revoke, then accepting should panic @@ -1497,19 +1437,32 @@ fn governance_set_fee_recipient_no_fee_does_not_accrue() { let vault_id = accounts(0); let mut c = new_test_contract(&vault_id); let owner = accounts(1); - setup_env(&vault_id, &owner, vec![]); + + 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![] + ); // 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; c.last_total_assets = 1_000; - c.performance_fee = crate::wad::Wad::zero(); + c.performance_fee = Wad::zero(); let ts_before = c.total_supply(); let last_before = c.last_total_assets; let new_recipient = accounts(5); + c.set_fee_recipient(new_recipient.clone()); assert_eq!( From 13da7eaa1bb28109f1e96c674a9a4f2f52b7daf8 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 3 Nov 2025 14:43:22 +0000 Subject: [PATCH 106/121] refactor: avoid requerying --- contract/vault/src/impl_callbacks.rs | 37 ++++----------- contract/vault/src/lib.rs | 8 ++-- contract/vault/src/tests.rs | 67 ++++++++++++++++------------ 3 files changed, 51 insertions(+), 61 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 3e6d87d3..b21adcab 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -28,22 +28,17 @@ impl Contract { #[private] pub fn supply_01_handle_transfer( &mut self, - // NOTE: we can't rely on this as a `true` value of accepted, so we are taking a belt-and-braces approach of - // querying the supply position #[callback_result] accepted: Result, + market: AccountId, op_id: u64, market_index: u32, attempted: U128, + remaining_before: U128, ) -> PromiseOrValue<()> { if let Err(e) = self.ctx_allocating(op_id) { return self.stop_and_exit(Some(&e)); }; - let market = match self.resolve_supply_market(market_index) { - Ok(m) => m, - Err(e) => return self.stop_and_exit(Some(&e)), - }; - match accepted { Err(_) => { Event::AllocationTransferFailed { @@ -56,7 +51,7 @@ impl Contract { self.stop_and_exit(Some(&Error::MarketTransferFailed)) } Ok(accepted) => { - let before = self.principal_of(market); + let before = self.principal_of(&market); PromiseOrValue::Promise( ext_market::ext(market.clone()) @@ -67,11 +62,13 @@ impl Contract { Self::ext(env::current_account_id()) .with_static_gas(SUPPLY_02_POSITION_READ_GAS) .supply_02_position_read( + market.clone(), op_id, market_index, U128(before), attempted, accepted, + remaining_before, ), ), ) @@ -83,13 +80,15 @@ impl Contract { pub fn supply_02_position_read( &mut self, #[callback_result] position: Result, PromiseError>, + market: AccountId, op_id: u64, market_index: u32, before: U128, attempted: U128, accepted: U128, + remaining_before: U128, ) -> PromiseOrValue<()> { - let (i, remaining_ctx) = match self.ctx_allocating(op_id) { + let (i, _remaining_ctx) = match self.ctx_allocating(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), }; @@ -98,12 +97,6 @@ impl Contract { return self.stop_and_exit(Some(&Error::IndexDrifted(i, market_index))); } - let market = match self.resolve_supply_market(market_index) { - Ok(m) => m, - Err(e) => return self.stop_and_exit(Some(&e)), - } - .clone(); - let SupplyReconciliation { new_principal, accepted_event, @@ -112,7 +105,7 @@ impl Contract { Ok(Some(position)) => reconcile_supply_outcome( &position.get_deposit().total().into(), &before.0, - &remaining_ctx, + &remaining_before.0, ), Ok(None) => { Event::AllocationPositionMissing { @@ -590,18 +583,6 @@ impl Contract { } } - /// Resolve a market for allocation by plan (if present) or `supply_queue` - pub(crate) fn resolve_supply_market(&self, market_index: u32) -> Result<&AccountId, Error> { - self.plan - .as_ref() - .and_then(|plan| { - plan.get(market_index as usize) - .map(|(m, _)| m) - .or(self.supply_queue.iter().nth(market_index as usize)) - }) - .ok_or(Error::MissingMarket(market_index)) - } - /// Resolve a market for withdraw by `withdraw_route` pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result<&AccountId, Error> { self.withdraw_route diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index b2e053f9..e7e2f84d 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -838,7 +838,7 @@ impl Contract { } /// build a supply `transfer_call` and chain `after_supply_1_check` - fn supply_and_then(&self, market: &AccountId, amount: u128, op_id: u64, index: u32) -> Promise { + fn supply_and_then(&self, market: &AccountId, amount: u128, op_id: u64, index: u32, remaining_before: u128) -> Promise { self::require_at_least(AFTER_SUPPLY_1_CHECK_GAS.saturating_add(GAS_FOR_FT_TRANSFER_CALL)); self.underlying_asset .transfer_call( @@ -854,7 +854,7 @@ impl Contract { .then( Self::ext(env::current_account_id()) .with_static_gas(AFTER_SUPPLY_1_CHECK_GAS) - .supply_01_handle_transfer(op_id, index, U128(amount)), + .supply_01_handle_transfer(market.clone(), op_id, index, U128(amount), U128(remaining_before)), ) } @@ -920,7 +920,7 @@ impl Contract { return self.step_allocation(); } - PromiseOrValue::Promise(self.supply_and_then(&market_id, to_supply, op_id, index)) + 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) @@ -972,7 +972,7 @@ impl Contract { return self.step_allocation(); } - PromiseOrValue::Promise(self.supply_and_then(market, to_supply, op_id, index)) + PromiseOrValue::Promise(self.supply_and_then(market, to_supply, op_id, index, remaining)) } else { self.stop_and_exit::(None) } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 77a61179..eceae413 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1517,7 +1517,14 @@ fn after_supply_1_check_allocating_not_allocating(c_max: Contract) { c.op_state = OpState::Idle; - c.supply_01_handle_transfer(Ok(U128(1)), 0, 2, Default::default()); + c.supply_01_handle_transfer( + Ok(U128(1)), + accounts(1), + 0, + 2, + Default::default(), + Default::default(), + ); assert_eq!(c.op_state, OpState::Idle); assert_eq!(c.plan, None); @@ -1545,7 +1552,14 @@ fn after_supply_1_check_allocating_not_allocating_index() { remaining: 0u128, }); - c.supply_01_handle_transfer(Ok(U128(1)), op_id + 1, 0, Default::default()); + c.supply_01_handle_transfer( + Ok(U128(1)), + accounts(1), + op_id + 1, + 0, + Default::default(), + Default::default(), + ); assert_eq!(c.op_state, OpState::Idle); assert_eq!(c.plan, None); @@ -1573,9 +1587,23 @@ fn after_supply_1_check_allocating() { remaining: 0u128, }); - c.supply_01_handle_transfer(Ok(U128(1)), op_id, 0, Default::default()); + c.supply_01_handle_transfer( + Ok(U128(1)), + accounts(3), + op_id, + 0, + Default::default(), + Default::default(), + ); - assert_eq!(c.op_state, OpState::Idle); + assert_eq!( + c.op_state, + OpState::Allocating(AllocatingState { + op_id, + index: 0, + remaining: 0u128 + }) + ); assert_eq!(c.plan, None); } @@ -2024,31 +2052,9 @@ fn resolve_market_helpers_supply_and_withdraw() { setup_env(&vault_id, &vault_id, vec![]); let mut c = new_test_contract(&vault_id); - // Prepare markets + // Withdraw resolver uses withdraw_route only let m1 = mk(1001); let m2 = mk(1002); - - // Supply: plan takes precedence - c.plan = Some(vec![(m2.clone(), 1u128)]); - c.supply_queue.insert(m1.clone()); - c.supply_queue.insert(m2.clone()); - - assert_eq!(c.resolve_supply_market(0).unwrap(), &m2); - assert!(matches!( - c.resolve_supply_market(1), - Err(Error::MissingMarket(1)) - )); - - // Without plan, use queue - c.plan = None; - assert_eq!(c.resolve_supply_market(0).unwrap(), &m1); - assert_eq!(c.resolve_supply_market(1).unwrap(), &m2); - assert!(matches!( - c.resolve_supply_market(2), - Err(Error::MissingMarket(2)) - )); - - // Withdraw resolver uses withdraw_queue c.withdraw_route = vec![m1.clone(), m2.clone()]; assert_eq!(c.resolve_withdraw_market(0).unwrap(), &m1); assert_eq!(c.resolve_withdraw_market(1).unwrap(), &m2); @@ -2066,7 +2072,7 @@ fn after_supply_2_read_missing_position_stops() { // Resolve market via supply_queue let market = mk(42); - c.supply_queue.insert(market); + c.supply_queue.insert(market.clone()); // Must be in Allocating ctx c.op_state = OpState::Allocating(AllocatingState { @@ -2076,7 +2082,8 @@ fn after_supply_2_read_missing_position_stops() { }); // Missing position -> stop_and_exit - let res = c.supply_02_position_read(Ok(None), 1, 0, U128(0), U128(5), U128(5)); + let res = + c.supply_02_position_read(Ok(None), market, 1, 0, U128(0), U128(5), U128(5), U128(10)); match res { PromiseOrValue::Value(()) => {} _ => panic!("Expected Value on missing position"), @@ -2104,11 +2111,13 @@ fn after_supply_2_read_read_failed_stops() { // Read failure -> stop_and_exit let res = c.supply_02_position_read( Err(near_sdk::PromiseError::Failed), + accounts(3), 7, 0, U128(0), U128(10), U128(10), + U128(100), ); match res { PromiseOrValue::Value(()) => {} From f07dad2f9692fd861ee97d070c4e8b99a95fca6f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 3 Nov 2025 14:59:10 +0000 Subject: [PATCH 107/121] fix: lints and fmts --- Cargo.lock | 153 +++++++++++++++++++--- common/src/vault.rs | 12 +- contract/market/src/lib.rs | 2 +- contract/vault/src/aum.rs | 2 +- contract/vault/src/governance.rs | 28 ++-- contract/vault/src/impl_callbacks.rs | 23 ++-- contract/vault/src/impl_token_receiver.rs | 2 +- contract/vault/src/lib.rs | 68 ++++++---- contract/vault/src/storage_management.rs | 13 +- contract/vault/src/tests.rs | 10 +- contract/vault/src/wad.rs | 2 + contract/vault/tests/invariants.rs | 16 --- test-utils/src/controller/vault.rs | 2 +- 13 files changed, 228 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce940eb4..9db9113d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,9 +482,9 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "byte-slice-cast" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ac9f8b63eca6fd385229b3675f6cc0dc5c8a5c8a54a59d4f52ffd670d87b0c" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] name = "bytecheck" @@ -710,7 +710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -748,6 +748,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -1195,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1276,6 +1296,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand", + "rustc-hex", + "static_assertions", +] + [[package]] name = "flate2" version = "1.0.35" @@ -1934,7 +1966,25 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "161ebdfec3c8e3b52bf61c4f3550a1eea4f9579d10dc1b936f3171ebdcd6c443" dependencies = [ - "parity-scale-codec", + "parity-scale-codec 2.3.1", +] + +[[package]] +name = "impl-codec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d40b9d5e17727407e55028eafc22b2dc68781786e6d7eb8a21103f5058e3a14" +dependencies = [ + "parity-scale-codec 3.7.5", +] + +[[package]] +name = "impl-serde" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a143eada6a1ec4aefa5049037a26a6d597bfd64f8c026d07b77133e02b7dd0b" +dependencies = [ + "serde", ] [[package]] @@ -2437,7 +2487,7 @@ dependencies = [ "near-config-utils", "near-schema-checker-lib", "near-stdx", - "primitive-types", + "primitive-types 0.10.1", "rand", "secp256k1", "serde", @@ -2549,7 +2599,7 @@ dependencies = [ "near-time", "num-rational", "ordered-float", - "primitive-types", + "primitive-types 0.10.1", "rand", "rand_chacha", "serde", @@ -3017,7 +3067,23 @@ dependencies = [ "bitvec 0.20.4", "byte-slice-cast", "impl-trait-for-tuples", - "parity-scale-codec-derive", + "parity-scale-codec-derive 2.3.1", + "serde", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec 1.0.1", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive 3.7.5", + "rustversion", "serde", ] @@ -3033,6 +3099,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "parking" version = "2.2.1" @@ -3218,9 +3296,21 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05e4722c697a58a99d5d06a08c30821d7c082a4632198de1eaa5a6c22ef42373" dependencies = [ - "fixed-hash", - "impl-codec", - "uint", + "fixed-hash 0.7.0", + "impl-codec 0.5.1", + "uint 0.9.5", +] + +[[package]] +name = "primitive-types" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721a1da530b5a2633218dc9f75713394c983c352be88d2d7c9ee85e2c4c21794" +dependencies = [ + "fixed-hash 0.8.0", + "impl-codec 0.7.1", + "impl-serde", + "uint 0.10.0", ] [[package]] @@ -3244,9 +3334,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -3273,9 +3363,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -3633,7 +3723,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.14", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3642,11 +3732,11 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4429,7 +4519,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4464,7 +4554,7 @@ dependencies = [ "near-contract-standards", "near-primitives", "near-sdk", - "primitive-types", + "primitive-types 0.10.1", "rand", "rstest", "schemars", @@ -4579,12 +4669,15 @@ dependencies = [ name = "templar-vault-contract" version = "1.1.0" dependencies = [ + "futures", "getrandom 0.2.15", "itertools 0.14.0", "near-contract-standards", "near-sdk", "near-sdk-contract-tools", "near-workspaces", + "primitive-types 0.14.0", + "rand", "rstest", "templar-common", "templar-relayer", @@ -4869,7 +4962,7 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "async-compression", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.9.4", "bytes", "futures-core", "futures-util", @@ -5003,6 +5096,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unicase" version = "2.8.1" @@ -5036,6 +5141,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/common/src/vault.rs b/common/src/vault.rs index c17e2723..3ac40e2d 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -220,7 +220,7 @@ impl PendingValue { require!( near_sdk::env::block_timestamp() >= self.valid_at_ns, "Timelock not elapsed yet" - ) + ); } } @@ -436,9 +436,9 @@ pub struct PendingWithdrawal { impl PendingWithdrawal { #[must_use] - pub const fn encoded_size() -> usize { - storage_bytes_for_account_id() as usize - + storage_bytes_for_account_id() as usize + pub fn encoded_size() -> u64 { + storage_bytes_for_account_id() + + storage_bytes_for_account_id() + 16 // escrow_shares: u128 + 16 // expected_assets: u128 + 8 // requested_at: u64 @@ -576,6 +576,8 @@ pub enum Event { #[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, @@ -711,7 +713,7 @@ mod tests { requested_at: 5 }) .unwrap() - .len(), + .len() as u64, PendingWithdrawal::encoded_size() ); } diff --git a/contract/market/src/lib.rs b/contract/market/src/lib.rs index 5b718124..1474fdc0 100644 --- a/contract/market/src/lib.rs +++ b/contract/market/src/lib.rs @@ -2,7 +2,7 @@ use std::ops::{Deref, DerefMut}; -use near_sdk::{env, near, serde_json, AccountId, BorshStorageKey, PanicOnDefault}; +use near_sdk::{env, near, AccountId, BorshStorageKey, PanicOnDefault}; use near_sdk_contract_tools::standard::nep145::{ Nep145Controller, Nep145ForceUnregister, StorageBalanceBounds, }; diff --git a/contract/vault/src/aum.rs b/contract/vault/src/aum.rs index 7687315f..0db4ac54 100644 --- a/contract/vault/src/aum.rs +++ b/contract/vault/src/aum.rs @@ -2,7 +2,7 @@ use near_sdk::near; use super::{Contract, U128}; -//// AUM (Assets Under Management) +/// AUM (Assets Under Management) /// /// BalanceSheet model only: total assets are the sum of idle_balance and all market principals. /// There is no governance-scoped AUM filtering; accounting changes only when cash actually moves. diff --git a/contract/vault/src/governance.rs b/contract/vault/src/governance.rs index 1654e5ef..1ab90184 100644 --- a/contract/vault/src/governance.rs +++ b/contract/vault/src/governance.rs @@ -209,6 +209,9 @@ impl Contract { /// Submits a change to a market's supply cap. /// Decreases apply immediately; increases are subject to the governance timelock. + /// + /// # Panics + /// If the market does not exist. #[payable] pub fn submit_cap(&mut self, market: AccountId, new_cap: U128) { Self::assert_curator_or_owner(); @@ -221,7 +224,9 @@ impl Contract { market: market.clone(), } .emit(); - self.markets.get_mut(&market).unwrap() + self.markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("Config not found")) } Some(m) => m, }; @@ -259,6 +264,8 @@ impl Contract { } /// Accepts a pending cap increase for `market` once the timelock has elapsed. + /// # Panics + /// If the market does not exist. #[payable] pub fn accept_cap(&mut self, market: AccountId) { Self::assert_curator_or_owner(); @@ -271,15 +278,13 @@ impl Contract { let was_enabled = m.cfg.enabled; - let pending_value = m - .pending_cap - .as_ref() - .map(|pending_cap| { + let pending_value = m.pending_cap.as_ref().map_or_else( + || env::panic_str("No pending cap change for this market"), + |pending_cap| { pending_cap.verify(); pending_cap.value - }) - .unwrap_or_else(|| env::panic_str("No pending cap change for this market")); - + }, + ); m.cfg.cap = pending_value.into(); if pending_value > 0 { @@ -302,7 +307,10 @@ impl Contract { } .emit(); - self.markets.get_mut(&market).unwrap().pending_cap = None; + self.markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("Config not found")) + .pending_cap = None; } /// Revokes any pending cap change for `market`. @@ -383,7 +391,7 @@ impl Contract { } // Compute and require storage for additions (no refunds for removals in this pass) - let current: HashSet = self.supply_queue.iter().cloned().collect(); + let current: BTreeSet = self.supply_queue.iter().cloned().collect(); let required_yocto = storage_management::yocto_for_queue_additions(¤t, &markets); let _ = require_attached_at_least(required_yocto, "supply queue update"); diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index b21adcab..3b60a9bf 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -186,7 +186,7 @@ impl Contract { if did_create.is_ok() { // Always defer execution: record the created request; keeper must call execute_next_market_withdrawal(op_id) self.pending_market_exec.push(market_index); - return PromiseOrValue::Value(()); + PromiseOrValue::Value(()) } else { Event::CreateWithdrawalFailed { op_id: op_id.into(), @@ -200,7 +200,7 @@ impl Contract { index: market_index.saturating_add(1), remaining: ctx.remaining, receiver: ctx.receiver.clone(), - collected: ctx.collected.clone(), + collected: ctx.collected, owner: ctx.owner.clone(), escrow_shares: ctx.escrow_shares, }); @@ -255,6 +255,9 @@ impl Contract { /// - If remaining == 0, transition to Payout; otherwise continue Withdrawing on next market. /// - Later in after_send_to_user, idle_balance is decremented on successful transfer to the user. /// - On transfer failure, idle_balance stays unchanged and escrowed shares are refunded to the owner. + /// + /// # Panics + /// - If the market is not found. #[private] pub fn execute_withdraw_02_reconcile_position( &mut self, @@ -337,18 +340,18 @@ impl Contract { &ctx.owner, ctx.escrow_shares, ctx.escrow_shares, - |_self| { + |self_| { // Nothing collected; refund escrowed shares let self_id = env::current_account_id(); // We expect the owner to maintain storage accounts, otherwise they will lose access to their funds - _self + self_ .transfer(&Nep141Transfer::new( ctx.escrow_shares, &self_id, &ctx.owner, )) - .expect("Failed to refund escrowed shares"); - _self.op_state = OpState::Idle; + .unwrap_or_else(|_| env::panic_str("Failed to refund escrowed shares")); + self_.op_state = OpState::Idle; PromiseOrValue::Value(()) }, ) @@ -412,7 +415,9 @@ impl Contract { // Burn only the proportional shares and refund the remainder to the owner. if burn_shares > 0 { // Serious issue: this should be infallible - if the withdrawal panics here we have an escrow settlement error - self.burn(&Nep141Burn::new(burn_shares, &env::current_account_id())); + let _ = self + .burn(&Nep141Burn::new(burn_shares, env::current_account_id())) + .inspect_err(|e| env::log_str(&format!("Failed to burn {e}"))); } // Maybe refund any delta to the owner @@ -420,7 +425,7 @@ impl Contract { // Note: this should be infallible since we are transferring to an existing owner, and they are unable to unregister from storage self.transfer(&Nep141Transfer::new( refund, - &env::current_account_id(), + env::current_account_id(), &owner, )) // Serious issue: this should be infallible - if the transfer panics here we have an escrow settlement error @@ -430,7 +435,7 @@ impl Contract { // On payout failure, refund full escrow to owner and leave idle_balance unchanged self.transfer(&Nep141Transfer::new( escrow_shares, - &env::current_account_id(), + env::current_account_id(), &owner, )) // If this fails, this is a serious issue as above diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index e76b74cf..d51f9554 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -116,7 +116,7 @@ impl Contract { let shares = self.preview_deposit(U128(accept)).0; self.mint(&Nep141Mint::new(shares, &sender_id)) - .expect("Failed to mint shares"); + .unwrap_or_else(|_| env::panic_str("Failed to mint shares")); Event::MintedShares { amount: shares.into(), diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index e7e2f84d..f0ee59b7 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -329,7 +329,11 @@ impl Contract { /// Executes one created market withdrawal request in the current Withdrawing op. /// Allocator only. - pub fn execute_next_market_withdrawal(&mut self, op_id: U64) -> PromiseOrValue<()> { + pub fn execute_next_market_withdrawal( + &mut self, + op_id: U64, + batch_limit: Option, + ) -> PromiseOrValue<()> { require_at_least(EXECUTE_WITHDRAW_GAS); Self::assert_allocator(); @@ -338,11 +342,8 @@ impl Contract { Err(e) => return self.stop_and_exit(Some(&e)), }; - let market_index = match self.pending_market_exec.first().copied() { - Some(idx) => idx, - None => { - env::panic_str("No pending market withdrawal request to execute"); - } + let Some(market_index) = self.pending_market_exec.first().copied() else { + env::panic_str("No pending market withdrawal request to execute"); }; let market = match self.resolve_withdraw_market(market_index) { @@ -354,7 +355,7 @@ impl Contract { templar_common::market::ext_market::ext(market.clone()) .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) .with_unused_gas_weight(0) - .execute_next_supply_withdrawal_request() + .execute_next_supply_withdrawal_request(batch_limit) .then( Self::ext(env::current_account_id()) .with_static_gas(EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS) @@ -477,11 +478,11 @@ impl Contract { // Keep the head pending but clear in-flight so it can be retried later fn park_inflight_head_for_retry(&mut self) { - if self.current_withdraw_inflight.is_some() { - env::log_str(&format!( - "WithdrawalParked id={}", - self.current_withdraw_inflight.unwrap() - )); + if let Some(current_withdraw_inflight) = self.current_withdraw_inflight { + Event::WithdrawalParked { + id: current_withdraw_inflight.into(), + } + .emit(); } self.current_withdraw_inflight = None; } @@ -490,6 +491,10 @@ impl Contract { /* ----- Views ----- */ #[near] impl Contract { + /// Panics + /// - If the owner is not set + /// - If the curator is not set + /// - If the guardian is not set #[allow(clippy::expect_used, reason = "No side effects")] pub fn get_configuration(&self) -> VaultConfiguration { let meta = self.get_metadata(); @@ -525,7 +530,7 @@ impl Contract { skim_recipient: self.skim_recipient.clone(), name: meta.name, symbol: meta.symbol, - decimals: NonZeroU8::new(meta.decimals).unwrap(), + decimals: NonZeroU8::new(meta.decimals).expect("Decimals must be non-zero"), mode: self.mode.clone(), } } @@ -766,7 +771,9 @@ impl Contract { if fee_shares > Number::zero() { let minted: u128 = fee_shares.into(); let recipient = self.fee_recipient.clone(); - self.mint(&Nep141Mint::new(minted, &recipient)); + let _ = self + .mint(&Nep141Mint::new(minted, &recipient)) + .inspect_err(|e| env::log_str(&format!("Failed to mint {e}"))); Event::PerformanceFeeAccrued { recipient, shares: U128(minted), @@ -838,7 +845,14 @@ impl Contract { } /// build a supply `transfer_call` and chain `after_supply_1_check` - fn supply_and_then(&self, market: &AccountId, amount: u128, op_id: u64, index: u32, remaining_before: u128) -> Promise { + fn supply_and_then( + &self, + market: &AccountId, + amount: u128, + op_id: u64, + index: u32, + remaining_before: u128, + ) -> Promise { self::require_at_least(AFTER_SUPPLY_1_CHECK_GAS.saturating_add(GAS_FOR_FT_TRANSFER_CALL)); self.underlying_asset .transfer_call( @@ -854,7 +868,13 @@ impl Contract { .then( Self::ext(env::current_account_id()) .with_static_gas(AFTER_SUPPLY_1_CHECK_GAS) - .supply_01_handle_transfer(market.clone(), op_id, index, U128(amount), U128(remaining_before)), + .supply_01_handle_transfer( + market.clone(), + op_id, + index, + U128(amount), + U128(remaining_before), + ), ) } @@ -920,7 +940,9 @@ impl Contract { return self.step_allocation(); } - PromiseOrValue::Promise(self.supply_and_then(&market_id, to_supply, op_id, index, remaining)) + 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) @@ -972,7 +994,9 @@ impl Contract { return self.step_allocation(); } - PromiseOrValue::Promise(self.supply_and_then(market, to_supply, op_id, index, remaining)) + PromiseOrValue::Promise( + self.supply_and_then(market, to_supply, op_id, index, remaining), + ) } else { self.stop_and_exit::(None) } @@ -1106,10 +1130,10 @@ impl Contract { &owner, escrow_shares, burn_shares, - |_self| { - _self.withdraw_route.clear(); - _self.op_state = OpState::Idle; - _self.park_inflight_head_for_retry(); + |self_| { + self_.withdraw_route.clear(); + self_.op_state = OpState::Idle; + self_.park_inflight_head_for_retry(); PromiseOrValue::Value(()) }, ) diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 23c0696c..49d25417 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -1,11 +1,10 @@ use near_sdk::{env, require, AccountId}; -use std::collections::HashSet; +use std::collections::BTreeSet; use templar_common::vault::{storage_bytes_for_account_id, PendingWithdrawal}; /// Set of hacks because near-sdk does not support borshschema and its overkill to implement /// We do not implement refunds for storage management ops, to avoid any potential issues with /// accounting. - /// Conservative per-entry overheads to cover collection metadata, prefixes, etc. pub const MAP_ENTRY_OVERHEAD: u64 = 64; @@ -30,10 +29,6 @@ pub fn yocto_for_ft_account() -> u128 { yocto_for_bytes(storage_bytes_for_ft_account_entry()) } - - - - #[must_use] pub fn storage_bytes_for_pending_withdrawal() -> u64 { // Key is u64 id -> 8 bytes @@ -48,10 +43,8 @@ pub fn yocto_for_bytes(bytes: u64) -> u128 { u128::from(bytes).saturating_mul(price) } - - #[must_use] -pub fn yocto_for_queue_additions(current: &HashSet, new: &[AccountId]) -> u128 { +pub fn yocto_for_queue_additions(current: &BTreeSet, new: &[AccountId]) -> u128 { new.iter().fold(0u128, |acc, id| { if current.contains(id) { acc @@ -85,7 +78,7 @@ pub fn require_attached_for_state_delta(ctx: &str, mutate: impl FnOnce() -> R let delta = after.saturating_sub(before); if delta > 0 { let yocto = yocto_for_bytes(delta); - require_attached_at_least(yocto, ctx); + let _ = require_attached_at_least(yocto, ctx); } out } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index eceae413..4320b593 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1667,10 +1667,7 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { } assert_eq!( - c.markets - .get(&market) - .map(|r| r.principal) - .unwrap_or(u128::MAX), + c.markets.get(&market).map_or(u128::MAX, |r| r.principal), 0, "Market principal should be updated to 0" ); @@ -1861,10 +1858,7 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect } assert_eq!( - c.markets - .get(&market) - .map(|r| r.principal) - .unwrap_or(u128::MAX), + c.markets.get(&market).map_or(u128::MAX, |r| r.principal), before, "principal must remain unchanged on read failure" ); diff --git a/contract/vault/src/wad.rs b/contract/vault/src/wad.rs index 8ab52d4f..164f286a 100644 --- a/contract/vault/src/wad.rs +++ b/contract/vault/src/wad.rs @@ -70,6 +70,8 @@ impl Number { let q = prod / U512::from(denom.0); Number(Self::as_u256_trunc(q)) } + + #[allow(clippy::many_single_char_names)] #[inline] #[must_use] pub fn mul_div_ceil(x: Number, y: Number, denom: Number) -> Number { diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index c4cbaa56..e5e1adaf 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -17,21 +17,6 @@ async fn supply_queue_mustnt_have_duplicates(#[future(awt)] worker: Worker) { - setup_test!( - worker - extract(vault, c, vault_curator) - accounts(supply_user, borrow_user) - ); - let m = c.market.contract().id().clone(); - - let queue = vec![m.clone(), m.clone()]; - vault.set_withdraw_queue(&vault_curator, &queue).await; -} - #[rstest] #[tokio::test] #[should_panic = "Invariant: Only one op in flight"] @@ -52,7 +37,6 @@ async fn state_machine_is_locked_when_another_op_is_running( vault.allocate(&vault_curator, vec![], Some(amount.into())), vault.submit_cap(&vault_curator, m.clone(), (amount * 2).into()), vault.set_supply_queue(&vault_curator, &queue), - vault.set_withdraw_queue(&vault_curator, &queue), vault.allocate(&vault_curator, vec![], Some(amount.into())), ); } diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs index 7fc6e7ab..e3a61556 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, VaultExt}; +use templar_common::vault::{AllocationWeights, DepositMsg, VaultConfiguration}; use tokio::sync::OnceCell; #[derive(Clone)] From f16b7782db76e5d377ab723fae7a9148087288f3 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 3 Nov 2025 15:50:29 +0000 Subject: [PATCH 108/121] chore: shut up clippy --- .../market/tests/configuration_validation.rs | 22 +++-- contract/vault/examples/gas_report.rs | 4 +- contract/vault/src/impl_callbacks.rs | 3 + contract/vault/src/impl_token_receiver.rs | 2 +- contract/vault/src/lib.rs | 16 ++- contract/vault/src/storage_management.rs | 2 +- contract/vault/src/test_utils.rs | 44 ++------- contract/vault/src/tests.rs | 98 ++++++++++++------- contract/vault/tests/happy_path.rs | 16 ++- 9 files changed, 99 insertions(+), 108 deletions(-) diff --git a/contract/market/tests/configuration_validation.rs b/contract/market/tests/configuration_validation.rs index 7fb0f8e8..a0039578 100644 --- a/contract/market/tests/configuration_validation.rs +++ b/contract/market/tests/configuration_validation.rs @@ -125,15 +125,19 @@ async fn withdrawal_minimum_greater_than_supply_minimum() { #[should_panic = "Smart contract panicked: Invalid configuration field `supply_withdrawal_fee.fee`: out of bounds"] async fn withdrawal_fee_greater_than_withdrawal_minimum() { let worker = near_workspaces::sandbox().await.unwrap(); - setup_everything(&worker, |c| { - c.supply_range = (2, None).try_into().unwrap(); - c.supply_withdrawal_range = (2, None).try_into().unwrap(); - c.supply_withdrawal_fee = TimeBasedFee { - fee: Fee::Flat(100.into()), - duration: 100.into(), - behavior: TimeBasedFeeFunction::Linear, - }; - }) + setup_everything( + &worker, + |c| { + c.supply_range = (2, None).try_into().unwrap(); + c.supply_withdrawal_range = (2, None).try_into().unwrap(); + c.supply_withdrawal_fee = TimeBasedFee { + fee: Fee::Flat(100.into()), + duration: 100.into(), + behavior: TimeBasedFeeFunction::Linear, + }; + }, + |_c| {}, + ) .await; } diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index 420e8960..c8824012 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -1,8 +1,8 @@ -#![allow(clippy::wildcard_imports)] +#![allow(clippy::pedantic)] use near_sdk::{json_types::U128, Gas}; use rand::Rng as _; -use test_utils::*; +use test_utils::{setup_test, ContractController}; #[tokio::main] async fn main() { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 3b60a9bf..df1e84eb 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -1,3 +1,5 @@ +#![allow(clippy::too_many_arguments)] + use std::fmt::Display; use crate::{near, Contract, ContractExt, Error, EscrowSettlement, Nep141Controller, OpState}; @@ -76,6 +78,7 @@ impl Contract { } } + #[allow(clippy::too_many_arguments)] #[private] pub fn supply_02_position_read( &mut self, diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index d51f9554..041dacc8 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -64,7 +64,7 @@ impl Nep245Receiver for Contract { /// Returns a one-element vector with the unused amount to refund to the sender. fn mt_on_transfer( &mut self, - _sender_id: AccountId, + #[allow(clippy::used_underscore_binding)] _sender_id: AccountId, previous_owner_ids: Vec, token_ids: Vec, amounts: Vec, diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index f0ee59b7..b01f069f 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -219,13 +219,13 @@ impl Contract { underlying_asset: underlying_token, aum: AUM::BalanceSheet, timelock_ns: initial_timelock_ns.0, - performance_fee: Default::default(), + performance_fee: Wad::default(), fee_recipient, skim_recipient, markets: BTreeMap::new(), pending_timelock: None, pending_guardian: None, - supply_queue: Default::default(), + supply_queue: BTreeSet::default(), last_total_assets: 0, virtual_shares: 1, virtual_assets: 1, @@ -491,7 +491,7 @@ impl Contract { /* ----- Views ----- */ #[near] impl Contract { - /// Panics + /// # Panics /// - If the owner is not set /// - If the curator is not set /// - If the guardian is not set @@ -713,12 +713,7 @@ impl Contract { } // Pure helper to compute how many escrowed shares to burn on partial payout - fn compute_burn_shares( - &self, - escrow_shares: u128, - collected: u128, - requested_total: u128, - ) -> u128 { + fn compute_burn_shares(escrow_shares: u128, collected: u128, requested_total: u128) -> u128 { mul_div_floor( escrow_shares.into(), collected.into(), @@ -1121,7 +1116,7 @@ impl Contract { ) } else { let requested = collected.saturating_add(remaining); - let burn_shares = self.compute_burn_shares(escrow_shares, collected, requested); + let burn_shares = Self::compute_burn_shares(escrow_shares, collected, requested); self.pay_collected( op_id, @@ -1140,6 +1135,7 @@ 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( &mut self, diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs index 49d25417..88096341 100644 --- a/contract/vault/src/storage_management.rs +++ b/contract/vault/src/storage_management.rs @@ -33,7 +33,7 @@ pub fn yocto_for_ft_account() -> u128 { pub fn storage_bytes_for_pending_withdrawal() -> u64 { // Key is u64 id -> 8 bytes let key = 8u64; - let val = PendingWithdrawal::encoded_size() as u64; + let val = PendingWithdrawal::encoded_size(); MAP_ENTRY_OVERHEAD + key + val } diff --git a/contract/vault/src/test_utils.rs b/contract/vault/src/test_utils.rs index 8d412486..4faca3b2 100644 --- a/contract/vault/src/test_utils.rs +++ b/contract/vault/src/test_utils.rs @@ -1,11 +1,13 @@ +#![allow(clippy::all)] + +use std::collections::HashMap; + use crate::Contract; -use near_sdk::env; use near_sdk::NearToken; pub use near_sdk::{ test_utils::{accounts, VMContextBuilder}, test_vm_config, testing_env, AccountId, PromiseResult, RuntimeFeesConfig, }; -use near_sdk_contract_tools::ft::Nep141Controller as _; use near_sdk_contract_tools::ft::Nep145; use test_utils::vault_configuration; @@ -26,7 +28,7 @@ pub fn setup_env( builder.build(), test_vm_config(), RuntimeFeesConfig::test(), - Default::default(), + HashMap::default(), promise_results ); } @@ -60,7 +62,7 @@ pub fn new_test_contract(vault_id: &AccountId) -> Contract { builder.build(), test_vm_config(), RuntimeFeesConfig::test(), - Default::default(), + HashMap::default(), vec![] ); let mut c = Contract::new(cfg); @@ -92,37 +94,3 @@ pub fn set_ctx(vault_id: &AccountId, signer: &AccountId, ts: Option, deposi } testing_env!(ctx.build()); } - -/// Ensure a market exists with given configuration and optionally adds to queues and supply -pub fn ensure_market( - c: &mut crate::Contract, - id: AccountId, - cap: u128, - enabled: bool, - supply: u128, - in_supply: bool, - removable_at: u64, -) { - let mut cfg = templar_common::vault::MarketConfiguration::default(); - cfg.cap = near_sdk::json_types::U128(cap); - cfg.enabled = enabled; - cfg.removable_at = removable_at; - c.markets.insert( - id.clone(), - crate::MarketRecord { - cfg, - pending_cap: None, - principal: supply, - }, - ); - if in_supply && !c.supply_queue.iter().any(|m| m == &id) { - c.supply_queue.insert(id.clone()); - } -} - -/// Seed shares into the vault's own account (used for escrow/burn tests) -pub fn seed_vault_shares(c: &mut crate::Contract, shares: u128) { - #[allow(clippy::expect_used, reason = "test helper")] - c.deposit_unchecked(&near_sdk::env::current_account_id(), shares) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); -} diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 4320b593..8792f4be 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1,4 +1,4 @@ -use std::u64; +#![allow(clippy::pedantic)] use crate::impl_callbacks::reconcile_supply_outcome; use crate::impl_callbacks::WithdrawReconciliation; @@ -63,9 +63,11 @@ fn c_asset_env(vault_id_fixture: AccountId) -> Contract { #[fixture] fn enabled_market_100() -> (AccountId, MarketConfiguration) { let m = mk(9001); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(100); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(100), + enabled: true, + removable_at: 0, + }; (m, cfg) } @@ -228,9 +230,11 @@ fn start_allocation_reserves_only_amount(c_vault_env: Contract) { // Configure a single market with cap = 80 in the supply queue let m1 = mk(2000); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(80); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(80), + enabled: true, + removable_at: 0, + }; c.markets.insert( m1.clone(), MarketRecord { @@ -303,9 +307,11 @@ fn queue_allocation_ignores_stale_plan() { let m1 = mk(3001); let m2 = mk(3002); - let mut cfg1 = MarketConfiguration::default(); - cfg1.cap = U128(10); - cfg1.enabled = true; + let cfg1 = MarketConfiguration { + cap: U128(10), + enabled: true, + removable_at: 0, + }; c.markets.insert(m1.clone(), cfg1.into()); c.supply_queue.insert(m1); @@ -334,9 +340,11 @@ fn queue_allocation_ignores_stale_plan() { fn compute_burn_shares_cases(escrow: u128, collected: u128, requested: u128, expect: u128) { let vault_id = accounts(0); setup_env(&vault_id, &vault_id, vec![]); - let c = new_test_contract(&vault_id); - assert_eq!(c.compute_burn_shares(escrow, collected, requested), expect); + assert_eq!( + Contract::compute_burn_shares(escrow, collected, requested), + expect + ); } #[test] @@ -384,9 +392,11 @@ fn cap_zero_keeps_enabled_and_submit_removal_works() { let m = mk(8001); // Seed a known, enabled market with cap > 0 - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(10); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(10), + enabled: true, + removable_at: 0, + }; c.markets.insert( m.clone(), MarketRecord { @@ -539,9 +549,11 @@ fn clamp_allocation_total_matches_min_bounds_cases( let mut c = new_test_contract(&vault_id); let m = mk(1); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(cap); - cfg.enabled = cap > 0; + let cfg = MarketConfiguration { + cap: U128(cap), + enabled: cap > 0, + removable_at: 0, + }; c.markets.insert( m.clone(), MarketRecord { @@ -938,9 +950,11 @@ fn ft_on_transfer_wrong_token_full_refund_via_receiver() { // Provide a market (not used due to wrong token) let m = mk(9003); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(100); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(100), + enabled: true, + removable_at: 0, + }; c.markets.insert(m.clone(), cfg.into()); c.supply_queue.insert(m); @@ -989,7 +1003,6 @@ fn ft_on_transfer_zero_amount_returns_zero_refund( c.supply_queue.insert(m); let sender: AccountId = c.underlying_asset.contract_id().into(); - let bal_before = c.balance_of(&sender); c.ft_on_transfer( sender.clone(), @@ -1138,9 +1151,11 @@ fn governance_set_curator_grants_allocator() { // Prepare a market to exercise allocator permission let m1 = mk(9101); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(1); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(1), + enabled: true, + removable_at: 0, + }; c.markets.insert(m1.clone(), cfg.into()); let new_cur = accounts(3); @@ -1169,9 +1184,11 @@ fn governance_set_is_allocator_grant_allows_queue_ops() { // Market to operate on let m1 = mk(9102); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(1); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(1), + enabled: true, + removable_at: 0, + }; c.markets.insert(m1.clone(), cfg.into()); // Grant Allocator role @@ -1202,9 +1219,12 @@ fn governance_set_is_allocator_revoke_disallows_queue_ops() { // Market to attempt on let m1 = mk(9103); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(1); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(1), + enabled: true, + removable_at: 0, + }; + c.markets.insert(m1.clone(), cfg.into()); // Revoke Allocator role; subsequent queue op by grantee should panic due to lack of rights @@ -1335,9 +1355,11 @@ fn governance_submit_cap_immediate_decrease() { setup_env(&vault_id, &owner, vec![]); let m = mk(9104); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(10); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(10), + enabled: true, + removable_at: 0, + }; c.markets.insert(m.clone(), cfg.into()); c.submit_cap(m.clone(), U128(3)); @@ -1404,9 +1426,11 @@ fn governance_submit_and_revoke_market_removal() { c.timelock_ns = 1; let m = mk(9107); - let mut cfg = MarketConfiguration::default(); - cfg.cap = U128(0); - cfg.enabled = true; + let cfg = MarketConfiguration { + cap: U128(0), + enabled: true, + removable_at: 0, + }; c.markets.insert(m.clone(), cfg.into()); // Submit removal (schedules timelock) diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs index 248d2972..6769e3c7 100644 --- a/contract/vault/tests/happy_path.rs +++ b/contract/vault/tests/happy_path.rs @@ -1,3 +1,5 @@ +#![allow(clippy::all, clippy::pedantic)] + use near_sdk::json_types::U128; use near_workspaces::{network::Sandbox, Worker}; use rstest::rstest; @@ -152,18 +154,12 @@ async fn happy(#[future(awt)] worker: Worker) { .await; } -// FIXME: should also do this in allocate on behalf of the vault? pub async fn harvest(c: &UnifiedMarketController, vault: &UnifiedVaultController) { // Wait for activation. - while !c - .get_supply_position(vault.contract().id()) - .await - .unwrap() - .get_deposit() - .incoming - .is_empty() - { - // TODO: should also do this in allocate + while let Some(position) = c.get_supply_position(vault.contract().id()).await { + if position.get_deposit().incoming.is_empty() { + break; + } c.harvest_yield(vault.contract().as_account(), None, None) .await; } From 9f1c909fe644963638bfafc9a45f451685d31a7d Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 11:01:27 +0000 Subject: [PATCH 109/121] chore: update gas --- common/src/vault.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 3ac40e2d..02c13d36 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -189,16 +189,14 @@ pub const AFTER_SUPPLY_1_CHECK_GAS: Gas = buffer(GET_SUPPLY_POSITION + AFTER_SUP // NOTE: these are taken after running the contract with the gas report and cieled to next whole TGAS. pub const SUPPLY_GAS: Gas = buffer(8); -pub const ALLOCATE_GAS: Gas = buffer(28); - +pub const ALLOCATE_GAS: Gas = buffer(20); pub const WITHDRAW_GAS: Gas = buffer(4); - pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); +pub const SUBMIT_CAP_GAS: Gas = buffer(3); + const AFTER_SEND_TO_USER: u64 = 5; pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(AFTER_SEND_TO_USER); -pub const SUBMIT_CAP_GAS: Gas = buffer(3); - pub fn require_at_least(needed: Gas) { let gas = env::prepaid_gas(); require!( From c1bc42e6e12105e981c0ed03f25a5d1c9edadce9 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 11:01:35 +0000 Subject: [PATCH 110/121] fix: gas for skim --- contract/vault/src/impl_callbacks.rs | 4 ++-- contract/vault/src/lib.rs | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index df1e84eb..2d8b5136 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -4,7 +4,7 @@ use std::fmt::Display; use crate::{near, Contract, ContractExt, Error, EscrowSettlement, Nep141Controller, OpState}; use near_contract_standards::fungible_token::core::ext_ft_core; -use near_sdk::{env, json_types::U128, AccountId, NearToken, PromiseError, PromiseOrValue}; +use near_sdk::{env, json_types::U128, AccountId, Gas, NearToken, PromiseError, PromiseOrValue}; use near_sdk_contract_tools::ft::{nep141::GAS_FOR_FT_TRANSFER_CALL, Nep141Burn, Nep141Transfer}; use templar_common::{ market::ext_market, @@ -472,7 +472,7 @@ impl Contract { PromiseOrValue::Promise( ext_ft_core::ext(token) .with_attached_deposit(NearToken::from_yoctonear(1)) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .with_static_gas(Gas::from_tgas(5)) .ft_transfer(recipient, U128(amount), None), ) } diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index b01f069f..9b5ba9f7 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -15,7 +15,8 @@ use near_sdk::{ json_types::{U128, U64}, near, require, serde_json, store::IterableMap, - AccountId, BorshStorageKey, IntoStorageKey, NearToken, PanicOnDefault, Promise, PromiseOrValue, + AccountId, BorshStorageKey, Gas, IntoStorageKey, NearToken, PanicOnDefault, Promise, + PromiseOrValue, }; use near_sdk_contract_tools::{ ft::{ @@ -381,11 +382,11 @@ impl Contract { self.ensure_idle(); ext_ft_core::ext(token.clone()) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .with_static_gas(Gas::from_tgas(3)) .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) - .with_static_gas(GAS_FOR_FT_TRANSFER_CALL) + .with_static_gas(Gas::from_tgas(10)) .skim_01_read_balance(token, self.skim_recipient.clone()), ) } From adc21ed85f85f635d8ad7bcadf65f8ca6da01361 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 11:01:45 +0000 Subject: [PATCH 111/121] fix: invariants were hard to force race condition --- contract/vault/src/impl_callbacks.rs | 2 +- contract/vault/tests/invariants.rs | 14 +++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 2d8b5136..52154d8f 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -5,7 +5,7 @@ use std::fmt::Display; use crate::{near, Contract, ContractExt, Error, EscrowSettlement, 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::{nep141::GAS_FOR_FT_TRANSFER_CALL, Nep141Burn, Nep141Transfer}; +use near_sdk_contract_tools::ft::{Nep141Burn, Nep141Transfer}; use templar_common::{ market::ext_market, supply::SupplyPosition, diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs index e5e1adaf..3ad7f04e 100644 --- a/contract/vault/tests/invariants.rs +++ b/contract/vault/tests/invariants.rs @@ -25,18 +25,14 @@ async fn state_machine_is_locked_when_another_op_is_running( ) { setup_test!( worker - extract(vault, c, vault_curator) + extract(vault, c, vault_owner) accounts(supply_user, borrow_user) ); let amount = 1000; - let m = c.market.contract().id().clone(); vault.supply(&supply_user, amount).await; - let queue = vec![m.clone()]; - tokio::join!( - vault.allocate(&vault_curator, vec![], Some(amount.into())), - vault.submit_cap(&vault_curator, m.clone(), (amount * 2).into()), - vault.set_supply_queue(&vault_curator, &queue), - vault.allocate(&vault_curator, vec![], Some(amount.into())), - ); + futures::future::select_all( + (0..100).map(|_| Box::pin(vault.allocate(&vault_owner, vec![], Some(1.into())))), + ) + .await; } From 3865729701ff53e84fe6b9f8a1aea00697c51199 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 12:24:38 +0000 Subject: [PATCH 112/121] fix: TOCTOU during payouts would cause accounting drift --- contract/vault/src/impl_token_receiver.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 041dacc8..ed4f63f6 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -108,6 +108,10 @@ impl Contract { require!(deposit > 0, "Deposit amount must be greater than zero"); + if matches!(self.op_state, OpState::Payout(_)) { + env::panic_str("Cannot deposit during payout"); + } + self.internal_accrue_fee(); let max = self.get_max_deposit().0; From bf57830c55e98d2bb5d774e24c629fde261489fe Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 12:40:57 +0000 Subject: [PATCH 113/121] fix: check actual balance bounds for the vault around supply boundaries (don't trust the market) --- common/src/vault.rs | 9 + contract/vault/src/impl_callbacks.rs | 257 +++++++++++++++++++++------ contract/vault/src/lib.rs | 13 +- contract/vault/src/tests.rs | 72 ++++++-- 4 files changed, 276 insertions(+), 75 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 02c13d36..366b7b4d 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -668,6 +668,15 @@ pub enum Event { token: AccountId, recipient: AccountId, }, + + #[event_version("1.0.0")] + WithdrawalPositionMissing { + op_id: U64, + market: AccountId, + index: u32, + before: U128, + need: U128, + }, } #[cfg(test)] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 52154d8f..cc8f0979 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -11,6 +11,7 @@ use templar_common::{ supply::SupplyPosition, vault::{ AllocatingState, Event, PayoutState, WithdrawingState, + EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS, GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, }, @@ -119,6 +120,12 @@ impl Contract { accepted, } .emit(); + + // NOTE: this may create accounting drift if we have infact transferred the funds to the market. + // Since we credit the idle position again on stop_and_exit. + // + // This is a critical path where we trust the market's position, however, this is expected as we have verified the following: + // - The transfer call to the market was successful in the previous receipt. return self.stop_and_exit(Some(&Error::MissingSupplyPosition)); } Err(_) => { @@ -135,6 +142,7 @@ impl Contract { }; let refunded = attempted.0.saturating_sub(accepted_event); + Event::AllocationStepSettled { op_id: op_id.into(), index: market_index, @@ -211,12 +219,47 @@ impl Contract { } } + #[private] + pub fn execute_withdraw_00_before_balance( + &mut self, + #[callback_result] before_balance: Result, + op_id: u64, + market_index: u32, + batch_limit: Option, + ) -> PromiseOrValue<()> { + let _ctx = match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), + }; + + let market = match self.resolve_withdraw_market(market_index) { + Ok(m) => m.clone(), + Err(e) => return self.stop_and_exit(Some(&e)), + }; + + let bb = before_balance.unwrap_or(U128(0)); + + PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) + .with_unused_gas_weight(0) + .execute_next_supply_withdrawal_request(batch_limit) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS) + // keep `need` for event compatibility; we don't use it for logic + .execute_withdraw_01_fetch_position(op_id, market_index, U128(0), bb), + ), + ) + } + #[private] pub fn execute_withdraw_01_fetch_position( &mut self, op_id: u64, market_index: u32, need: U128, + before_balance: U128, ) -> PromiseOrValue<()> { let ctx = match self.ctx_withdrawing(op_id) { Ok(v) => v, @@ -247,96 +290,97 @@ impl Contract { market_index, U128(before), need, + before_balance, ), ), ) } - /// Cash flow: - /// - Reconcile market position to compute 'credited' (funds returned from market). - /// - Increment idle_balance by credited to reflect funds now held by the vault. - /// - If remaining == 0, transition to Payout; otherwise continue Withdrawing on next market. - /// - Later in after_send_to_user, idle_balance is decremented on successful transfer to the user. - /// - On transfer failure, idle_balance stays unchanged and escrowed shares are refunded to the owner. - /// - /// # Panics - /// - If the market is not found. #[private] - pub fn execute_withdraw_02_reconcile_position( + pub fn execute_withdraw_03_settle( &mut self, - #[callback_result] position: Result, PromiseError>, + #[callback_result] after_balance: Result, op_id: u64, market_index: u32, - before: U128, + before_principal: U128, + new_principal_reported: U128, need: U128, + before_balance: U128, ) -> PromiseOrValue<()> { let ctx = match self.ctx_withdrawing(op_id) { - Ok(v) => v, + Ok(v) => v.clone(), Err(e) => return self.stop_and_exit(Some(&e)), - } - .clone(); + }; if ctx.index != market_index { return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); } let market = match self.resolve_withdraw_market(market_index) { - Ok(m) => m, + Ok(m) => m.clone(), Err(e) => return self.stop_and_exit(Some(&e)), }; - let before_principal = before.0; - let new_principal = match position { - Ok(Some(position)) => { - let np: u128 = position.get_deposit().total().into(); - np - } - Ok(None) => { - // No position => treat as principal = 0 - 0 - } + let before_p = before_principal.0; + let new_p_raw = new_principal_reported.0; + + // Principal drop as reported by the market + let principal_drop = before_p.saturating_sub(new_p_raw); + + // Actual token inflow = after_balance - before_balance (fail-closed to 0 on error) + let bb = before_balance.0; + let ab = match after_balance { + Ok(U128(v)) => v, Err(_) => { - Event::WithdrawalPositionReadFailed { - op_id: op_id.into(), - market: market.clone(), - index: market_index, - before: U128(before_principal), - need, - } - .emit(); - before_principal + env::log_str(&format!( + "WithdrawBalanceReadFailed market={} op_id={} index={}", + market, op_id, market_index + )); + 0 } }; + let inflow = ab.saturating_sub(bb); - let WithdrawReconciliation { - remaining_next, - collected_next, - idle_delta, - .. - } = reconcile_withdraw_outcome( - before_principal, - new_principal, - ctx.remaining, - ctx.collected, - ); + // Only credit what we actually received on-chain, capped by the market's reported principal drop + let creditable = core::cmp::min(principal_drop, inflow); - if let Some(rec) = self.markets.get_mut(&market.clone()) { - rec.principal = new_principal; + if principal_drop > inflow { + env::log_str(&format!( + "WithdrawalInflowMismatch market={} op_id={} index={} principal_drop={} inflow={}", + market, op_id, market_index, principal_drop, inflow + )); } - if idle_delta > 0 { - self.idle_balance = self.idle_balance.saturating_add(idle_delta); + + // Effective new principal keeps accounting consistent with credited inflow + let eff_new_principal = before_p.saturating_sub(creditable); + + if let Some(rec) = self.markets.get_mut(&market) { + rec.principal = eff_new_principal; + } + if creditable > 0 { + self.idle_balance = self.idle_balance.saturating_add(creditable); } + // Settle pending market exec entry only if fully credited if let Some(pos) = self .pending_market_exec .iter() .position(|&idx| idx == market_index) { - self.pending_market_exec.remove(pos); + if creditable == principal_drop { + self.pending_market_exec.remove(pos); + } } + // Reconcile remaining/collected based on credited inflow only + let WithdrawReconciliation { + remaining_next, + collected_next, + .. + } = reconcile_withdraw_outcome(before_p, eff_new_principal, ctx.remaining, ctx.collected); + if remaining_next == 0 { - self.pay_collected( + return self.pay_collected( op_id, &ctx.receiver, collected_next, @@ -344,9 +388,8 @@ impl Contract { ctx.escrow_shares, ctx.escrow_shares, |self_| { - // Nothing collected; refund escrowed shares + // On early completion we still finalize; storage safety as in existing code let self_id = env::current_account_id(); - // We expect the owner to maintain storage accounts, otherwise they will lose access to their funds self_ .transfer(&Nep141Transfer::new( ctx.escrow_shares, @@ -354,11 +397,17 @@ impl Contract { &ctx.owner, )) .unwrap_or_else(|_| env::panic_str("Failed to refund escrowed shares")); + self_.pending_market_exec.clear(); + self_.remove_inflight_and_advance_head(); + self_.withdraw_route.clear(); self_.op_state = OpState::Idle; PromiseOrValue::Value(()) }, - ) - } else { + ); + } + + if creditable == principal_drop && principal_drop > 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), @@ -369,9 +418,105 @@ impl Contract { escrow_shares: ctx.escrow_shares, }); self.step_withdraw() + } else { + // 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(()) } } + /// Cash flow: + /// - Reconcile market position to compute 'credited' (funds returned from market). + /// - Increment idle_balance by credited to reflect funds now held by the vault. + /// - If remaining == 0, transition to Payout; otherwise continue Withdrawing on next market. + /// - Later in after_send_to_user, idle_balance is decremented on successful transfer to the user. + /// - On transfer failure, idle_balance stays unchanged and escrowed shares are refunded to the owner. + /// + /// # Panics + /// - If the market is not found. + #[private] + pub fn execute_withdraw_02_reconcile_position( + &mut self, + #[callback_result] position: Result, PromiseError>, + op_id: u64, + market_index: u32, + before: U128, + need: U128, + before_balance: U128, + ) -> PromiseOrValue<()> { + let ctx = match self.ctx_withdrawing(op_id) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), + } + .clone(); + + if ctx.index != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); + } + + let market = match self.resolve_withdraw_market(market_index) { + Ok(m) => m, + Err(e) => return self.stop_and_exit(Some(&e)), + }; + + let before_principal = before.0; + let new_principal = match position { + Ok(Some(position)) => { + let np: u128 = position.get_deposit().total().into(); + np + } + Ok(None) => { + Event::WithdrawalPositionMissing { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + before, + need, + } + .emit(); + // Treat missing position as zero principal and continue to balance settlement + 0 + } + Err(_) => { + Event::WithdrawalPositionReadFailed { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + before, + need, + } + .emit(); + return self.stop_and_exit(Some(&Error::PositionReadFailed)); + } + }; + + PromiseOrValue::Promise( + ext_ft_core::ext(self.underlying_asset.contract_id().into()) + .with_static_gas(Gas::from_tgas(5)) + .ft_balance_of(env::current_account_id()) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS) + .execute_withdraw_03_settle( + op_id, + market_index, + U128(before_principal), + U128(new_principal), + need, + before_balance, + ), + ), + ) + } + /// Cash flow: /// - Runs in Payout context after funds were credited in after_exec_withdraw_read. /// - On success: idle_balance -= amount; burn a portion of escrow_shares and refund the rest to the owner. diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 9b5ba9f7..aa7075a0 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -353,14 +353,17 @@ impl Contract { }; PromiseOrValue::Promise( - templar_common::market::ext_market::ext(market.clone()) - .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) - .with_unused_gas_weight(0) - .execute_next_supply_withdrawal_request(batch_limit) + ext_ft_core::ext(self.underlying_asset.contract_id().into()) + .with_static_gas(Gas::from_tgas(5)) + .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_fetch_position(op_id.into(), market_index, U128(0)), + .execute_withdraw_00_before_balance( + op_id.into(), + market_index, + batch_limit, + ), ), ) } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 8792f4be..25f54ffe 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1683,11 +1683,27 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { escrow_shares: 50, }); - let res = c.execute_withdraw_02_reconcile_position(Ok(None), 42, 0, U128(100), U128(60)); + let res = c.execute_withdraw_02_reconcile_position(Ok(None), 42, 0, U128(100), U128(60), U128(0)); match res { PromiseOrValue::Promise(_p) => {} - _ => panic!("Expected a Promise to send payout"), + _ => panic!("Expected a Promise to proceed to balance settlement"), + } + + // Simulate the after-balance callback to settle crediting using actual inflow + let res2 = c.execute_withdraw_03_settle( + Ok(U128(100)), // observed after_balance + 42, + 0, + U128(100), // before_principal + U128(0), // new_principal reported by market + U128(60), // need + U128(0), // before_balance snapshot + ); + + match res2 { + PromiseOrValue::Promise(_p) => {} + _ => panic!("Expected a Promise to send payout after settlement"), } assert_eq!( @@ -1875,10 +1891,11 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect 0, U128(before), U128(need), + U128(0), ); match res { - PromiseOrValue::Promise(_) => {} - _ => panic!("Expected Promise to send payout at end-of-queue"), + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) due to read failure and stop"), } assert_eq!( @@ -1891,12 +1908,10 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect "idle_balance must not change when nothing credited" ); - match &c.op_state { - OpState::Payout(PayoutState { amount, .. }) => { - assert_eq!(*amount, collected, "Payout amount must equal collected"); - } - other => panic!("Unexpected state: {other:?}"), - } + assert!( + matches!(c.op_state, OpState::Idle), + "Vault must go Idle on read failure" + ); } /// Property: Callbacks must match current op_id or index; otherwise stop and go Idle @@ -1937,7 +1952,7 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde let call_idx = if pass_index { real_idx } else { real_idx + 1 }; let r = - c.execute_withdraw_02_reconcile_position(Ok(None), call_op, call_idx, U128(10), U128(1)); + c.execute_withdraw_02_reconcile_position(Ok(None), call_op, call_idx, U128(10), U128(1), U128(0)); if let (true, true) = (pass_op, pass_index) { assert!( !matches!(c.op_state, OpState::Idle), @@ -1982,8 +1997,22 @@ fn refund_path_consistency() { let owner_before = c.balance_of(&owner); // Read result with need=0 ensures credited=0; triggers refund branch - let res = c.execute_withdraw_02_reconcile_position(Ok(None), 77, 0, U128(0), U128(0)); + let res = c.execute_withdraw_02_reconcile_position(Ok(None), 77, 0, U128(0), U128(0), U128(0)); match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to proceed to balance settlement"), + } + + let res2 = c.execute_withdraw_03_settle( + Ok(U128(0)), // no inflow observed + 77, + 0, + U128(0), // before_principal + U128(0), // new_principal reported + U128(0), // need + U128(0), // before_balance + ); + match res2 { PromiseOrValue::Value(()) => {} _ => panic!("Expected Value(()) on immediate escrow refund"), } @@ -2203,7 +2232,7 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { escrow_shares: 0, }); - let res = c.execute_withdraw_01_fetch_position(33, 0, U128(5)); + let res = c.execute_withdraw_01_fetch_position(33, 0, U128(5), U128(0)); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to read supply position after exec"), @@ -2243,12 +2272,27 @@ fn after_exec_withdraw_read_advances_when_remaining( }); // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 - let res = c.execute_withdraw_02_reconcile_position(Ok(None), 0, 0, U128(10), U128(100)); + let res = c.execute_withdraw_02_reconcile_position(Ok(None), 0, 0, U128(10), U128(100), U128(0)); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to continue withdraw steps"), } + // Settle with observed inflow equal to the reported principal drop (10) + let res2 = c.execute_withdraw_03_settle( + Ok(U128(10)), // after_balance + 0, + 0, + U128(10), // before_principal + U128(0), // new_principal reported + U128(100), // need + U128(0), // before_balance + ); + match res2 { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise to proceed to payout after advancing"), + } + // Idle credited, state advanced to next index with remaining reduced assert_eq!(c.idle_balance, 10); From 928e076b4bf8ab8dd3b3af410b245641ea153288 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 12:44:07 +0000 Subject: [PATCH 114/121] fix: share accounting bug was settling AFTER payouts --- contract/vault/src/impl_callbacks.rs | 12 ++++++------ contract/vault/src/lib.rs | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index cc8f0979..8c926930 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -24,7 +24,7 @@ use templar_common::{ /// - Payout -> Idle (success or failure) /// /// Invariants: -/// - idle_balance increases only when funds are received and decreases only on payout success. +/// - idle_balance increases only when funds are received and is pre-decremented when payout is initiated (restored on failure). /// - escrow_shares are refunded on stop/failure or partially burned/refunded on payout success. #[near] impl Contract { @@ -519,8 +519,8 @@ impl Contract { /// Cash flow: /// - Runs in Payout context after funds were credited in after_exec_withdraw_read. - /// - On success: idle_balance -= amount; burn a portion of escrow_shares and refund the rest to the owner. - /// - On failure: refund full escrow_shares to the owner and keep idle_balance unchanged (funds remain in vault). + /// - On success: idle_balance was pre-decremented before transfer; burn a portion of escrow_shares and refund the rest to the owner. + /// - On failure: refund full escrow_shares to the owner and restore idle_balance (funds remain in vault). #[private] pub fn payment_01_reconcile_idle_or_refund( &mut self, @@ -552,8 +552,7 @@ impl Contract { }; if result.is_ok() { - // On payout success, idle_balance -= payout_amount. - self.idle_balance = self.idle_balance.saturating_sub(expected_amount); + // On payout success, idle_balance was already decremented before transfer. let EscrowSettlement { to_burn: burn_shares, @@ -580,7 +579,8 @@ impl Contract { .unwrap_or_else(|e| env::log_str(&e.to_string())); } } else { - // On payout failure, refund full escrow to owner and leave idle_balance unchanged + // On payout failure, refund full escrow to owner and restore idle_balance + self.idle_balance = self.idle_balance.saturating_add(expected_amount); self.transfer(&Nep141Transfer::new( escrow_shares, env::current_account_id(), diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index aa7075a0..94472426 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -1080,6 +1080,8 @@ impl Contract { escrow_shares, burn_shares: escrow_shares, }); + require!(self.idle_balance >= collected, "idle underflow in payout"); + self.idle_balance -= collected; return PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) @@ -1160,6 +1162,8 @@ impl Contract { escrow_shares, burn_shares, }); + require!(self.idle_balance >= collected, "idle underflow in payout"); + self.idle_balance -= collected; PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) From 13dbaa4eb4f1e0185b00a19d2e4638615bb2c190 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 15:09:53 +0000 Subject: [PATCH 115/121] fix: avoid wedging during dust redemptions --- contract/vault/src/lib.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 94472426..5e695b8c 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -274,6 +274,7 @@ impl Contract { let sender = env::predecessor_account_id(); require!(shares > 0, "Invalid shares"); + require!(assets > 0, "Dust redeem would yield 0 assets"); let _ = require_attached_for_pending_withdrawal(); @@ -314,6 +315,14 @@ impl Contract { .unwrap_or_else(|| env::panic_str("pending vanished unexpectedly")); let owner = pending.owner.clone(); let receiver = pending.receiver.clone(); + + 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(); + return self.execute_next_withdrawal_request(route); + } + self.current_withdraw_inflight = Some(id); env::log_str(&format!("WithdrawalExecutionStarted id={id}")); return self.start_withdraw( @@ -818,7 +827,9 @@ impl Contract { fn start_allocation(&mut self, amount: u128) -> PromiseOrValue<()> { if amount == 0 { - return self.stop_and_exit(Some(&Error::ZeroAmount)); + // 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 62768bbb97d9c67842b5cba133cfb8d689609bfd Mon Sep 17 00:00:00 2001 From: carrion256 Date: Tue, 4 Nov 2025 15:28:59 +0000 Subject: [PATCH 116/121] fix: additive inflow stranding surplus principal --- common/src/vault.rs | 7 +++++ contract/vault/src/impl_callbacks.rs | 28 +++++++++++++++---- contract/vault/src/tests.rs | 40 +++++++++++++++++----------- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index 366b7b4d..ca649148 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -677,6 +677,13 @@ pub enum Event { before: U128, need: U128, }, + #[event_version("1.0.0")] + WithdrawalOverpayCredited { + op_id: U64, + market: AccountId, + index: u32, + extra: U128, + }, } #[cfg(test)] diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 8c926930..eddb7339 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -341,24 +341,34 @@ impl Contract { }; let inflow = ab.saturating_sub(bb); - // Only credit what we actually received on-chain, capped by the market's reported principal drop + // Compute effective principal drop we can book (conservative on shortfall) let creditable = core::cmp::min(principal_drop, inflow); + // Log mismatch cases and emit an event if market overpays (yield with principal) if principal_drop > inflow { env::log_str(&format!( "WithdrawalInflowMismatch market={} op_id={} index={} principal_drop={} inflow={}", market, op_id, market_index, principal_drop, inflow )); + } else if inflow > principal_drop { + let extra = inflow.saturating_sub(principal_drop); + Event::WithdrawalOverpayCredited { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + extra: U128(extra), + } + .emit(); } - // Effective new principal keeps accounting consistent with credited inflow + // Update principal by the amount we can safely credit, and always credit the full inflow to idle let eff_new_principal = before_p.saturating_sub(creditable); if let Some(rec) = self.markets.get_mut(&market) { rec.principal = eff_new_principal; } - if creditable > 0 { - self.idle_balance = self.idle_balance.saturating_add(creditable); + if inflow > 0 { + self.idle_balance = self.idle_balance.saturating_add(inflow); } // Settle pending market exec entry only if fully credited @@ -379,6 +389,12 @@ impl Contract { .. } = reconcile_withdraw_outcome(before_p, eff_new_principal, ctx.remaining, ctx.collected); + // If market overpaid beyond principal drop, use the extra to satisfy this withdrawal + let extra = inflow.saturating_sub(principal_drop); + let extra_payout = core::cmp::min(extra, remaining_next); + let mut remaining_next = remaining_next.saturating_sub(extra_payout); + let mut collected_next = collected_next.saturating_add(extra_payout); + if remaining_next == 0 { return self.pay_collected( op_id, @@ -396,7 +412,9 @@ impl Contract { &self_id, &ctx.owner, )) - .unwrap_or_else(|_| env::panic_str("Failed to refund escrowed shares")); + .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(); diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 25f54ffe..f63a7502 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -1683,7 +1683,8 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { escrow_shares: 50, }); - let res = c.execute_withdraw_02_reconcile_position(Ok(None), 42, 0, U128(100), U128(60), U128(0)); + let res = + c.execute_withdraw_02_reconcile_position(Ok(None), 42, 0, U128(100), U128(60), U128(0)); match res { PromiseOrValue::Promise(_p) => {} @@ -1695,10 +1696,10 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { Ok(U128(100)), // observed after_balance 42, 0, - U128(100), // before_principal - U128(0), // new_principal reported by market - U128(60), // need - U128(0), // before_balance snapshot + U128(100), // before_principal + U128(0), // new_principal reported by market + U128(60), // need + U128(0), // before_balance snapshot ); match res2 { @@ -1951,8 +1952,14 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde let call_op = if pass_op { real_op } else { real_op + 1 }; let call_idx = if pass_index { real_idx } else { real_idx + 1 }; - let r = - c.execute_withdraw_02_reconcile_position(Ok(None), call_op, call_idx, U128(10), U128(1), U128(0)); + let r = c.execute_withdraw_02_reconcile_position( + Ok(None), + call_op, + call_idx, + U128(10), + U128(1), + U128(0), + ); if let (true, true) = (pass_op, pass_index) { assert!( !matches!(c.op_state, OpState::Idle), @@ -2007,10 +2014,10 @@ fn refund_path_consistency() { Ok(U128(0)), // no inflow observed 77, 0, - U128(0), // before_principal - U128(0), // new_principal reported - U128(0), // need - U128(0), // before_balance + U128(0), // before_principal + U128(0), // new_principal reported + U128(0), // need + U128(0), // before_balance ); match res2 { PromiseOrValue::Value(()) => {} @@ -2272,7 +2279,8 @@ fn after_exec_withdraw_read_advances_when_remaining( }); // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 - let res = c.execute_withdraw_02_reconcile_position(Ok(None), 0, 0, U128(10), U128(100), U128(0)); + let res = + c.execute_withdraw_02_reconcile_position(Ok(None), 0, 0, U128(10), U128(100), U128(0)); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to continue withdraw steps"), @@ -2283,10 +2291,10 @@ fn after_exec_withdraw_read_advances_when_remaining( Ok(U128(10)), // after_balance 0, 0, - U128(10), // before_principal - U128(0), // new_principal reported - U128(100), // need - U128(0), // before_balance + U128(10), // before_principal + U128(0), // new_principal reported + U128(100), // need + U128(0), // before_balance ); match res2 { PromiseOrValue::Promise(_) => {} From 52ae2dcd47af589104e32f8782cc3d620dd8cd4f Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 5 Nov 2025 11:42:47 +0000 Subject: [PATCH 117/121] fix: after defensive idle decrement - tests --- common/src/vault.rs | 34 ++- contract/vault/src/impl_callbacks.rs | 334 ++++++++++++---------- contract/vault/src/impl_token_receiver.rs | 6 +- contract/vault/src/lib.rs | 26 +- contract/vault/src/tests.rs | 218 +++++--------- 5 files changed, 304 insertions(+), 314 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index ca649148..fc36db20 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -450,12 +450,36 @@ pub const fn storage_bytes_for_account_id() -> u64 { 4 + AccountId::MAX_LEN as u64 } +#[derive(Clone, Debug)] +#[near(serializers = [borsh, json])] +pub enum IdleBalanceDelta { + Increase(U128), + Decrease(U128), +} + +impl IdleBalanceDelta { + pub fn apply(&self, balance: u128) -> u128 { + let new = match self { + IdleBalanceDelta::Increase(amount) => balance.saturating_add(amount.0), + IdleBalanceDelta::Decrease(amount) => balance.saturating_sub(amount.0), + }; + Event::IdleBalanceUpdated { + prev: U128::from(balance), + delta: self.clone(), + } + .emit(); + new + } +} + #[near(event_json(standard = "templar-vault"))] pub enum Event { #[event_version("1.0.0")] MintedShares { amount: U128, receiver: AccountId }, #[event_version("1.0.0")] AllocationStarted { op_id: U64, remaining: U128 }, + #[event_version("1.0.0")] + IdleBalanceUpdated { prev: U128, delta: IdleBalanceDelta }, // Allocation lifecycle (plan/request) #[event_version("1.0.0")] @@ -626,7 +650,6 @@ pub enum Event { market: AccountId, index: u32, before: U128, - need: U128, }, #[event_version("1.0.0")] @@ -675,7 +698,14 @@ pub enum Event { market: AccountId, index: u32, before: U128, - need: U128, + }, + #[event_version("1.0.0")] + WithdrawalInflowMismatch { + op_id: U64, + market: AccountId, + index: u32, + delta: U128, + inflow: U128, }, #[event_version("1.0.0")] WithdrawalOverpayCredited { diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index eddb7339..a7fe75ea 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -10,7 +10,7 @@ use templar_common::{ market::ext_market, supply::SupplyPosition, vault::{ - AllocatingState, Event, PayoutState, WithdrawingState, + AllocatingState, Event, IdleBalanceDelta, PayoutState, WithdrawingState, EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS, GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, @@ -220,51 +220,81 @@ impl Contract { } #[private] - pub fn execute_withdraw_00_before_balance( + pub fn execute_withdraw_01_call_market_fetch_position( &mut self, #[callback_result] before_balance: Result, op_id: u64, market_index: u32, batch_limit: Option, ) -> PromiseOrValue<()> { - let _ctx = match self.ctx_withdrawing(op_id) { + let ctx = match self.ctx_withdrawing(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), }; + if ctx.index != market_index { + return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); + } + let market = match self.resolve_withdraw_market(market_index) { Ok(m) => m.clone(), Err(e) => return self.stop_and_exit(Some(&e)), }; - let bb = before_balance.unwrap_or(U128(0)); + let principal = self.principal_of(&market); + let before_balance = before_balance.unwrap_or(U128(0)); + // Flatten the callback chain by chaining market execution -> position fetch -> reconcile in a single promise chain. PromiseOrValue::Promise( ext_market::ext(market.clone()) - .with_static_gas(EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS) + .with_static_gas(Gas::from_tgas( + EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS.as_tgas() + * (batch_limit.unwrap_or(1) as u64), + )) .with_unused_gas_weight(0) .execute_next_supply_withdrawal_request(batch_limit) + .then( + ext_market::ext(market.clone()) + .with_static_gas(GET_SUPPLY_POSITION_GAS) + .with_unused_gas_weight(0) + .get_supply_position(env::current_account_id()), + ) .then( Self::ext(env::current_account_id()) - .with_static_gas(EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS) - // keep `need` for event compatibility; we don't use it for logic - .execute_withdraw_01_fetch_position(op_id, market_index, U128(0), bb), + .with_static_gas(EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS) + .execute_withdraw_02_reconcile_position( + op_id, + market_index, + U128(principal), + before_balance, + ), ), ) } + /// Cash flow: + /// - Reconcile market position to compute 'credited' (funds returned from market). + /// - Increment idle_balance by credited to reflect funds now held by the vault. + /// - If remaining == 0, transition to Payout; otherwise continue Withdrawing on next market. + /// - Later in after_send_to_user, idle_balance is decremented on successful transfer to the user. + /// - On transfer failure, idle_balance stays unchanged and escrowed shares are refunded to the owner. + /// + /// # Panics + /// - If the market is not found. #[private] - pub fn execute_withdraw_01_fetch_position( + pub fn execute_withdraw_02_reconcile_position( &mut self, + #[callback_result] position: Result, PromiseError>, op_id: u64, market_index: u32, - need: U128, + principal: U128, before_balance: U128, ) -> PromiseOrValue<()> { let ctx = match self.ctx_withdrawing(op_id) { Ok(v) => v, Err(e) => return self.stop_and_exit(Some(&e)), - }; + } + .clone(); if ctx.index != market_index { return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); @@ -275,21 +305,43 @@ impl Contract { Err(e) => return self.stop_and_exit(Some(&e)), }; - // Verify actual withdrawal by reading market position after execution - let before = self.principal_of(market); + let reported_principal: u128 = match position { + Ok(Some(position)) => position.get_deposit().total().into(), + Ok(None) => { + Event::WithdrawalPositionMissing { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + before: principal, + } + .emit(); + // Treat missing position as zero principal and continue to balance settlement + 0 + } + Err(_) => { + Event::WithdrawalPositionReadFailed { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + before: principal, + } + .emit(); + return self.stop_and_exit(Some(&Error::PositionReadFailed)); + } + }; + PromiseOrValue::Promise( - ext_market::ext(market.clone()) - .with_static_gas(GET_SUPPLY_POSITION_GAS) - .with_unused_gas_weight(0) - .get_supply_position(env::current_account_id()) + ext_ft_core::ext(self.underlying_asset.contract_id().into()) + .with_static_gas(Gas::from_tgas(5)) + .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) .with_static_gas(EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS) - .execute_withdraw_02_reconcile_position( + .execute_withdraw_03_settle( op_id, market_index, - U128(before), - need, + principal, + U128(reported_principal), before_balance, ), ), @@ -303,8 +355,7 @@ impl Contract { op_id: u64, market_index: u32, before_principal: U128, - new_principal_reported: U128, - need: U128, + reported_principal: U128, before_balance: U128, ) -> PromiseOrValue<()> { let ctx = match self.ctx_withdrawing(op_id) { @@ -321,37 +372,33 @@ impl Contract { Err(e) => return self.stop_and_exit(Some(&e)), }; - let before_p = before_principal.0; - let new_p_raw = new_principal_reported.0; - - // Principal drop as reported by the market - let principal_drop = before_p.saturating_sub(new_p_raw); - - // Actual token inflow = after_balance - before_balance (fail-closed to 0 on error) - let bb = before_balance.0; - let ab = match after_balance { - Ok(U128(v)) => v, - Err(_) => { - env::log_str(&format!( - "WithdrawBalanceReadFailed market={} op_id={} index={}", - market, op_id, market_index - )); - 0 - } - }; - let inflow = ab.saturating_sub(bb); + let (principal_delta, inflow, creditable) = Self::compute_withdraw_deltas( + before_principal, + reported_principal, + after_balance, + &market, + op_id, + market_index, + before_balance, + ); + env::log_str(&format!( + "principal_delta: {}, inflow: {}, creditable: {}", + principal_delta, inflow, creditable + )); - // Compute effective principal drop we can book (conservative on shortfall) - let creditable = core::cmp::min(principal_drop, inflow); + let extra = inflow.saturating_sub(principal_delta); // Log mismatch cases and emit an event if market overpays (yield with principal) - if principal_drop > inflow { - env::log_str(&format!( - "WithdrawalInflowMismatch market={} op_id={} index={} principal_drop={} inflow={}", - market, op_id, market_index, principal_drop, inflow - )); - } else if inflow > principal_drop { - let extra = inflow.saturating_sub(principal_drop); + if principal_delta > inflow { + Event::WithdrawalInflowMismatch { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + delta: U128(principal_delta), + inflow: U128(inflow), + } + .emit(); + } else if inflow > principal_delta { Event::WithdrawalOverpayCredited { op_id: op_id.into(), market: market.clone(), @@ -362,38 +409,38 @@ impl Contract { } // Update principal by the amount we can safely credit, and always credit the full inflow to idle - let eff_new_principal = before_p.saturating_sub(creditable); + let effective_principal = before_principal.0.saturating_sub(creditable); if let Some(rec) = self.markets.get_mut(&market) { - rec.principal = eff_new_principal; + env::log_str(&format!("Updating principal to {}", effective_principal)); + rec.principal = effective_principal; } if inflow > 0 { - self.idle_balance = self.idle_balance.saturating_add(inflow); + self.update_idle_balance(IdleBalanceDelta::Increase(inflow.into())); } - // Settle pending market exec entry only if fully credited - if let Some(pos) = self - .pending_market_exec - .iter() - .position(|&idx| idx == market_index) - { - if creditable == principal_drop { - self.pending_market_exec.remove(pos); - } - } + self.try_settle_pending_market_exec(market_index, creditable, principal_delta); // Reconcile remaining/collected based on credited inflow only let WithdrawReconciliation { remaining_next, collected_next, .. - } = reconcile_withdraw_outcome(before_p, eff_new_principal, ctx.remaining, ctx.collected); + } = reconcile_withdraw_outcome( + before_principal.0, + effective_principal, + ctx.remaining, + ctx.collected, + ); // If market overpaid beyond principal drop, use the extra to satisfy this withdrawal - let extra = inflow.saturating_sub(principal_drop); - let extra_payout = core::cmp::min(extra, remaining_next); - let mut remaining_next = remaining_next.saturating_sub(extra_payout); - let mut collected_next = collected_next.saturating_add(extra_payout); + 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); + + env::log_str(&format!("Extra payout: {}", extra_payout)); + env::log_str(&format!("Remaining next: {}", remaining_next)); + env::log_str(&format!("Collected next: {}", collected_next)); if remaining_next == 0 { return self.pay_collected( @@ -424,7 +471,7 @@ impl Contract { ); } - if creditable == principal_drop && principal_drop > 0 { + if creditable == principal_delta && principal_delta > 0 { // Fully executed for this market: advance to next and continue self.op_state = OpState::Withdrawing(WithdrawingState { op_id, @@ -450,91 +497,6 @@ impl Contract { PromiseOrValue::Value(()) } } - - /// Cash flow: - /// - Reconcile market position to compute 'credited' (funds returned from market). - /// - Increment idle_balance by credited to reflect funds now held by the vault. - /// - If remaining == 0, transition to Payout; otherwise continue Withdrawing on next market. - /// - Later in after_send_to_user, idle_balance is decremented on successful transfer to the user. - /// - On transfer failure, idle_balance stays unchanged and escrowed shares are refunded to the owner. - /// - /// # Panics - /// - If the market is not found. - #[private] - pub fn execute_withdraw_02_reconcile_position( - &mut self, - #[callback_result] position: Result, PromiseError>, - op_id: u64, - market_index: u32, - before: U128, - need: U128, - before_balance: U128, - ) -> PromiseOrValue<()> { - let ctx = match self.ctx_withdrawing(op_id) { - Ok(v) => v, - Err(e) => return self.stop_and_exit(Some(&e)), - } - .clone(); - - if ctx.index != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); - } - - let market = match self.resolve_withdraw_market(market_index) { - Ok(m) => m, - Err(e) => return self.stop_and_exit(Some(&e)), - }; - - let before_principal = before.0; - let new_principal = match position { - Ok(Some(position)) => { - let np: u128 = position.get_deposit().total().into(); - np - } - Ok(None) => { - Event::WithdrawalPositionMissing { - op_id: op_id.into(), - market: market.clone(), - index: market_index, - before, - need, - } - .emit(); - // Treat missing position as zero principal and continue to balance settlement - 0 - } - Err(_) => { - Event::WithdrawalPositionReadFailed { - op_id: op_id.into(), - market: market.clone(), - index: market_index, - before, - need, - } - .emit(); - return self.stop_and_exit(Some(&Error::PositionReadFailed)); - } - }; - - PromiseOrValue::Promise( - ext_ft_core::ext(self.underlying_asset.contract_id().into()) - .with_static_gas(Gas::from_tgas(5)) - .ft_balance_of(env::current_account_id()) - .then( - Self::ext(env::current_account_id()) - .with_static_gas(EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS) - .execute_withdraw_03_settle( - op_id, - market_index, - U128(before_principal), - U128(new_principal), - need, - before_balance, - ), - ), - ) - } - /// 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. @@ -571,7 +533,6 @@ impl Contract { if result.is_ok() { // On payout success, idle_balance was already decremented before transfer. - let EscrowSettlement { to_burn: burn_shares, refund, @@ -598,7 +559,7 @@ impl Contract { } } else { // On payout failure, refund full escrow to owner and restore idle_balance - self.idle_balance = self.idle_balance.saturating_add(expected_amount); + self.update_idle_balance(IdleBalanceDelta::Increase(expected_amount.into())); self.transfer(&Nep141Transfer::new( escrow_shares, env::current_account_id(), @@ -658,7 +619,8 @@ impl Contract { }) .emit(); - self.idle_balance = self.idle_balance.saturating_add(s.remaining); + self.update_idle_balance(IdleBalanceDelta::Increase(s.remaining.into())); + self.plan = None; self.op_state = OpState::Idle; } @@ -760,6 +722,63 @@ impl Contract { .get(market_index as usize) .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 { + env::log_str(&format!( + "Settling pending market exec for market {}", + market_index + )); + self.pending_market_exec.remove(pos); + } + } + } + + pub fn compute_withdraw_deltas( + before_principal: U128, + new_principal_reported: U128, + after_balance: Result, + market: &AccountId, + op_id: u64, + market_index: u32, + before_balance: U128, + ) -> (u128, u128, u128) { + // Principal drop as reported by the market + let principal_delta = before_principal.0.saturating_sub(new_principal_reported.0); + + // Actual token inflow = after_balance - before_balance (fail-closed to 0 on error) + let after_balance = match after_balance { + Ok(U128(v)) => v, + Err(_) => { + env::log_str(&format!( + "WithdrawBalanceReadFailed market={} op_id={} index={}", + market, op_id, market_index + )); + 0 + } + }; + let inflow = after_balance.saturating_sub(before_balance.0); + + // Compute effective principal drop we can book (conservative on shortfall) + let creditable = principal_delta.min(inflow); + (principal_delta, inflow, creditable) + } + + pub fn update_idle_balance(&mut self, delta: IdleBalanceDelta) { + let idle_balance = self.idle_balance; + self.idle_balance = delta.apply(idle_balance); + } } pub struct SupplyReconciliation { @@ -790,7 +809,6 @@ pub struct WithdrawReconciliation { pub idle_delta: u128, } -/// Pure reconciliation for withdraw read outcome to enable unit tests #[must_use] pub fn reconcile_withdraw_outcome( before_principal: u128, diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index ed4f63f6..7ab74a49 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -2,7 +2,9 @@ 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, SUPPLY_GAS}; +use templar_common::vault::{ + require_at_least, AllocationMode, DepositMsg, Event, IdleBalanceDelta, SUPPLY_GAS, +}; #[allow(clippy::wildcard_imports)] use near_sdk_contract_tools::mt::*; @@ -128,7 +130,7 @@ impl Contract { } .emit(); - self.idle_balance = self.idle_balance.saturating_add(accept); + 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 { diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 5e695b8c..68cd03d3 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -32,11 +32,12 @@ use templar_common::{ asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, vault::{ require_at_least, AllocatingState, AllocationMode, AllocationPlan, AllocationWeights, - Error, Event, 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_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, - EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, + 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_NEXT_SUPPLY_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::*; @@ -356,9 +357,8 @@ impl Contract { env::panic_str("No pending market withdrawal request to execute"); }; - let market = match self.resolve_withdraw_market(market_index) { - Ok(m) => m, - 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)); }; PromiseOrValue::Promise( @@ -368,7 +368,7 @@ impl Contract { .then( Self::ext(env::current_account_id()) .with_static_gas(EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS) - .execute_withdraw_00_before_balance( + .execute_withdraw_01_call_market_fetch_position( op_id.into(), market_index, batch_limit, @@ -837,7 +837,7 @@ impl Contract { amount <= self.idle_balance, "Policy violation: reserve amount must be <= idle_balance" ); - self.idle_balance -= amount; + self.update_idle_balance(IdleBalanceDelta::Decrease(amount.into())); let op_id = self.next_op_id; self.next_op_id += 1; @@ -1092,7 +1092,8 @@ impl Contract { burn_shares: escrow_shares, }); require!(self.idle_balance >= collected, "idle underflow in payout"); - self.idle_balance -= collected; + self.update_idle_balance(IdleBalanceDelta::Decrease(collected.into())); + return PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) @@ -1174,7 +1175,8 @@ impl Contract { burn_shares, }); require!(self.idle_balance >= collected, "idle underflow in payout"); - self.idle_balance -= collected; + self.update_idle_balance(IdleBalanceDelta::Decrease(collected.into())); + PromiseOrValue::Promise( self.underlying_asset .transfer(receiver.clone(), U128(collected).into()) diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index f63a7502..11b5234b 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -178,19 +178,21 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e c.idle_balance = 1_000; // Partial payout scenario: collected/requested = 200/500 => burn 40% of escrowed shares + let amount = 200; + let op_id = 1; c.op_state = OpState::Payout(PayoutState { - op_id: 1, + op_id, receiver: receiver.clone(), - amount: 200, + amount, owner: owner.clone(), escrow_shares: 100, burn_shares: 40, // precomputed proportional burn for test }); let supply_before = c.total_supply(); - c.payment_01_reconcile_idle_or_refund(Ok(()), 1, receiver, U128(200)); - // Idle decreased by payout - assert_eq!(c.idle_balance, 800); + c.payment_01_reconcile_idle_or_refund(Ok(()), op_id, receiver, U128(amount)); + + // 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 @@ -1631,51 +1633,26 @@ fn after_supply_1_check_allocating() { assert_eq!(c.plan, None); } -#[test] -fn after_send_to_user_success_no_escrow() { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - - let mut c = new_test_contract(&vault_id); - - let receiver = mk(7); - - c.idle_balance = 1_000; - c.op_state = OpState::Payout(PayoutState { - op_id: 1, - receiver: receiver.clone(), - amount: 200, - owner: accounts(1), - escrow_shares: 0, - burn_shares: 0, - }); - - c.payment_01_reconcile_idle_or_refund(Ok(()), 1, receiver.clone(), U128(200)); - assert_eq!(c.idle_balance, 800, "Idle balance must decrease by payout"); - assert!( - matches!(c.op_state, OpState::Idle), - "Vault must go Idle after successful payout" - ); -} - #[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; c.markets.insert( market.clone(), MarketRecord { cfg: MarketConfiguration::default(), pending_cap: None, - principal: 100, + principal, }, ); - // Withdrawing: need 60, already collected 10; expect position None => new_principal = 0, withdrawn = 100, credited = min(100, 60) = 60 + let op_id = 42; + let index = 0; c.op_state = OpState::Withdrawing(WithdrawingState { - op_id: 42, - index: 0, + op_id, + index, remaining: 60, receiver: mk(9), collected: 10, @@ -1683,23 +1660,19 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { escrow_shares: 50, }); - let res = - c.execute_withdraw_02_reconcile_position(Ok(None), 42, 0, U128(100), U128(60), U128(0)); - + let res = c.execute_withdraw_02_reconcile_position(Ok(None), 42, 0, U128(principal), U128(0)); match res { PromiseOrValue::Promise(_p) => {} _ => panic!("Expected a Promise to proceed to balance settlement"), } - // Simulate the after-balance callback to settle crediting using actual inflow let res2 = c.execute_withdraw_03_settle( - Ok(U128(100)), // observed after_balance - 42, - 0, - U128(100), // before_principal - U128(0), // new_principal reported by market - U128(60), // need - U128(0), // before_balance snapshot + Ok(U128(principal)), // observed after_balance + op_id, + index, + U128(principal), // before_principal + U128(0), + U128(0), ); match res2 { @@ -1713,8 +1686,10 @@ fn after_exec_withdraw_read_none_to_payout(mut c: Contract) { "Market principal should be updated to 0" ); + // Collected was 70, payouit is 70, idle is 30 + assert_eq!( - c.idle_balance, 100, + c.idle_balance, 30, "Idle balance should increase by returned amount" ); @@ -1758,53 +1733,6 @@ fn after_skim_balance_positive_returns_promise() { } } -/// Property: Payout failure keeps idle_balance unchanged and does not burn escrow -#[rstest( - idle => [0u128, 1, 100], - escrow => [0u128, 1, 50], - amount => [0u128, 1, 25] -)] -fn prop_after_send_to_user_failure_keeps_idle(idle: u128, escrow: u128, amount: u128) { - let vault_id = accounts(0); - setup_env(&vault_id, &vault_id, vec![]); - let mut c = new_test_contract(&vault_id); - - let receiver = mk(7); - let owner = accounts(1); - - if escrow > 0 { - use near_sdk_contract_tools::ft::Nep141Controller as _; - - c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); - } - c.idle_balance = idle; - c.op_state = OpState::Payout(PayoutState { - op_id: 1, - receiver: receiver.clone(), - amount, - owner: owner.clone(), - escrow_shares: escrow, - burn_shares: escrow, - }); - - let before = c.idle_balance; - c.payment_01_reconcile_idle_or_refund( - Err(near_sdk::PromiseError::Failed), - 1, - receiver.clone(), - U128(amount), - ); - assert_eq!( - c.idle_balance, before, - "idle_balance must stay the same on payout failure" - ); - assert!( - matches!(c.op_state, OpState::Idle), - "Vault must go Idle after payout failure" - ); -} - /// Property: Create-withdraw failure skips to next market and if collected>0 ends in Payout #[rstest( collected => [1u128, 10u128], @@ -1814,6 +1742,7 @@ 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); @@ -1843,6 +1772,7 @@ fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { 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, .. }) => { @@ -1891,7 +1821,6 @@ fn prop_after_exec_withdraw_read_err_no_change(before: u128, need: u128, collect 99, 0, U128(before), - U128(need), U128(0), ); match res { @@ -1952,14 +1881,8 @@ fn prop_after_exec_withdraw_read_requires_current_state(pass_op: bool, pass_inde let call_op = if pass_op { real_op } else { real_op + 1 }; let call_idx = if pass_index { real_idx } else { real_idx + 1 }; - let r = c.execute_withdraw_02_reconcile_position( - Ok(None), - call_op, - call_idx, - U128(10), - U128(1), - U128(0), - ); + let r = + c.execute_withdraw_02_reconcile_position(Ok(None), call_op, call_idx, U128(10), U128(0)); if let (true, true) = (pass_op, pass_index) { assert!( !matches!(c.op_state, OpState::Idle), @@ -1982,16 +1905,27 @@ fn refund_path_consistency() { 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()]; // 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 { - op_id: 77, - index: 0, + op_id, + index, remaining: 0, receiver: mk(9), collected: 0, @@ -2004,7 +1938,7 @@ fn refund_path_consistency() { let owner_before = c.balance_of(&owner); // Read result with need=0 ensures credited=0; triggers refund branch - let res = c.execute_withdraw_02_reconcile_position(Ok(None), 77, 0, U128(0), U128(0), U128(0)); + let res = c.execute_withdraw_02_reconcile_position(Ok(None), op_id, index, U128(0), U128(0)); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to proceed to balance settlement"), @@ -2012,11 +1946,10 @@ fn refund_path_consistency() { let res2 = c.execute_withdraw_03_settle( Ok(U128(0)), // no inflow observed - 77, - 0, + op_id, + index, U128(0), // before_principal U128(0), // new_principal reported - U128(0), // need U128(0), // before_balance ); match res2 { @@ -2229,8 +2162,9 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { }, ); + let op_id = 33; c.op_state = OpState::Withdrawing(WithdrawingState { - op_id: 33, + op_id, index: 0, remaining: 5, receiver: mk(9), @@ -2239,7 +2173,7 @@ fn after_exec_withdraw_req_returns_promise(mut c: Contract) { escrow_shares: 0, }); - let res = c.execute_withdraw_01_fetch_position(33, 0, U128(5), U128(0)); + let res = c.execute_withdraw_01_call_market_fetch_position(Ok(U128(1)), op_id, 0, None); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to read supply position after exec"), @@ -2256,21 +2190,24 @@ fn after_exec_withdraw_read_advances_when_remaining( owner: AccountId, receiver: AccountId, ) { - // Two markets; first has principal to withdraw 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()]; - c.markets.insert( - m1.clone(), - MarketRecord { - cfg: MarketConfiguration::default(), - pending_cap: None, - principal: 10, - }, - ); + + let op_id = 0; + let index = 0; + let before_balance = 0; + c.op_state = OpState::Withdrawing(WithdrawingState { - op_id: 0, - index: 0, + op_id, + index, remaining: 100, receiver: receiver.clone(), collected: 0, @@ -2278,33 +2215,34 @@ fn after_exec_withdraw_read_advances_when_remaining( escrow_shares: 0, }); - // Position None => new_principal = 0 => withdrawn = 10 => credited = 10 - let res = - c.execute_withdraw_02_reconcile_position(Ok(None), 0, 0, U128(10), U128(100), U128(0)); + let res = c.execute_withdraw_02_reconcile_position( + Ok(None), + op_id, + index, + U128(0), + U128(before_balance), + ); match res { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to continue withdraw steps"), } - // Settle with observed inflow equal to the reported principal drop (10) + // Settle with the inflow equal to the reported principal delta + // before = 0 + // after = 10 let res2 = c.execute_withdraw_03_settle( - Ok(U128(10)), // after_balance - 0, - 0, - U128(10), // before_principal - U128(0), // new_principal reported - U128(100), // need - U128(0), // before_balance + Ok(U128(record.principal)), // after_balance + op_id, + index, + U128(record.principal), // before_principal + U128(0), + U128(before_balance), ); match res2 { PromiseOrValue::Promise(_) => {} _ => panic!("Expected Promise to proceed to payout after advancing"), } - // Idle credited, state advanced to next index with remaining reduced - assert_eq!(c.idle_balance, 10); - - // This works match &c.op_state { OpState::Payout(PayoutState { op_id, @@ -2315,7 +2253,7 @@ fn after_exec_withdraw_read_advances_when_remaining( burn_shares, }) => { assert_eq!(*op_id, 0); - assert_eq!(*amount, 10); + assert_eq!(*amount, before_balance + record.principal); assert_eq!(*escrow_shares, 0); assert_eq!(*burn_shares, 0); assert_eq!(*r, receiver); From 84ac693370dc43c2d9ca95525b8d3b0264396bb5 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 5 Nov 2025 12:03:45 +0000 Subject: [PATCH 118/121] fix: clippy --- common/src/vault.rs | 2 +- contract/vault/src/impl_callbacks.rs | 223 +++++++++++---------------- contract/vault/src/lib.rs | 29 ++-- 3 files changed, 100 insertions(+), 154 deletions(-) diff --git a/common/src/vault.rs b/common/src/vault.rs index fc36db20..fbe61ca0 100644 --- a/common/src/vault.rs +++ b/common/src/vault.rs @@ -175,7 +175,7 @@ pub const AFTER_CREATE_WITHDRAW_REQ_GAS: Gas = // TODO: rename const AFTER_EXECUTE_NEXT_WITHDRAW: u64 = 5 + 5 + AFTER_SEND_TO_USER; -pub const EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_WITHDRAW); +pub const EXECUTE_WITHDRAW_03_SETTLE_GAS: Gas = buffer(AFTER_EXECUTE_NEXT_WITHDRAW); // todo: rename const AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ: u64 = diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index a7fe75ea..03f4be85 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -1,5 +1,6 @@ #![allow(clippy::too_many_arguments)] +use core::cmp::Ordering; use std::fmt::Display; use crate::{near, Contract, ContractExt, Error, EscrowSettlement, Nep141Controller, OpState}; @@ -11,9 +12,8 @@ use templar_common::{ supply::SupplyPosition, vault::{ AllocatingState, Event, IdleBalanceDelta, PayoutState, WithdrawingState, - EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, - EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS, GET_SUPPLY_POSITION_GAS, - SUPPLY_02_POSITION_READ_GAS, + EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_03_SETTLE_GAS, + GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, }, }; @@ -121,11 +121,6 @@ impl Contract { } .emit(); - // NOTE: this may create accounting drift if we have infact transferred the funds to the market. - // Since we credit the idle position again on stop_and_exit. - // - // This is a critical path where we trust the market's position, however, this is expected as we have verified the following: - // - The transfer call to the market was successful in the previous receipt. return self.stop_and_exit(Some(&Error::MissingSupplyPosition)); } Err(_) => { @@ -166,7 +161,6 @@ impl Contract { remaining: remaining_next, }); if remaining_next == 0 { - // All funds allocated successfully return self.stop_and_exit(None::<&String>); } self.step_allocation() @@ -180,22 +174,12 @@ impl Contract { market_index: u32, need: U128, ) -> PromiseOrValue<()> { - let ctx = match self.ctx_withdrawing(op_id) { - Ok(s) => s, - Err(e) => return self.stop_and_exit(Some(&e)), - }; - - if ctx.index != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); - } - - let market = match self.resolve_withdraw_market(market_index) { - Ok(m) => m, - Err(e) => return self.stop_and_exit(Some(&e)), + let (ctx, market) = match self.withdraw_ctx_and_market_or_exit(op_id, market_index) { + Ok(v) => v, + Err(p) => return p, }; if did_create.is_ok() { - // Always defer execution: record the created request; keeper must call execute_next_market_withdrawal(op_id) self.pending_market_exec.push(market_index); PromiseOrValue::Value(()) } else { @@ -227,29 +211,19 @@ impl Contract { market_index: u32, batch_limit: Option, ) -> PromiseOrValue<()> { - let ctx = match self.ctx_withdrawing(op_id) { + let (_ctx, market) = match self.withdraw_ctx_and_market_or_exit(op_id, market_index) { Ok(v) => v, - Err(e) => return self.stop_and_exit(Some(&e)), - }; - - if ctx.index != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); - } - - let market = match self.resolve_withdraw_market(market_index) { - Ok(m) => m.clone(), - Err(e) => return self.stop_and_exit(Some(&e)), + Err(p) => return p, }; let principal = self.principal_of(&market); let before_balance = before_balance.unwrap_or(U128(0)); - // Flatten the callback chain by chaining market execution -> position fetch -> reconcile in a single promise chain. PromiseOrValue::Promise( ext_market::ext(market.clone()) .with_static_gas(Gas::from_tgas( EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS.as_tgas() - * (batch_limit.unwrap_or(1) as u64), + * (u64::from(batch_limit.unwrap_or(1))), )) .with_unused_gas_weight(0) .execute_next_supply_withdrawal_request(batch_limit) @@ -261,7 +235,7 @@ impl Contract { ) .then( Self::ext(env::current_account_id()) - .with_static_gas(EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS) + .with_static_gas(EXECUTE_WITHDRAW_03_SETTLE_GAS) .execute_withdraw_02_reconcile_position( op_id, market_index, @@ -290,19 +264,9 @@ impl Contract { principal: U128, before_balance: U128, ) -> PromiseOrValue<()> { - let ctx = match self.ctx_withdrawing(op_id) { + let (_ctx, market) = match self.withdraw_ctx_and_market_or_exit(op_id, market_index) { Ok(v) => v, - Err(e) => return self.stop_and_exit(Some(&e)), - } - .clone(); - - if ctx.index != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); - } - - let market = match self.resolve_withdraw_market(market_index) { - Ok(m) => m, - Err(e) => return self.stop_and_exit(Some(&e)), + Err(p) => return p, }; let reported_principal: u128 = match position { @@ -336,7 +300,7 @@ impl Contract { .ft_balance_of(env::current_account_id()) .then( Self::ext(env::current_account_id()) - .with_static_gas(EXECUTE_WITHDRAW_02_RECONCILE_POSITION_GAS) + .with_static_gas(EXECUTE_WITHDRAW_03_SETTLE_GAS) .execute_withdraw_03_settle( op_id, market_index, @@ -348,6 +312,7 @@ impl Contract { ) } + #[allow(clippy::too_many_lines)] #[private] pub fn execute_withdraw_03_settle( &mut self, @@ -358,61 +323,45 @@ impl Contract { reported_principal: U128, before_balance: U128, ) -> PromiseOrValue<()> { - let ctx = match self.ctx_withdrawing(op_id) { - Ok(v) => v.clone(), - Err(e) => return self.stop_and_exit(Some(&e)), - }; - - if ctx.index != market_index { - return self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index))); - } - - let market = match self.resolve_withdraw_market(market_index) { - Ok(m) => m.clone(), - Err(e) => return self.stop_and_exit(Some(&e)), + let (ctx, market) = match self.withdraw_ctx_and_market_or_exit(op_id, market_index) { + Ok(v) => v, + Err(p) => return p, }; let (principal_delta, inflow, creditable) = Self::compute_withdraw_deltas( before_principal, reported_principal, after_balance, - &market, - op_id, - market_index, before_balance, ); - env::log_str(&format!( - "principal_delta: {}, inflow: {}, creditable: {}", - principal_delta, inflow, creditable - )); - let extra = inflow.saturating_sub(principal_delta); - // Log mismatch cases and emit an event if market overpays (yield with principal) - if principal_delta > inflow { - Event::WithdrawalInflowMismatch { - op_id: op_id.into(), - market: market.clone(), - index: market_index, - delta: U128(principal_delta), - inflow: U128(inflow), + match principal_delta.cmp(&inflow) { + Ordering::Greater => { + Event::WithdrawalInflowMismatch { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + delta: U128(principal_delta), + inflow: U128(inflow), + } + .emit(); } - .emit(); - } else if inflow > principal_delta { - Event::WithdrawalOverpayCredited { - op_id: op_id.into(), - market: market.clone(), - index: market_index, - extra: U128(extra), + Ordering::Less => { + Event::WithdrawalOverpayCredited { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + extra: U128(extra), + } + .emit(); } - .emit(); + Ordering::Equal => {} } - // Update principal by the amount we can safely credit, and always credit the full inflow to idle let effective_principal = before_principal.0.saturating_sub(creditable); if let Some(rec) = self.markets.get_mut(&market) { - env::log_str(&format!("Updating principal to {}", effective_principal)); rec.principal = effective_principal; } if inflow > 0 { @@ -438,10 +387,6 @@ impl Contract { let remaining_next = remaining_next.saturating_sub(extra_payout); let collected_next = collected_next.saturating_add(extra_payout); - env::log_str(&format!("Extra payout: {}", extra_payout)); - env::log_str(&format!("Remaining next: {}", remaining_next)); - env::log_str(&format!("Collected next: {}", collected_next)); - if remaining_next == 0 { return self.pay_collected( op_id, @@ -451,7 +396,7 @@ impl Contract { ctx.escrow_shares, ctx.escrow_shares, |self_| { - // On early completion we still finalize; storage safety as in existing code + // On early completion we still finalise let self_id = env::current_account_id(); self_ .transfer(&Nep141Transfer::new( @@ -471,30 +416,33 @@ impl Contract { ); } - if creditable == principal_delta && 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() - } else { - // 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(()) + 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(()) + } } } /// Cash flow: @@ -532,7 +480,6 @@ impl Contract { }; if result.is_ok() { - // On payout success, idle_balance was already decremented before transfer. let EscrowSettlement { to_burn: burn_shares, refund, @@ -546,7 +493,6 @@ impl Contract { .inspect_err(|e| env::log_str(&format!("Failed to burn {e}"))); } - // Maybe refund any delta to the owner if refund > 0 { // Note: this should be infallible since we are transferring to an existing owner, and they are unable to unregister from storage self.transfer(&Nep141Transfer::new( @@ -716,6 +662,30 @@ impl Contract { } } + /// Combined helper for withdrawing callbacks: validate ctx and resolve market. + /// Returns (cloned context, owned market `AccountId`) on success, or calls `stop_and_exit` and returns Err on failure. + pub(crate) fn withdraw_ctx_and_market_or_exit( + &mut self, + op_id: u64, + market_index: u32, + ) -> Result<(WithdrawingState, AccountId), PromiseOrValue<()>> { + let ctx = match self.ctx_withdrawing(op_id) { + Ok(s) => s.clone(), + Err(e) => return Err(self.stop_and_exit(Some(&e))), + }; + + if ctx.index != market_index { + return Err(self.stop_and_exit(Some(&Error::IndexDrifted(ctx.index, market_index)))); + } + + let market = match self.resolve_withdraw_market(market_index) { + Ok(m) => m.clone(), + Err(e) => return Err(self.stop_and_exit(Some(&e))), + }; + + Ok((ctx, market)) + } + /// Resolve a market for withdraw by `withdraw_route` pub(crate) fn resolve_withdraw_market(&self, market_index: u32) -> Result<&AccountId, Error> { self.withdraw_route @@ -736,37 +706,24 @@ impl Contract { .position(|&idx| idx == market_index) { if creditable == principal_drop { - env::log_str(&format!( - "Settling pending market exec for market {}", - market_index - )); self.pending_market_exec.remove(pos); } } } + #[must_use] pub fn compute_withdraw_deltas( before_principal: U128, new_principal_reported: U128, after_balance: Result, - market: &AccountId, - op_id: u64, - market_index: u32, before_balance: U128, ) -> (u128, u128, u128) { // Principal drop as reported by the market let principal_delta = before_principal.0.saturating_sub(new_principal_reported.0); - // Actual token inflow = after_balance - before_balance (fail-closed to 0 on error) let after_balance = match after_balance { Ok(U128(v)) => v, - Err(_) => { - env::log_str(&format!( - "WithdrawBalanceReadFailed market={} op_id={} index={}", - market, op_id, market_index - )); - 0 - } + Err(_) => 0, }; let inflow = after_balance.saturating_sub(before_balance.0); diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 68cd03d3..22285ce5 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -35,9 +35,8 @@ use templar_common::{ 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_NEXT_SUPPLY_WITHDRAW_REQ_GAS, - EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, - MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_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::*; @@ -57,9 +56,6 @@ mod test_utils; #[derive(BorshStorageKey)] /// Internal storage keys used by persistent collections. pub enum StorageKey { - Config, - PendingCaps, - MarketSupply, PendingWithdrawals, } @@ -204,19 +200,6 @@ impl Contract { "timelock bounds" ); - let prefix = b"v"; - // TODO: this is copied from market, make a helper - let prefix = prefix.into_storage_key(); - macro_rules! key { - ($key: ident) => { - [ - prefix.as_slice(), - StorageKey::$key.into_storage_key().as_slice(), - ] - .concat() - }; - } - let mut contract = Self { underlying_asset: underlying_token, aum: AUM::BalanceSheet, @@ -237,7 +220,13 @@ impl Contract { mode, plan: None, current_withdraw_inflight: None, - pending_withdrawals: IterableMap::new(key!(PendingWithdrawals)), + pending_withdrawals: IterableMap::new( + [ + b'v'.into_storage_key().as_slice(), + StorageKey::PendingWithdrawals.into_storage_key().as_slice(), + ] + .concat(), + ), next_withdraw_id: 0, next_withdraw_to_execute: 0, pending_market_exec: Vec::new(), From c9808078dfd0b4bf9fd69d2c06c5ca154943bc3e Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 5 Nov 2025 13:56:29 +0000 Subject: [PATCH 119/121] chore: synchronous gas report --- contract/vault/examples/gas_report.rs | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs index c8824012..a4fd0777 100644 --- a/contract/vault/examples/gas_report.rs +++ b/contract/vault/examples/gas_report.rs @@ -26,18 +26,14 @@ async fn main() { let user1_amount = max / ITERATIONS as u128; // Run supplies concurrently. - let supply_futures = (0..ITERATIONS).map(|_| async { - vault + let mut supply_gas_average = 0f64; + for _ in 0..ITERATIONS { + supply_gas_average += vault .supply(&user1, user1_amount) .await .total_gas_burnt .as_gas() as f64 - }); - let supply_results = futures::future::join_all(supply_futures).await; - - let mut supply_gas_average = 0f64; - for s in supply_results { - supply_gas_average += s / ITERATIONS as f64; + / ITERATIONS as f64; } let mut allocation_gas_average = 0f64; @@ -69,18 +65,14 @@ async fn main() { vault.supply(&user3, user3_amount).await; - let withdraw_futures = (0..ITERATIONS).map(|_| async { - vault + let mut withdraw_gas_average = 0f64; + for _ in 0..ITERATIONS { + withdraw_gas_average += vault .withdraw(&user2, U128(1), None) .await .total_gas_burnt .as_gas() as f64 - }); - let withdraw_results = futures::future::join_all(withdraw_futures).await; - - let mut withdraw_gas_average = 0f64; - for w in withdraw_results { - withdraw_gas_average += w / ITERATIONS as f64; + / ITERATIONS as f64; } let withdraw_route = vec![c.market.contract().id().clone()]; From d86ef94cbb9558ec1953b41d3910c6cfd7f041e2 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 12 Nov 2025 09:48:26 +0000 Subject: [PATCH 120/121] fix: race condition when CI script fails to create the contract --- .github/workflows/deploy-staging.yml | 29 +++++++++++++++++----------- Cargo.lock | 6 +++--- script/ci/contract-exists.sh | 15 ++++++++++++++ 3 files changed, 36 insertions(+), 14 deletions(-) create mode 100755 script/ci/contract-exists.sh diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 2dd761b6..48cf0be2 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -4,8 +4,8 @@ on: permissions: contents: read - id-token: write # Required for workflows that call test.yml (Codecov OIDC) - pull-requests: write # Required for deployment comments + id-token: write # Required for workflows that call test.yml (Codecov OIDC) + pull-requests: write # Required for deployment comments jobs: # Check if we need to run contract-related jobs @@ -72,11 +72,11 @@ jobs: - name: Initialize staging account run: | - EXISTS=$(./script/ci/account-exists.sh \ + ACCOUNT_EXISTS=$(./script/ci/account-exists.sh \ --account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}") - if [[ -z "$EXISTS" ]]; then + if [[ -z "$ACCOUNT_EXISTS" ]]; then echo "Account does not already exist, creating" near account create-account fund-myself "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" '20 NEAR' \ @@ -88,19 +88,26 @@ jobs: echo "NEWLY_CREATED=1" >> $GITHUB_ENV else - echo "Account already exists, adding tokens and removing old market versions" - near tokens "${{ vars.NEAR_CONTRACT_STAGING_ACCOUNT_ID }}" \ send-near "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" '6 NEAR' \ network-config "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ sign-with-plaintext-private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" \ send - ./script/ci/remove-all-versions-from-registry.sh \ - --account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ - --registry "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ - --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ - --private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" + CONTRACT_EXISTS=$(./script/ci/contract-exists.sh \ + --contract "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}") + + if [[ -n "$CONTRACT_EXISTS" ]]; then + echo "Contract already exists on staging account, removing old market versions" + # ./script/ci/remove-all-versions-from-registry.sh \ + # --account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + # --registry "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + # --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ + # --private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" + else + echo "NEWLY_CREATED=1" >> $GITHUB_ENV + fi fi - name: Deploy registry to staging account diff --git a/Cargo.lock b/Cargo.lock index 8866819f..96f8eea4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3853,7 +3853,7 @@ dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", + "linux-raw-sys 0.11.0", "windows-sys 0.59.0", ] @@ -4672,7 +4672,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.0.8", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -4841,7 +4841,7 @@ dependencies = [ [[package]] name = "templar-vault-contract" -version = "1.1.0" +version = "1.2.0" dependencies = [ "futures", "getrandom 0.2.15", diff --git a/script/ci/contract-exists.sh b/script/ci/contract-exists.sh new file mode 100755 index 00000000..43be8684 --- /dev/null +++ b/script/ci/contract-exists.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$(dirname "$(readlink -f ${BASH_SOURCE[0]})") +source "$SCRIPT_DIR/utils.sh" + +parse_args "--account:ACCOUNT_ID,--network:NETWORK" "$@" + +if [ -z "$NETWORK" ]; then + NETWORK="testnet" +fi + +near contract download-wasm ${ACCOUNT_ID} save-to-file /tmp/${ACCOUNT_ID}.wasm network-config ${NETWORK} now > /dev/null 2>&1 || true +if [ -f "/tmp/${ACCOUNT_ID}.wasm" ]; then + echo 1 +fi From fafd3b61c753a5cd7cdb86028b8cdfa79f1b9916 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Wed, 12 Nov 2025 10:27:35 +0000 Subject: [PATCH 121/121] fix: undo hack --- .github/workflows/deploy-staging.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 48cf0be2..d53e87ee 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -98,13 +98,13 @@ jobs: --contract "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}") - if [[ -n "$CONTRACT_EXISTS" ]]; then + if [[ -z "$CONTRACT_EXISTS" ]]; then echo "Contract already exists on staging account, removing old market versions" - # ./script/ci/remove-all-versions-from-registry.sh \ - # --account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ - # --registry "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ - # --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ - # --private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" + ./script/ci/remove-all-versions-from-registry.sh \ + --account "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + --registry "${{ env.NEAR_CONTRACT_PR_STAGING_ACCOUNT_ID }}" \ + --network "${{ vars.NEAR_CONTRACT_STAGING_NETWORK }}" \ + --private-key "${{ secrets.NEAR_CONTRACT_STAGING_ACCOUNT_PRIVATE_KEY }}" else echo "NEWLY_CREATED=1" >> $GITHUB_ENV fi