diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 2dd761b6..d53e87ee 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 [[ -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 }}" + else + echo "NEWLY_CREATED=1" >> $GITHUB_ENV + fi fi - name: Deploy registry to staging account diff --git a/.gitignore b/.gitignore index 7df7658e..cd3e44e8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ # documentation site _site/ +.aider* diff --git a/Cargo.lock b/Cargo.lock index 59ad75b7..96f8eea4 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", ] @@ -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" @@ -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" @@ -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" @@ -1489,19 +1521,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]] @@ -2012,7 +2044,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]] @@ -2069,7 +2119,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", ] @@ -2114,6 +2164,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" @@ -2188,9 +2247,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" @@ -2204,7 +2263,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", ] @@ -2227,9 +2286,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" @@ -2332,7 +2391,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", ] @@ -2506,7 +2565,7 @@ dependencies = [ "near-config-utils", "near-schema-checker-lib", "near-stdx", - "primitive-types", + "primitive-types 0.10.1", "rand", "secp256k1", "serde", @@ -2608,7 +2667,7 @@ dependencies = [ "easy-ext", "enum-map", "hex", - "itertools", + "itertools 0.12.1", "near-crypto", "near-fmt", "near-parameters", @@ -2618,7 +2677,7 @@ dependencies = [ "near-time", "num-rational", "ordered-float", - "primitive-types", + "primitive-types 0.10.1", "rand", "rand_chacha", "serde", @@ -2809,7 +2868,7 @@ dependencies = [ "rand", "rayon", "ripemd", - "rustix 1.0.8", + "rustix 1.1.2", "serde", "sha2", "sha3", @@ -3008,7 +3067,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", @@ -3086,7 +3145,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", ] @@ -3102,6 +3177,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" @@ -3287,9 +3374,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]] @@ -3313,9 +3412,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", ] @@ -3342,9 +3441,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", ] @@ -3425,7 +3524,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]] @@ -3738,7 +3837,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", @@ -3747,14 +3846,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", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", + "linux-raw-sys 0.11.0", "windows-sys 0.59.0", ] @@ -3904,7 +4003,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", @@ -4281,7 +4380,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.9.4", "byteorder", "bytes", "crc", @@ -4325,7 +4424,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.9.4", "byteorder", "crc", "dotenvy", @@ -4522,7 +4621,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 0.6.0", ] @@ -4571,9 +4670,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.59.0", ] @@ -4605,7 +4704,7 @@ dependencies = [ "near-contract-standards", "near-primitives", "near-sdk", - "primitive-types", + "primitive-types 0.10.1", "rand", "rstest", "schemars", @@ -4740,6 +4839,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "templar-vault-contract" +version = "1.2.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", + "test-utils", + "tokio", +] + [[package]] name = "test-utils" version = "0.1.0" @@ -5017,7 +5136,7 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "async-compression", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.9.4", "bytes", "futures-core", "futures-util", @@ -5151,6 +5270,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" @@ -5184,6 +5315,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" @@ -5258,7 +5395,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]] @@ -5294,15 +5431,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 e2fe80ce..f45a48ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,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-account-id = "1.1.4" near-contract-standards = "5.17.2" near-crypto = "0.31.1" @@ -42,6 +43,7 @@ rstest = { version = "0.24" } schemars = { version = "0.8" } templar-common = { path = "./common" } templar-universal-account = { path = "./universal-account" } +templar-vault-contract = { path = "./contract/vault" } test-utils = { path = "./test-utils" } thiserror = "2.0.11" tokio = { version = "1.30.0", features = ["full"] } diff --git a/common/src/asset.rs b/common/src/asset.rs index c96be7bc..fa8d4c96 100644 --- a/common/src/asset.rs +++ b/common/src/asset.rs @@ -103,6 +103,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, + ), + } + } + /// Creates a simple `ft_transfer` action (no callback). #[cfg(not(target_arch = "wasm32"))] pub fn transfer_action( @@ -554,3 +585,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/common/src/lib.rs b/common/src/lib.rs index 12f144da..7c6bfed5 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -14,8 +14,12 @@ pub mod registry; pub mod snapshot; pub mod supply; pub mod time_chunk; +pub mod vault; pub mod withdrawal_queue; +pub use primitive_types; +pub use schemars; + /// Panic helper that works in both WASM and native contexts. /// /// In WASM contexts (contract compilation), uses `near_sdk::env::panic_str`. diff --git a/common/src/vault.rs b/common/src/vault.rs new file mode 100644 index 00000000..fbe61ca0 --- /dev/null +++ b/common/src/vault.rs @@ -0,0 +1,764 @@ +use std::num::NonZeroU8; + +use near_sdk::{ + env, + json_types::{U128, U64}, + near, require, AccountId, Gas, Promise, PromiseOrValue, +}; + +use crate::asset::{BorrowAsset, FungibleAsset}; + +pub type TimestampNs = u64; + +pub const MIN_TIMELOCK_NS: u64 = 0; +pub const MAX_TIMELOCK_NS: u64 = 30 * 86_400_000_000_000; // 30 days +pub const MAX_QUEUE_LEN: usize = 64; + +pub type ExpectedIdx = u32; +pub type ActualIdx = u32; +pub type AllocationWeights = Vec<(AccountId, U128)>; +pub type AllocationPlan = Vec<(AccountId, u128)>; + +#[derive(Clone, Debug, Default)] +#[near(serializers = [json, borsh])] +pub enum AllocationMode { + /// When eager makes sense + /// + /// • Retail/auto-pilot vaults: users expect deposits to “start earning” immediately without an active allocator. + /// • Small/simple vaults: stable caps/ordering, few markets; operational simplicity > fine-grained control. + /// • Integrations that assume quick deployment of idle assets. + /// + /// Risks/trade-offs of eager + /// + /// • Gas burden on depositors: ft_transfer_call into your vault must carry enough gas for multi-hop allocation. + /// Under-provisioned gas leads to partial allocations and extra callbacks. + /// • Timing control: depositors implicitly decide when allocation runs, which can fight the allocator’s planned rebalancing + /// cadence. + /// • Thrashing: many small deposits can trigger many allocation passes. + /// • Current code is “eager-ish but incomplete”: it only auto-starts when Idle, and does not auto-restart after the op. Deposits + /// that arrive during an allocation stay idle until someone triggers another pass. + /// + /// Behaviour + /// • On deposit: if Idle and idle_balance ≥ min_batch, start_allocation(idle_balance). + /// • Eager allocation can still honor a per-op plan if one is set (plan wins); otherwise fall back to supply_queue order. + Eager { min_batch: U128 }, + #[default] + Lazy, +} + +/// Parsed from the string parameter `msg` passed by `*_transfer_call` to +/// `*_on_transfer` calls. +#[near(serializers = [json])] +pub enum DepositMsg { + /// Add the attached tokens to the sender's vault position. + Supply, +} + +/// Confrete configuration for a market. +#[derive(Clone, Default, Debug)] +#[near] +pub struct MarketConfiguration { + /// Supply cap for this market (in underlying asset units) + pub cap: U128, + /// Whether market is enabled for deposits/withdrawals + pub enabled: bool, + /// Timestamp (ns) after which market can be removed (if pending removal) + pub removable_at: TimestampNs, +} + +impl MarketConfiguration { + /// Size of the market configuration in borsh encoded bytes. + #[must_use] + 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_ns: U64, + /// The account that receives fees for this vault. + pub fee_recipient: AccountId, + /// The skim account that can unorphan any assets erroneously sent to this vault. + pub skim_recipient: AccountId, + /// The name of the share token. + pub name: String, + /// The symbol of the share token. + pub symbol: String, + /// The number of decimals for the share token, usually would be the same as the underlying asset. + pub decimals: NonZeroU8, +} + +#[near_sdk::ext_contract(ext_vault)] +pub trait VaultExt { + // Role and admin + fn set_curator(account: AccountId); + fn set_is_allocator(account: AccountId, allowed: bool); + fn submit_guardian(new_g: AccountId); + fn accept_guardian(); + fn revoke_pending_guardian(); + fn set_skim_recipient(account: AccountId); + fn set_fee_recipient(account: AccountId); + fn set_performance_fee(fee: U128); + fn submit_timelock(new_timelock_ns: U64); + fn accept_timelock(); + fn revoke_pending_timelock(); + + // Market config and queues + fn submit_cap(market: AccountId, new_cap: U128); + fn accept_cap(market: AccountId); + fn revoke_pending_cap(market: AccountId); + fn submit_market_removal(market: AccountId); + fn revoke_pending_market_removal(market: AccountId); + fn set_supply_queue(markets: Vec); + fn set_withdraw_queue(queue: Vec); + + // User flows + fn withdraw(amount: U128, receiver: AccountId) -> PromiseOrValue<()>; + fn redeem(shares: U128, receiver: AccountId) -> PromiseOrValue<()>; + fn execute_next_withdrawal_request() -> PromiseOrValue<()>; + fn skim(token: AccountId) -> Promise; + fn allocate(weights: AllocationWeights, amount: Option) -> PromiseOrValue<()>; + + // Views + fn get_configuration() -> VaultConfiguration; + fn get_total_assets() -> U128; + fn get_total_supply() -> U128; + fn get_max_deposit() -> U128; + fn convert_to_shares(assets: U128) -> U128; + fn convert_to_assets(shares: U128) -> U128; + fn preview_deposit(assets: U128) -> U128; + fn preview_mint(shares: U128) -> U128; + fn preview_withdraw(assets: U128) -> U128; + fn preview_redeem(shares: U128) -> U128; +} + +// Add a 20% buffer to a gas estimate +#[must_use] +pub const fn buffer(size: u64) -> Gas { + Gas::from_tgas((size * 6 + 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); + +// 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 EXECUTE_WITHDRAW_03_SETTLE_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 EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS: Gas = + buffer(AFTER_EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ); + +const AFTER_SUPPLY_2_READ: u64 = 5; +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. +pub const SUPPLY_GAS: Gas = buffer(8); +pub const ALLOCATE_GAS: Gas = buffer(20); +pub const WITHDRAW_GAS: Gas = buffer(4); +pub const EXECUTE_WITHDRAW_GAS: Gas = buffer(9); +pub const SUBMIT_CAP_GAS: Gas = buffer(3); + +const AFTER_SEND_TO_USER: u64 = 5; +pub const AFTER_SEND_TO_USER_GAS: Gas = Gas::from_tgas(AFTER_SEND_TO_USER); + +pub fn require_at_least(needed: Gas) { + let gas = env::prepaid_gas(); + require!( + gas >= needed, + format!("Insufficient gas: {}, needed: {needed}", gas) + ); +} + +#[derive(Clone, Debug)] +#[near] +pub struct PendingValue { + pub value: T, + // Timestamp when this pending value can be finalized + pub valid_at_ns: TimestampNs, +} + +impl PendingValue { + pub fn verify(&self) { + require!( + near_sdk::env::block_timestamp() >= self.valid_at_ns, + "Timelock not elapsed yet" + ); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// No operation in-flight. The vault is ready to start a new allocation or withdrawal. +pub struct IdleState; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// Supplying idle underlying to markets according to a plan or queue. +/// +/// Transitions: +/// - On completion of allocation: Withdrawing (to satisfy pending user requests) or Idle (if stopped). +/// - On stop/failure: Idle. +pub struct AllocatingState { + /// Unique operation id used to correlate async callbacks and detect drift. + pub op_id: u64, + /// Zero-based position within the allocation plan/queue currently being processed. + pub index: u32, + /// Amount of underlying (in asset units) still to allocate during this operation. + pub remaining: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// Collecting liquidity from markets to satisfy a user withdrawal/redeem request. +/// +/// Transitions: +/// - Advance within queue: Withdrawing (index increments) while collecting funds. +/// - When enough is collected to satisfy the request: Payout. +/// - If the op is stopped or cannot proceed and needs to refund: Idle (escrow_shares refunded). +pub struct WithdrawingState { + /// Unique operation id used to correlate async callbacks and detect drift. + pub op_id: u64, + /// Zero-based position within the withdraw queue currently being processed. + pub index: u32, + /// Remaining assets that must still be collected to satisfy the request. + pub remaining: u128, + /// Assets already collected and held as idle_balance pending payout. + pub collected: u128, + /// Account that should receive the assets during payout. + pub receiver: AccountId, + /// The owner whose shares are being redeemed. + pub owner: AccountId, + /// Shares locked in escrow for this request. + /// - Refunded on stop/failure. + /// - On payout success, a portion is burned (see burn_shares) and any remainder is refunded. + pub escrow_shares: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// Final step that transfers assets to the receiver and settles the share escrow. +/// +/// Transitions: +/// - On success or failure: Idle. +/// +/// Invariant hooks: +/// - idle_balance decreases only on payout success by `amount`. +/// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded. +/// - On failure, all `escrow_shares` are refunded. +pub struct PayoutState { + /// Unique operation id used to correlate async callbacks and detect drift. + pub op_id: u64, + /// Receiver of the asset payout. + pub receiver: AccountId, + /// Amount of assets to transfer out from idle_balance. + pub amount: u128, + /// The owner whose shares were escrowed for this payout. + pub owner: AccountId, + /// Total shares currently held in escrow for this operation. + pub escrow_shares: u128, + /// Portion of `escrow_shares` that will be burned on successful payout. + pub burn_shares: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[near(serializers = [borsh])] +/// Operation state machine for asynchronous allocation, withdrawal, and payout flows. +/// +/// State machine: +/// - Allocating -> Withdrawing (or Idle via stop) +/// - Withdrawing -> Withdrawing (advance) | Payout | Idle (refund) +/// - Payout -> Idle (success or failure) +/// +/// Invariants: +/// - idle_balance increases only when funds are received and decreases only on payout success. +/// - escrow_shares are refunded on stop/failure or partially burned/refunded on payout success. +pub enum OpState { + /// No operation in-flight. The vault is ready to start a new allocation or withdrawal. + Idle, + + /// Supplying idle underlying to markets according to a plan or queue. + /// + /// Transitions: + /// - On completion of allocation: Withdrawing (to satisfy pending user requests) or Idle (if stopped). + /// - On stop/failure: Idle. + Allocating(AllocatingState), + + /// Collecting liquidity from markets to satisfy a user withdrawal/redeem request. + /// + /// Transitions: + /// - Advance within queue: Withdrawing (index increments) while collecting funds. + /// - When enough is collected to satisfy the request: Payout. + /// - If the op is stopped or cannot proceed and needs to refund: Idle (escrow_shares refunded). + Withdrawing(WithdrawingState), + + /// Final step that transfers assets to the receiver and settles the share escrow. + /// + /// Transitions: + /// - On success or failure: Idle. + /// + /// Invariant hooks: + /// - idle_balance decreases only on payout success by `amount`. + /// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded. + /// - On failure, all `escrow_shares` are refunded. + Payout(PayoutState), +} + +impl From for OpState { + fn from(_: IdleState) -> Self { + OpState::Idle + } +} + +impl From for OpState { + fn from(s: AllocatingState) -> Self { + OpState::Allocating(s) + } +} + +impl From for OpState { + fn from(s: WithdrawingState) -> Self { + OpState::Withdrawing(s) + } +} + +impl From for OpState { + fn from(s: PayoutState) -> Self { + OpState::Payout(s) + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &IdleState { + match self { + OpState::Idle => &IdleState, + _ => panic!("OpState::Idle expected"), + } + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &AllocatingState { + match self { + OpState::Allocating(s) => s, + _ => panic!("OpState::Allocating expected"), + } + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &WithdrawingState { + match self { + OpState::Withdrawing(s) => s, + _ => panic!("OpState::Withdrawing expected"), + } + } +} + +impl AsRef for OpState { + fn as_ref(&self) -> &PayoutState { + match self { + OpState::Payout(s) => s, + _ => panic!("OpState::Payout expected"), + } + } +} + +#[derive(Debug)] +#[near(serializers = [json])] +pub enum Error { + // Invariant: Index drift or stale op_id results in a graceful stop + IndexDrifted(ExpectedIdx, ActualIdx), + // Invariant: Attempting to work on a market that is missing from the withdraw queue + MissingMarket(u32), + NotWithdrawing, + NotAllocating, + MarketTransferFailed, + MissingSupplyPosition, + PositionReadFailed, + // Insufficient liquidity across all markets to satisfy withdrawal + InsufficientLiquidity, + ZeroAmount, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +#[derive(Clone, Debug)] +#[near(serializers = [borsh])] +pub struct PendingWithdrawal { + pub owner: AccountId, + pub receiver: AccountId, + pub escrow_shares: u128, + pub expected_assets: u128, + pub requested_at: u64, +} + +impl PendingWithdrawal { + #[must_use] + pub fn encoded_size() -> u64 { + storage_bytes_for_account_id() + + storage_bytes_for_account_id() + + 16 // escrow_shares: u128 + + 16 // expected_assets: u128 + + 8 // requested_at: u64 + } +} + +// Worst case size encoded for AccountId +#[must_use] +pub const fn storage_bytes_for_account_id() -> u64 { + // 4 bytes for length prefix + worst case size encoded for AccountId + 4 + AccountId::MAX_LEN as u64 +} + +#[derive(Clone, Debug)] +#[near(serializers = [borsh, json])] +pub enum IdleBalanceDelta { + Increase(U128), + Decrease(U128), +} + +impl IdleBalanceDelta { + pub fn apply(&self, balance: u128) -> u128 { + let new = match self { + IdleBalanceDelta::Increase(amount) => balance.saturating_add(amount.0), + IdleBalanceDelta::Decrease(amount) => balance.saturating_sub(amount.0), + }; + Event::IdleBalanceUpdated { + prev: U128::from(balance), + delta: self.clone(), + } + .emit(); + new + } +} + +#[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")] + AllocationRequestedQueue { op_id: U64, total: U128 }, + #[event_version("1.0.0")] + AllocationPlanSet { + op_id: U64, + total: U128, + 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, + }, + + #[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 }, + #[event_version("1.0.0")] + FeeRecipientSet { account: AccountId }, + #[event_version("1.0.0")] + PerformanceFeeSet { fee: U128 }, + + #[event_version("1.0.0")] + TimelockSet { seconds: U64 }, + #[event_version("1.0.0")] + TimelockChangeSubmitted { new_ns: U64, valid_at_ns: 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_ns: u64, + }, + #[event_version("1.0.0")] + SupplyCapRaiseRevoked { market: AccountId }, + + #[event_version("1.0.0")] + SupplyCapSet { market: AccountId, new_cap: U128 }, + #[event_version("1.0.0")] + MarketEnabled { market: AccountId }, + #[event_version("1.0.0")] + MarketAlreadyInWithdrawQueue { market: AccountId }, + #[event_version("1.0.0")] + WithdrawQueueMarketAdded { market: AccountId }, + #[event_version("1.0.0")] + WithdrawDequeued { index: U64 }, + #[event_version("1.0.0")] + WithdrawalParked { id: U64 }, + #[event_version("1.0.0")] + MarketRemovalSubmitted { + market: AccountId, + removable_at: U64, + }, + #[event_version("1.0.0")] + MarketRemovalRevoked { market: AccountId }, + #[event_version("1.0.0")] + WithdrawQueueUpdated { markets: Vec }, + + // User flows + #[event_version("1.0.0")] + RedeemRequested { + shares: U128, + estimated_assets: U128, + }, + #[event_version("1.0.0")] + WithdrawalQueued { + id: U64, + owner: AccountId, + receiver: AccountId, + escrow_shares: U128, + expected_assets: U128, + requested_at: U64, + }, + + // Allocation read/settlement diagnostics + #[event_version("1.0.0")] + AllocationPositionMissing { + op_id: U64, + index: u32, + market: AccountId, + attempted: U128, + accepted: U128, + }, + #[event_version("1.0.0")] + AllocationPositionReadFailed { + op_id: U64, + index: u32, + market: AccountId, + attempted: U128, + accepted: U128, + }, + + // Withdrawal read diagnostics + #[event_version("1.0.0")] + WithdrawalPositionReadFailed { + op_id: U64, + market: AccountId, + index: u32, + before: 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 { + 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")] + WithdrawalPositionMissing { + op_id: U64, + market: AccountId, + index: u32, + before: U128, + }, + #[event_version("1.0.0")] + WithdrawalInflowMismatch { + op_id: U64, + market: AccountId, + index: u32, + delta: U128, + inflow: U128, + }, + #[event_version("1.0.0")] + WithdrawalOverpayCredited { + op_id: U64, + market: AccountId, + index: u32, + extra: U128, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + 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() as u64, + PendingWithdrawal::encoded_size() + ); + } +} diff --git a/contract/market/src/impl_helper.rs b/contract/market/src/impl_helper.rs index c3622d82..3c69783a 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}, @@ -11,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/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..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, }; @@ -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)] diff --git a/contract/market/tests/configuration_validation.rs b/contract/market/tests/configuration_validation.rs index 937d0c82..a0039578 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; } @@ -93,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; } @@ -109,8 +145,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/Cargo.toml b/contract/vault/Cargo.toml new file mode 100644 index 00000000..91366206 --- /dev/null +++ b/contract/vault/Cargo.toml @@ -0,0 +1,53 @@ +[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 +primitive-types = { version = "0.14.0", features = ["serde"] } + +[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" } +rand = "0.8" +futures.workspace = true + +[lints] +workspace = true + +# [[example]] +# name = "receipt_gas" diff --git a/contract/vault/README.md b/contract/vault/README.md new file mode 100644 index 00000000..985f7111 --- /dev/null +++ b/contract/vault/README.md @@ -0,0 +1,362 @@ +# Templar Vault: Architecture, Codebase, and Flows + +This document explains how the vault works end-to-end: roles and permissions, data flow, deposits and withdrawals, and the async allocation/withdraw pipelines. + +## High-level overview + +- The vault issues shares over an underlying asset and allocates liquidity into configured 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 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), 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). + - 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. +- 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, 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.). + +## Roles and permissions + +Roles are enforced via RBAC. The Curator is also granted the Allocator role at init. + +- 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 + +- 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. +- 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 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 (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: + - 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 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) + - 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 +- 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/parks 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. + - 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. + +## Withdrawal and redeem flow (queue-less, keeper-routed) + +Two phases: user requests (escrow) and keeper-routed execution (pull liquidity, pay out). + +1. User request (escrow shares) + +- withdraw(amount, receiver) + - Computes shares_needed via preview_withdraw and defers to redeem. +- redeem(shares, receiver) + - 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; 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 to the owner. + - Go Idle. + - On failure: + - Refund full escrow to owner; leave idle unchanged; go Idle. + +Important + +- 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. +- 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/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 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; total assets continue to include remaining principal until withdrawn. + +## 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(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-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. + +## Key invariants + +- Single op in flight; ensure_idle() on all mutating entrypoints. +- 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. + +## Testing and local development + +- Unit/property tests cover: + - 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 with a short route 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): no extra storage for withdraw order (none exists). +- set_supply_queue(markets) + - Storage for markets added that were not previously in the queue. +- allocate(weights, amount) + - 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. diff --git a/contract/vault/examples/gas_report.rs b/contract/vault/examples/gas_report.rs new file mode 100644 index 00000000..a4fd0777 --- /dev/null +++ b/contract/vault/examples/gas_report.rs @@ -0,0 +1,112 @@ +#![allow(clippy::pedantic)] + +use near_sdk::{json_types::U128, Gas}; +use rand::Rng as _; +use test_utils::{setup_test, ContractController}; + +#[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) + ); + + 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; + + // Run supplies concurrently. + 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 + / 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; + 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(); + + // 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 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 + / 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, withdraw_route.clone()) + .await + .total_gas_burnt + .as_gas() as f64; + execute_withdraw_gas_average += execute_gas / ITERATIONS as f64; + } + + println!("## Gas Report"); + println!(); + println!("Estimated allocation limit: 0"); + println!(); + println!("### Action Gas Descriptors"); + println!(); + 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), + ), + ("submit_cap", Gas::from_gas(submit_cap_gas as u64)), + ]; + for (action_label, gas) in list { + println!("| `{action_label}` | {gas} |"); + } + println!(); +} diff --git a/contract/vault/src/aum.rs b/contract/vault/src/aum.rs new file mode 100644 index 00000000..0db4ac54 --- /dev/null +++ b/contract/vault/src/aum.rs @@ -0,0 +1,23 @@ +use near_sdk::near; + +use super::{Contract, U128}; + +/// 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. +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone)] +pub enum AUM { + /// BalanceSheet: balance sheet = truth for AUM. See module docs for tradeoffs. + BalanceSheet, +} + +impl AUM { + /// Compute total assets (BalanceSheet): idle balance + sum of all market principals. + pub fn get_total_assets(&self, c: &Contract) -> U128 { + 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 new file mode 100644 index 00000000..1ab90184 --- /dev/null +++ b/contract/vault/src/governance.rs @@ -0,0 +1,404 @@ +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(); + if self.storage_balance_of(account.clone()).is_none() { + 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. + /// + /// # Panics + /// If the market does not exist. + #[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 => { + self.markets.insert(market.clone(), MarketRecord::default()); + Event::MarketCreated { + market: market.clone(), + } + .emit(); + self.markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("Config not found")) + } + 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. + /// # Panics + /// If the market does not exist. + #[payable] + pub fn accept_cap(&mut self, market: AccountId) { + Self::assert_curator_or_owner(); + self.ensure_idle(); + + let m = self + .markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("Config not found")); + + let was_enabled = m.cfg.enabled; + + 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 + }, + ); + 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(); + } + + Event::SupplyCapSet { + market: market.clone(), + new_cap: U128(pending_value), + } + .emit(); + + self.markets + .get_mut(&market) + .unwrap_or_else(|| env::panic_str("Config not found")) + .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 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(); + 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: 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"); + + self.supply_queue.clear(); + + for m in &markets { + self.supply_queue.insert(m.clone()); + } + } +} diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs new file mode 100644 index 00000000..03f4be85 --- /dev/null +++ b/contract/vault/src/impl_callbacks.rs @@ -0,0 +1,787 @@ +#![allow(clippy::too_many_arguments)] + +use core::cmp::Ordering; +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::{Nep141Burn, Nep141Transfer}; +use templar_common::{ + market::ext_market, + supply::SupplyPosition, + vault::{ + AllocatingState, Event, IdleBalanceDelta, PayoutState, WithdrawingState, + EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_03_SETTLE_GAS, + GET_SUPPLY_POSITION_GAS, SUPPLY_02_POSITION_READ_GAS, + }, +}; + +/// 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 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 { + #[private] + pub fn supply_01_handle_transfer( + &mut self, + #[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)); + }; + + 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)) + } + Ok(accepted) => { + let before = self.principal_of(&market); + + 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( + 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, + ), + ), + ) + } + } + } + + #[allow(clippy::too_many_arguments)] + #[private] + 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) { + 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))); + } + + let SupplyReconciliation { + new_principal, + accepted_event, + remaining: remaining_next, + } = match position { + Ok(Some(position)) => reconcile_supply_outcome( + &position.get_deposit().total().into(), + &before.0, + &remaining_before.0, + ), + Ok(None) => { + Event::AllocationPositionMissing { + op_id: op_id.into(), + index: market_index, + market: market.clone(), + attempted, + accepted, + } + .emit(); + + return self.stop_and_exit(Some(&Error::MissingSupplyPosition)); + } + Err(_) => { + Event::AllocationPositionReadFailed { + op_id: op_id.into(), + index: market_index, + market: market.clone(), + attempted, + accepted, + } + .emit(); + return self.stop_and_exit(Some(&Error::PositionReadFailed)); + } + }; + + let refunded = attempted.0.saturating_sub(accepted_event); + + Event::AllocationStepSettled { + op_id: op_id.into(), + index: market_index, + market: market.clone(), + before, + new_principal: U128(new_principal), + accepted: U128(accepted_event), + attempted, + refunded: U128(refunded), + remaining_after: U128(remaining_next), + } + .emit(); + + if let Some(rec) = self.markets.get_mut(&market) { + rec.principal = new_principal; + } + + self.op_state = OpState::Allocating(AllocatingState { + op_id, + index: market_index.saturating_add(1), + remaining: remaining_next, + }); + if remaining_next == 0 { + return self.stop_and_exit(None::<&String>); + } + self.step_allocation() + } + + #[private] + 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 (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() { + self.pending_market_exec.push(market_index); + PromiseOrValue::Value(()) + } else { + Event::CreateWithdrawalFailed { + op_id: op_id.into(), + market: market.clone(), + index: ctx.index, + need, + } + .emit(); + self.op_state = OpState::Withdrawing(WithdrawingState { + op_id, + index: market_index.saturating_add(1), + remaining: ctx.remaining, + receiver: ctx.receiver.clone(), + collected: ctx.collected, + owner: ctx.owner.clone(), + escrow_shares: ctx.escrow_shares, + }); + self.step_withdraw() + } + } + + #[private] + 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, market) = match self.withdraw_ctx_and_market_or_exit(op_id, market_index) { + Ok(v) => v, + Err(p) => return p, + }; + + let principal = self.principal_of(&market); + let before_balance = before_balance.unwrap_or(U128(0)); + + PromiseOrValue::Promise( + ext_market::ext(market.clone()) + .with_static_gas(Gas::from_tgas( + EXECUTE_NEXT_SUPPLY_WITHDRAW_REQ_GAS.as_tgas() + * (u64::from(batch_limit.unwrap_or(1))), + )) + .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_03_SETTLE_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_02_reconcile_position( + &mut self, + #[callback_result] position: Result, PromiseError>, + op_id: u64, + market_index: u32, + principal: U128, + before_balance: U128, + ) -> PromiseOrValue<()> { + let (_ctx, market) = match self.withdraw_ctx_and_market_or_exit(op_id, market_index) { + Ok(v) => v, + Err(p) => return p, + }; + + 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_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_03_SETTLE_GAS) + .execute_withdraw_03_settle( + op_id, + market_index, + principal, + U128(reported_principal), + before_balance, + ), + ), + ) + } + + #[allow(clippy::too_many_lines)] + #[private] + pub fn execute_withdraw_03_settle( + &mut self, + #[callback_result] after_balance: Result, + op_id: u64, + market_index: u32, + before_principal: U128, + reported_principal: U128, + before_balance: U128, + ) -> PromiseOrValue<()> { + 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, + before_balance, + ); + let extra = inflow.saturating_sub(principal_delta); + + 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(); + } + Ordering::Less => { + Event::WithdrawalOverpayCredited { + op_id: op_id.into(), + market: market.clone(), + index: market_index, + extra: U128(extra), + } + .emit(); + } + Ordering::Equal => {} + } + + let effective_principal = before_principal.0.saturating_sub(creditable); + + if let Some(rec) = self.markets.get_mut(&market) { + rec.principal = effective_principal; + } + if inflow > 0 { + self.update_idle_balance(IdleBalanceDelta::Increase(inflow.into())); + } + + 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_principal.0, + effective_principal, + ctx.remaining, + ctx.collected, + ); + + // If market overpaid beyond principal drop, use the extra to satisfy this withdrawal + let extra_payout = extra.min(remaining_next); + let remaining_next = remaining_next.saturating_sub(extra_payout); + let collected_next = collected_next.saturating_add(extra_payout); + + if remaining_next == 0 { + return self.pay_collected( + op_id, + &ctx.receiver, + collected_next, + &ctx.owner, + ctx.escrow_shares, + ctx.escrow_shares, + |self_| { + // On early completion we still finalise + let self_id = env::current_account_id(); + self_ + .transfer(&Nep141Transfer::new( + ctx.escrow_shares, + &self_id, + &ctx.owner, + )) + .unwrap_or_else(|e| { + env::panic_str(&format!("Failed to refund escrowed shares {e}")) + }); + self_.pending_market_exec.clear(); + self_.remove_inflight_and_advance_head(); + self_.withdraw_route.clear(); + self_.op_state = OpState::Idle; + 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: + /// - 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. + /// - 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, + #[callback_result] result: Result<(), PromiseError>, + op_id: u64, + receiver: AccountId, + amount: U128, + ) { + let (owner, escrow_shares, expected_amount, burn_shares) = match &self.op_state { + OpState::Payout(PayoutState { + op_id: current_op, + receiver: recv, + amount, + owner, + escrow_shares, + burn_shares, + }) if *current_op == op_id && *recv == receiver => { + (owner.clone(), *escrow_shares, *amount, *burn_shares) + } + _ => { + Event::PayoutUnexpectedState { + op_id: op_id.into(), + receiver: receiver.clone(), + amount, + } + .emit(); + return; + } + }; + + if result.is_ok() { + 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. + if burn_shares > 0 { + // Serious issue: this should be infallible - if the withdrawal panics here we have an escrow settlement error + let _ = self + .burn(&Nep141Burn::new(burn_shares, env::current_account_id())) + .inspect_err(|e| env::log_str(&format!("Failed to burn {e}"))); + } + + 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( + 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 restore idle_balance + self.update_idle_balance(IdleBalanceDelta::Increase(expected_amount.into())); + 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.remove_inflight_and_advance_head(); + self.withdraw_route.clear(); + self.op_state = OpState::Idle; + } + + #[private] + pub fn skim_01_read_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(()); + } + }; + PromiseOrValue::Promise( + ext_ft_core::ext(token) + .with_attached_deposit(NearToken::from_yoctonear(1)) + .with_static_gas(Gas::from_tgas(5)) + .ft_transfer(recipient, U128(amount), None), + ) + } +} + +impl Contract { + pub fn stop_and_exit_allocating( + &mut self, + msg: Option<&T>, + ) { + let s: &AllocatingState = self.op_state.as_ref(); + + 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(); + + self.update_idle_balance(IdleBalanceDelta::Increase(s.remaining.into())); + + self.plan = None; + self.op_state = OpState::Idle; + } + + /// Stop helper for Withdrawing: refund escrowed shares to owner and go Idle. + pub fn stop_and_exit_withdrawing( + &mut self, + msg: Option<&T>, + ) { + 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), + } + .emit(); + + let owner = s.owner.clone(); + + if s.escrow_shares > 0 { + #[allow(clippy::expect_used, reason = "No side effects")] + 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.withdraw_route.clear(); + self.op_state = OpState::Idle; + } + + /// refund escrowed shares to owner and go Idle. + pub fn stop_and_exit_payout( + &mut self, + msg: Option<&T>, + ) { + 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), + } + .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.withdraw_route.clear(); + 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 => { + Event::OperationStoppedWhileIdle { + reason: msg.map(std::string::ToString::to_string), + } + .emit(); + } + } + 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 { + OpState::Allocating(AllocatingState { + op_id: cur, + index, + remaining, + }) if *cur == op_id => Ok((*index, *remaining)), + _ => Err(Error::NotAllocating), + } + } + + /// Validate current op is Withdrawing and return context tuple + pub(crate) fn ctx_withdrawing(&self, op_id: u64) -> Result<&WithdrawingState, Error> { + match &self.op_state { + OpState::Withdrawing(s) if s.op_id == op_id => Ok(s), + _ => Err(Error::NotWithdrawing), + } + } + + /// 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 + .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 { + self.pending_market_exec.remove(pos); + } + } + } + + #[must_use] + pub fn compute_withdraw_deltas( + before_principal: U128, + new_principal_reported: U128, + after_balance: Result, + 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); + + let after_balance = match after_balance { + Ok(U128(v)) => v, + Err(_) => 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 { + pub new_principal: u128, + pub accepted_event: u128, + pub remaining: u128, +} + +#[must_use] +pub 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, + } +} + +pub struct WithdrawReconciliation { + pub payout_delta: u128, + pub remaining_next: u128, + pub collected_next: u128, + pub idle_delta: u128, +} + +#[must_use] +pub 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, + } +} diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs new file mode 100644 index 00000000..7ab74a49 --- /dev/null +++ b/contract/vault/src/impl_token_receiver.rs @@ -0,0 +1,154 @@ +use crate::{Contract, ContractExt, OpState}; +use near_contract_standards::fungible_token::receiver::FungibleTokenReceiver; +use near_sdk::{env, json_types::U128, near, require, AccountId, PromiseOrValue}; +use near_sdk_contract_tools::ft::{Nep141Controller as _, Nep141Mint}; +use templar_common::vault::{ + require_at_least, AllocationMode, DepositMsg, Event, IdleBalanceDelta, SUPPLY_GAS, +}; + +#[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. + /// 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 { + let msg = parse_deposit_msg(&msg); + + let asset_id = env::predecessor_account_id(); + + match msg { + DepositMsg::Supply => { + require_at_least(SUPPLY_GAS); + 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, + #[allow(clippy::used_underscore_binding)] _sender_id: AccountId, + previous_owner_ids: Vec, + token_ids: Vec, + amounts: Vec, + msg: String, + ) -> PromiseOrValue> { + let msg = parse_deposit_msg(&msg); + + 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 token_contract = env::predecessor_account_id(); + + require!( + self.underlying_asset.is_nep245(&token_contract, token_id), + "Invalid token ID" + ); + + let refund = self.execute_supply(depositor.clone(), token_contract, amount.into()); + + PromiseOrValue::Value(vec![U128(refund)]) + } + } + } +} + +impl Contract { + pub(crate) fn execute_supply( + &mut self, + sender_id: AccountId, + asset_id: AccountId, + deposit: u128, + ) -> u128 { + // Invariant: Only the underlying token is accepted; others are fully refunded + require!( + asset_id == self.underlying_asset.contract_id(), + "Invalid token ID" + ); + + 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; + let accept = deposit.min(max); + let refund = deposit - accept; + + let shares = self.preview_deposit(U128(accept)).0; + self.mint(&Nep141Mint::new(shares, &sender_id)) + .unwrap_or_else(|_| env::panic_str("Failed to mint shares")); + + Event::MintedShares { + amount: shares.into(), + receiver: sender_id.clone(), + } + .emit(); + + self.update_idle_balance(IdleBalanceDelta::Increase(accept.into())); + self.last_total_assets = self.last_total_assets.saturating_add(accept); + + if let AllocationMode::Eager { min_batch } = self.mode { + if matches!(self.op_state, OpState::Idle) && self.idle_balance >= min_batch.0 { + // Invariant: no overlapping operations + let op_id = self.next_op_id; + Event::AllocationEagerTriggered { + op_id: op_id.into(), + idle_balance: U128(self.idle_balance), + min_batch, + deposit_accepted: U128(accept), + } + .emit(); + self.ensure_idle(); + self.start_allocation(self.idle_balance); + } + } + + refund + } +} diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs new file mode 100644 index 00000000..22285ce5 --- /dev/null +++ b/contract/vault/src/lib.rs @@ -0,0 +1,1196 @@ +#![allow(clippy::needless_pass_by_value)] + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + num::NonZeroU8, +}; + +use crate::{ + aum::AUM, + storage_management::{require_attached_at_least, require_attached_for_pending_withdrawal}, +}; +use near_contract_standards::fungible_token::core::ext_ft_core; +use near_sdk::{ + env, + json_types::{U128, U64}, + near, require, serde_json, + store::IterableMap, + AccountId, BorshStorageKey, Gas, IntoStorageKey, NearToken, PanicOnDefault, Promise, + PromiseOrValue, +}; +use near_sdk_contract_tools::{ + ft::{ + nep141::GAS_FOR_FT_TRANSFER_CALL, nep145::Nep145ForceUnregister, ContractMetadata, + FungibleToken, Nep141Controller, Nep141Mint, Nep141Transfer, Nep145 as _, Nep145Controller, + Nep148Controller, StorageBalanceBounds, + }, + Owner, Rbac, +}; +use near_sdk_contract_tools::{owner::Owner, rbac}; +use near_sdk_contract_tools::{owner::OwnerExternal, rbac::Rbac}; +use templar_common::{ + asset::{BorrowAsset, BorrowAssetAmount, FungibleAsset}, + vault::{ + require_at_least, AllocatingState, AllocationMode, AllocationPlan, AllocationWeights, + Error, Event, IdleBalanceDelta, MarketConfiguration, OpState, PayoutState, PendingValue, + PendingWithdrawal, TimestampNs, VaultConfiguration, WithdrawingState, + AFTER_CREATE_WITHDRAW_REQ_GAS, AFTER_SEND_TO_USER_GAS, AFTER_SUPPLY_1_CHECK_GAS, + ALLOCATE_GAS, CREATE_WITHDRAW_REQ_GAS, EXECUTE_WITHDRAW_01_FETCH_POSITION_GAS, + EXECUTE_WITHDRAW_GAS, MAX_QUEUE_LEN, MAX_TIMELOCK_NS, MIN_TIMELOCK_NS, WITHDRAW_GAS, + }, +}; +pub use wad::*; + +pub mod aum; +pub mod governance; +pub mod impl_callbacks; +pub mod impl_token_receiver; +pub mod storage_management; +pub mod wad; + +#[cfg(test)] +mod test_utils; + +#[derive(Debug, Clone)] +#[near(serializers = [borsh])] +#[derive(BorshStorageKey)] +/// Internal storage keys used by persistent collections. +pub enum StorageKey { + PendingWithdrawals, +} + +#[derive(BorshStorageKey)] +#[near] +/// Role-based access control roles for privileged actions. +pub enum Role { + /// Primary operator for market configuration and policy. + /// Can submit/accept cap changes and market removals, and is implicitly granted the Allocator role. + Curator, + /// Safety backstop that can revoke pending governance changes (e.g., timelock/guardian). + /// Has no authority to change caps or the supply queue on its own. + Guardian, + /// Operational role for allocation and withdrawal execution. + /// May set the supply_queue while the vault is Idle; cannot modify caps/timelocks/guardian. + Allocator, +} + +#[near(serializers = [borsh])] +#[derive(Debug, Clone, Default)] +pub struct MarketRecord { + pub cfg: MarketConfiguration, + pub 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")] +#[near(contract_state)] +/// Vault contract that issues shares over an underlying fungible asset and allocates liquidity +/// across configured markets. Implements 4626-like deposit/withdraw semantics. +/// +/// What this contract does +/// - Issues a share token (NEP-141) that represents a vault over an underlying NEP-141 “BorrowAsset”. +/// - Allocates deposits across “markets” via a supply queue; 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 +/// - Assets accounting is correct: total_assets = idle_balance + sum(all principals in markets). +/// - Only one op in flight (op_state); mutating ops require Idle. +/// - Governance changes obey timelocks; Guardian may revoke pending changes. +/// +/// Note: RBAC storage is paid by the contract; callers are not charged deposits for RBAC changes. +pub struct Contract { + /// The underlying asset that the vault manages + underlying_asset: FungibleAsset, + + /// The process in which the vault calculates its assets under management + aum: AUM, + + /// The mode in which the allocator will operate + mode: AllocationMode, + plan: Option, + + /// Performance fee + performance_fee: wad::Wad, + 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, + + // 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>, + /// 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: BTreeSet, + + // id of the pending withdrawal being executed, if any + current_withdraw_inflight: Option, + + /// underlying held by vault + idle_balance: u128, + op_state: OpState, + next_op_id: u64, + + /// Pending withdrawals queue (vault-level, FIFO by id) + pending_withdrawals: IterableMap, + next_withdraw_id: u64, + next_withdraw_to_execute: u64, + + // 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] +impl Contract { + #[allow(clippy::unwrap_used, reason = "Infallible")] + #[init] + /// Initializes a new vault. + /// - `owner_id`: account that controls Owner-only actions. + /// - `curator_id`: manages markets and is also granted the Allocator role. + /// - `guardian_id`: can revoke pending governance actions. + /// - `underlying_token_id`: NEP-141 underlying asset managed by the vault. + /// - `initial_timelock_sec`: governance timelock in seconds. + /// - `fee_recipient`: account to receive performance fees. + /// - `skim_recipient`: account to receive skimmed tokens. + /// - `name`/`symbol`/`decimals`: metadata for the share token. + #[must_use] + pub fn new(configuration: VaultConfiguration) -> Self { + let VaultConfiguration { + owner, + curator, + guardian, + underlying_token, + initial_timelock_ns, + fee_recipient, + skim_recipient, + name, + symbol, + decimals, + mode, + } = configuration; + + require!( + (MIN_TIMELOCK_NS..=MAX_TIMELOCK_NS).contains(&initial_timelock_ns.0), + "timelock bounds" + ); + + let mut contract = Self { + underlying_asset: underlying_token, + aum: AUM::BalanceSheet, + timelock_ns: initial_timelock_ns.0, + performance_fee: Wad::default(), + fee_recipient, + skim_recipient, + markets: BTreeMap::new(), + pending_timelock: None, + pending_guardian: None, + supply_queue: BTreeSet::default(), + last_total_assets: 0, + virtual_shares: 1, + virtual_assets: 1, + idle_balance: 0, + op_state: OpState::Idle, + next_op_id: 1, + mode, + plan: None, + current_withdraw_inflight: None, + 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(), + 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 + } + + /// Burns the necessary shares to withdraw `amount` of underlying to `receiver`. + /// Internally calls `redeem` after computing the share amount. + #[payable] + pub fn withdraw(&mut self, amount: U128, receiver: AccountId) -> PromiseOrValue<()> { + require_at_least(WITHDRAW_GAS); + 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. + #[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!(shares > 0, "Invalid shares"); + require!(assets > 0, "Dust redeem would yield 0 assets"); + + let _ = require_attached_for_pending_withdrawal(); + + self.transfer(&Nep141Transfer::new( + shares, + &sender, + env::current_account_id(), + )) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + self.internal_accrue_fee(); + + Event::RedeemRequested { + shares: U128(shares), + estimated_assets: U128(assets), + } + .emit(); + + self.enqueue_pending_withdrawal(&sender, &receiver, shares, assets); + PromiseOrValue::Value(()) + } + + /// Executes the 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); + self.ensure_idle(); + Self::assert_allocator(); + + if self.current_withdraw_inflight.is_some() { + env::panic_str("A pending withdrawal is already in-flight"); + } + + if let Some(id) = self.peek_next_pending_withdrawal_id() { + let pending = self + .pending_withdrawals + .get(&id) + .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( + pending.expected_assets, + &receiver, + &owner, + pending.escrow_shares, + route, + ); + } + + PromiseOrValue::Value(()) + } + + /// Executes one created market withdrawal request in the current Withdrawing op. + /// Allocator only. + pub fn execute_next_market_withdrawal( + &mut self, + op_id: U64, + batch_limit: Option, + ) -> PromiseOrValue<()> { + require_at_least(EXECUTE_WITHDRAW_GAS); + Self::assert_allocator(); + + let _ctx = match self.ctx_withdrawing(op_id.into()) { + Ok(v) => v, + Err(e) => return self.stop_and_exit(Some(&e)), + }; + + let Some(market_index) = self.pending_market_exec.first().copied() else { + env::panic_str("No pending market withdrawal request to execute"); + }; + + if let Err(e) = self.resolve_withdraw_market(market_index) { + return self.stop_and_exit(Some(&e)); + }; + + 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_01_FETCH_POSITION_GAS) + .execute_withdraw_01_call_market_fetch_position( + op_id.into(), + market_index, + batch_limit, + ), + ), + ) + } + + /// Sends the entire balance of `token` held by the vault to the `skim_recipient`. + pub fn skim(&mut self, token: AccountId) -> Promise { + Self::require_owner(); + + // Disallow skimming underlying or this own share token + let share_token_id = env::current_account_id(); + let underlying_token_id = self.underlying_asset.contract_id(); + + require!(token != share_token_id, "Refusing to skim the share token"); + require!( + token != underlying_token_id, + "Refusing to skim the underlying token" + ); + + self.ensure_idle(); + + ext_ft_core::ext(token.clone()) + .with_static_gas(Gas::from_tgas(3)) + .ft_balance_of(env::current_account_id()) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(Gas::from_tgas(10)) + .skim_01_read_balance(token, self.skim_recipient.clone()), + ) + } + + /// Allocates assets across markets according to the provided weights. + /// If `amount` is provided, it is used as the target amount for each market. + /// Otherwise, the vault will attempt to allocate as much as possible. + /// + /// NOTE: Each allocation takes roughly [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: When we rewrite this we should use a delta based approach + #[payable] + pub fn allocate( + &mut self, + weights: AllocationWeights, + amount: Option, + ) -> PromiseOrValue<()> { + require_at_least(ALLOCATE_GAS); + Self::assert_allocator(); + self.ensure_idle(); + + let total = self.clamp_allocation_total(amount.map(|x| x.0)); + + if weights.is_empty() { + if total == 0 { + return self.stop_and_exit(Some(&Error::ZeroAmount)); + } + let op_id = self.next_op_id; + Event::AllocationRequestedQueue { + op_id: op_id.into(), + total: U128(total), + } + .emit(); + self.plan = None; + return self.start_allocation(total); + } + + let weights = weights + .into_iter() + .map(|(m, w)| (m, u128::from(w))) + .collect::>(); + + let sum_weights: u128 = weights.values().sum(); + if sum_weights == 0 { + env::panic_str("Sum of weights is zero"); + } + if total == 0 { + env::panic_str("No funds to allocate"); + } + + let op_id = self.next_op_id; + let weights_for_event: Vec<(AccountId, U128)> = + weights.iter().map(|(m, w)| (m.clone(), U128(*w))).collect(); + Event::AllocationPlanSet { + op_id: op_id.into(), + total: U128(total), + plan: weights_for_event, + } + .emit(); + self.plan = Some(weights.into_iter().collect()); + + self.start_allocation(total) + } + + // Advance next_withdraw_to_execute to the next present id and return it, or None if none + fn peek_next_pending_withdrawal_id(&mut self) -> Option { + let mut id = self.next_withdraw_to_execute; + while id < self.next_withdraw_id { + if self.pending_withdrawals.get(&id).is_some() { + self.next_withdraw_to_execute = id; + return Some(id); + } + id = id.saturating_add(1); + } + self.next_withdraw_to_execute = id; + None + } + + // Remove the in-flight pending (success or explicit abort) and advance head past it + fn remove_inflight_and_advance_head(&mut self) { + if let Some(id) = self.current_withdraw_inflight.take() { + let _ = self.pending_withdrawals.remove(&id); + self.next_withdraw_to_execute = id.saturating_add(1); + Event::WithdrawDequeued { index: id.into() }.emit(); + } + } + + // Keep the head pending but clear in-flight so it can be retried later + fn park_inflight_head_for_retry(&mut self) { + if let Some(current_withdraw_inflight) = self.current_withdraw_inflight { + Event::WithdrawalParked { + id: current_withdraw_inflight.into(), + } + .emit(); + } + self.current_withdraw_inflight = None; + } +} + +/* ----- Views ----- */ +#[near] +impl Contract { + /// # Panics + /// - If the owner is not set + /// - If the curator is not set + /// - If the guardian is not set + #[allow(clippy::expect_used, reason = "No side effects")] + pub fn get_configuration(&self) -> VaultConfiguration { + let meta = self.get_metadata(); + VaultConfiguration { + owner: self + .own_get_owner() + .unwrap_or_else(|| 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 in get_configuration") + .clone() + }), + guardian: Self::with_members_of(&Role::Guardian, |members| { + require!( + members.len() == 1, + "Invariant violation: Cannot have more than one Guardian" + ); + members + .iter() + .next() + .expect("Guardian not set in get_configuration") + .clone() + }), + underlying_token: self.underlying_asset.clone(), + initial_timelock_ns: self.timelock_ns.into(), + fee_recipient: self.fee_recipient.clone(), + skim_recipient: self.skim_recipient.clone(), + name: meta.name, + symbol: meta.symbol, + decimals: NonZeroU8::new(meta.decimals).expect("Decimals must be non-zero"), + mode: self.mode.clone(), + } + } + + /// Returns total assets under management = idle balance + sum of market principals. + pub fn get_total_assets(&self) -> U128 { + self.aum.get_total_assets(self) + } + + pub fn get_idle_balance(&self) -> U128 { + self.idle_balance.into() + } + + pub fn get_total_supply(&self) -> U128 { + U128(self.total_supply()) + } + + /// Returns the maximum additional amount that can be deposited across all markets given current caps. + pub fn get_max_deposit(&self) -> U128 { + let total = self + .supply_queue + .iter() + .fold(0u128, |acc, m| match self.markets.get(m) { + Some(rec) if rec.cfg.cap.0 > 0 => acc + rec.cfg.cap.0.saturating_sub(rec.principal), + _ => acc, + }); + U128(total) + } + + /// Converts an amount of underlying assets to shares, flooring the result. + /// Uses virtual offsets and fee-aware totals (pre-accrual simulation). + pub fn convert_to_shares(&self, assets: U128) -> U128 { + let a: u128 = assets.0; + if a == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(mul_div_floor(a.into(), new_total_supply.into(), new_total_assets.into()).into()) + } + + /// Converts an amount of shares to underlying assets, flooring the result. + /// Uses virtual offsets and fee-aware totals (pre-accrual simulation). + pub fn convert_to_assets(&self, shares: U128) -> U128 { + let s: u128 = shares.0; + if s == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(mul_div_floor(s.into(), new_total_assets.into(), new_total_supply.into()).into()) + } + + /// Preview the number of shares minted for a deposit of `assets` (floored). + /// Simulates fee accrual first (minting fee shares), then applies virtual offsets for conversion. + pub fn preview_deposit(&self, assets: U128) -> U128 { + self.convert_to_shares(assets) + } + + /// Preview the amount of assets required to mint `shares` (ceiled). + /// Simulates fee accrual first (minting fee shares), then applies virtual offsets for conversion. + pub fn preview_mint(&self, shares: U128) -> U128 { + let s = shares.0; + if s == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(mul_div_ceil(s.into(), new_total_assets.into(), new_total_supply.into()).into()) + } + + /// Preview the number of shares required to withdraw `assets` (ceiled). + /// Applies virtual offsets and fee-aware totals (pre-accrual simulation). + pub fn preview_withdraw(&self, assets: U128) -> U128 { + let a = assets.0; + if a == 0 { + return U128(0); + } + let (new_total_supply, new_total_assets) = self.effective_totals_fee_aware(); + U128(mul_div_ceil(a.into(), new_total_supply.into(), new_total_assets.into()).into()) + } + + /// Preview the amount of assets received by redeeming `shares` (floored). + /// Returns 0 if total supply is zero. + pub fn preview_redeem(&self, shares: U128) -> U128 { + self.convert_to_assets(shares) + } + + pub fn get_withdrawing_op_id(&self) -> Option { + match &self.op_state { + OpState::Withdrawing(WithdrawingState { op_id, .. }) => Some((*op_id).into()), + _ => None, + } + } + + 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)] +pub(crate) struct EscrowSettlement { + pub to_burn: u128, + pub refund: u128, +} + +impl From for (u128, u128) { + fn from(tuple: EscrowSettlement) -> Self { + (tuple.to_burn, tuple.refund) + } +} + +/* ----- Private Helpers ----- */ +impl Contract { + // Principal (vault-supplied) units currently recorded for a market + fn principal_of(&self, market: &AccountId) -> u128 { + self.markets.get(market).map_or(0, |r| r.principal) + } + + fn cap_of(&self, market: &AccountId) -> u128 { + self.markets.get(market).map_or(0, |r| r.cfg.cap.0) + } + + // Remaining room until cap for a market + fn room_of(&self, market: &AccountId) -> u128 { + self.cap_of(market) + .saturating_sub(self.principal_of(market)) + } + + /// 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: id.into(), + owner: owner.clone(), + receiver: receiver.clone(), + escrow_shares: U128(escrow_shares), + expected_assets: U128(expected_assets), + requested_at: requested_at.into(), + } + .emit(); + } + + /// Computes fee-aware effective totals for conversions, mimicking `MetaMorpho`: + /// - Include fee shares that would be minted if fees accrued now. + /// - Apply virtual offsets: +`virtual_shares` to supply and +`virtual_assets` to assets. + fn effective_totals_fee_aware(&self) -> (u128, u128) { + let cur = self.get_total_assets().0; + let ts = self.total_supply(); + let (new_total_supply, new_total_assets) = Self::compute_effective_totals( + cur.into(), + self.last_total_assets.into(), + self.performance_fee, + ts.into(), + self.virtual_shares.into(), + self.virtual_assets.into(), + ); + (new_total_supply.into(), new_total_assets.into()) + } + + // Pure helper to compute how many escrowed shares to burn on partial payout + fn compute_burn_shares(escrow_shares: u128, collected: u128, requested_total: u128) -> u128 { + mul_div_floor( + escrow_shares.into(), + collected.into(), + requested_total.max(1).into(), + ) + .into() + } + + pub(crate) fn compute_effective_totals( + 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 + .saturating_add(fee_shares) + .saturating_add(virtual_shares); + let new_total_assets = cur_assets.saturating_add(virtual_assets); + (new_total_supply, new_total_assets) + } + + pub(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, + ) -> EscrowSettlement { + let to_burn = burn_shares.min(escrow_shares); + let refund = escrow_shares.saturating_sub(to_burn); + EscrowSettlement { to_burn, refund } + } + + pub fn internal_accrue_fee(&mut self) { + // Invariant: Fees are minted only when total_assets() > last_total_assets (no fees on losses/flat). + let cur = self.get_total_assets().0; + let fee_shares = compute_fee_shares( + cur.into(), + self.last_total_assets.into(), + self.performance_fee, + self.total_supply().into(), + ); + if fee_shares > Number::zero() { + let minted: u128 = fee_shares.into(); + let recipient = self.fee_recipient.clone(); + let _ = self + .mint(&Nep141Mint::new(minted, &recipient)) + .inspect_err(|e| env::log_str(&format!("Failed to mint {e}"))); + Event::PerformanceFeeAccrued { + recipient, + shares: U128(minted), + } + .emit(); + } + self.last_total_assets = cur; + } + + /* ----- Auth ----- */ + fn assert_guardian_or_owner() { + let p = env::predecessor_account_id(); + + if !Self::has_role(&p, &Role::Guardian) { + Self::require_owner(); + } + } + + fn assert_curator_or_owner() { + let p = env::predecessor_account_id(); + if !Self::has_role(&p, &Role::Curator) { + Self::require_owner(); + } + } + + fn assert_allocator() { + let p = env::predecessor_account_id(); + if !Self::has_role(&p, &Role::Allocator) && !Self::has_role(&p, &Role::Curator) { + Self::require_owner(); + } + } + + /* ----- Internal: op orchestration ----- */ + fn ensure_idle(&self) { + // Invariant: Only one op in flight; ensure_idle() guards all mutating ops. + if !matches!(self.op_state, OpState::Idle) { + env::panic_str(&format!( + "Invariant: Only one op in flight; current op_state = {:?}", + self.op_state + )); + } + } + + fn start_allocation(&mut self, amount: u128) -> PromiseOrValue<()> { + if amount == 0 { + // Dust request: clear the head and stay Idle to avoid wedging the queue + self.remove_inflight_and_advance_head(); + return PromiseOrValue::Value(()); + } + self.ensure_idle(); + + require!( + amount <= self.idle_balance, + "Policy violation: reserve amount must be <= idle_balance" + ); + self.update_idle_balance(IdleBalanceDelta::Decrease(amount.into())); + + let op_id = self.next_op_id; + self.next_op_id += 1; + self.op_state = OpState::Allocating(AllocatingState { + op_id, + index: 0, + remaining: amount, + }); + Event::AllocationStarted { + op_id: op_id.into(), + remaining: U128(amount), + } + .emit(); + self.step_allocation() + } + + /// build a supply `transfer_call` and chain `after_supply_1_check` + fn supply_and_then( + &self, + market: &AccountId, + amount: u128, + op_id: u64, + index: u32, + remaining_before: u128, + ) -> Promise { + self::require_at_least(AFTER_SUPPLY_1_CHECK_GAS.saturating_add(GAS_FOR_FT_TRANSFER_CALL)); + self.underlying_asset + .transfer_call( + market, + U128(amount).into(), + Some( + #[allow(clippy::expect_used, reason = "Infallible")] + serde_json::to_string(&templar_common::market::DepositMsg::Supply) + .unwrap_or_else(|e| env::panic_str(&e.to_string())) + .as_str(), + ), + ) + .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), + ), + ) + } + + // Step allocation when a weighted plan is present. + fn step_allocation_with_plan( + &mut self, + op_id: u64, + index: u32, + remaining: u128, + ) -> PromiseOrValue<()> { + if let Some(plan) = &self.plan { + let idx = index as usize; + if let Some((market, weight)) = plan.get(idx) { + let market_id = market.clone(); + + // Sum weights of remaining markets in the plan (including current) + let mut sum_w: u128 = 0; + for (_, w) in plan.iter().skip(idx) { + sum_w = sum_w.saturating_add(*w); + } + + // Compute weighted target for this step. For the last market (or zero sum), take all remaining. + let target = if sum_w == 0 || idx + 1 == plan.len() { + remaining + } else { + mul_div_floor(remaining.into(), (*weight).into(), sum_w.into()).into() + }; + + let room = self.room_of(&market_id); + let to_supply = room.min(target); + + Event::AllocationStepPlanned { + op_id: op_id.into(), + index, + market: market_id.clone(), + target: U128(target), + room: U128(room), + to_supply: U128(to_supply), + remaining_before: U128(remaining), + planned: true, + } + .emit(); + + if to_supply == 0 { + Event::AllocationStepSkipped { + op_id: op_id.into(), + index, + market: market_id.clone(), + reason: if room == 0 { + "no-room".to_string() + } else { + "zero-target".to_string() + }, + remaining: U128(remaining), + } + .emit(); + + self.op_state = OpState::Allocating(AllocatingState { + op_id, + index: index + 1, + remaining, + }); + return self.step_allocation(); + } + + PromiseOrValue::Promise( + self.supply_and_then(&market_id, to_supply, op_id, index, remaining), + ) + } else { + // Plan exhausted; stop and reconcile remaining in stop_and_exit + self.stop_and_exit::(None) + } + } else { + self.stop_and_exit(Some(&Error::NotAllocating)) + } + } + + // Step allocation using the supply_queue order. + fn step_allocation_from_queue( + &mut self, + op_id: u64, + index: u32, + remaining: u128, + ) -> PromiseOrValue<()> { + if let Some(market) = self.supply_queue.iter().nth(index as usize) { + let room = self.room_of(market); + let to_supply = room.min(remaining); + + // Emit planned step event (queue-based) + Event::AllocationStepPlanned { + op_id: op_id.into(), + index, + market: market.clone(), + target: U128(remaining), + room: U128(room), + to_supply: U128(to_supply), + remaining_before: U128(remaining), + planned: false, + } + .emit(); + + if to_supply == 0 { + Event::AllocationStepSkipped { + op_id: op_id.into(), + index, + market: market.clone(), + reason: "no-room".to_string(), + remaining: U128(remaining), + } + .emit(); + + self.op_state = OpState::Allocating(AllocatingState { + op_id, + index: index + 1, + remaining, + }); + return self.step_allocation(); + } + + PromiseOrValue::Promise( + self.supply_and_then(market, to_supply, op_id, index, remaining), + ) + } else { + self.stop_and_exit::(None) + } + } + + fn step_allocation(&mut self) -> PromiseOrValue<()> { + let (op_id, index, remaining) = match &self.op_state { + OpState::Allocating(AllocatingState { + op_id, + index, + remaining, + }) => (*op_id, *index, *remaining), + _ => return self.stop_and_exit(Some(&Error::NotAllocating)), + }; + + if remaining == 0 { + return self.stop_and_exit::(None); + } + + if self.plan.is_some() { + self.step_allocation_with_plan(op_id, index, remaining) + } else { + self.step_allocation_from_queue(op_id, index, remaining) + } + } + + fn start_withdraw( + &mut self, + amount: u128, + receiver: &AccountId, + owner: &AccountId, + escrow_shares: u128, + route: Vec, + ) -> PromiseOrValue<()> { + if amount == 0 { + return self.stop_and_exit(Some(&Error::ZeroAmount)); + } + self.ensure_idle(); + let op_id = self.next_op_id; + self.next_op_id += 1; + + // 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.pending_market_exec.clear(); + self.withdraw_route = route; + + self.op_state = OpState::Withdrawing(WithdrawingState { + op_id, + index: Default::default(), + remaining, + receiver: receiver.clone(), + collected, + owner: owner.clone(), + escrow_shares, + }); + self.step_withdraw() + } + + fn step_withdraw(&mut self) -> PromiseOrValue<()> { + let OpState::Withdrawing(WithdrawingState { + op_id, + index, + remaining, + receiver, + collected, + owner, + escrow_shares, + }) = self.op_state.clone() + else { + return self.stop_and_exit(Some(&Error::NotWithdrawing)); + }; + + if remaining == 0 { + self.op_state = OpState::Payout(PayoutState { + op_id, + receiver: receiver.clone(), + amount: collected, + owner: owner.clone(), + escrow_shares, + burn_shares: escrow_shares, + }); + require!(self.idle_balance >= collected, "idle underflow in payout"); + self.update_idle_balance(IdleBalanceDelta::Decrease(collected.into())); + + return PromiseOrValue::Promise( + self.underlying_asset + .transfer(receiver.clone(), U128(collected).into()) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(AFTER_SEND_TO_USER_GAS) + .payment_01_reconcile_idle_or_refund(op_id, receiver, U128(collected)), + ), + ); + } + if let Some(market) = self.withdraw_route.get(index as usize) { + let have = self.principal_of(market); + let to_request = have.min(remaining); + if to_request == 0 { + self.op_state = OpState::Withdrawing(WithdrawingState { + op_id, + index: index + 1, + remaining, + receiver, + collected, + owner, + escrow_shares, + }); + env::log_str(&format!( + "Skipping withdrawal for market {market} (have {have}, remaining {remaining})" + )); + return self.step_withdraw(); + } + PromiseOrValue::Promise( + templar_common::market::ext_market::ext(market.clone()) + .with_static_gas(CREATE_WITHDRAW_REQ_GAS) + .create_supply_withdrawal_request(BorrowAssetAmount::from(U128(to_request))) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(AFTER_CREATE_WITHDRAW_REQ_GAS) + .withdraw_01_handle_create_request(op_id, index, U128(to_request)), + ), + ) + } else { + 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_| { + self_.withdraw_route.clear(); + self_.op_state = OpState::Idle; + self_.park_inflight_head_for_retry(); + PromiseOrValue::Value(()) + }, + ) + } + } + + #[allow(clippy::too_many_arguments)] + /// If we collected something, pay it out now and burn proportional shares or do something else + fn pay_collected( + &mut self, + op_id: u64, + receiver: &AccountId, + collected: u128, + owner: &AccountId, + escrow_shares: u128, + burn_shares: u128, + or_else: impl FnOnce(&mut Self) -> PromiseOrValue<()>, + ) -> PromiseOrValue<()> { + if collected > 0 { + self.op_state = OpState::Payout(PayoutState { + op_id, + receiver: receiver.clone(), + amount: collected, + owner: owner.clone(), + escrow_shares, + burn_shares, + }); + require!(self.idle_balance >= collected, "idle underflow in payout"); + self.update_idle_balance(IdleBalanceDelta::Decrease(collected.into())); + + PromiseOrValue::Promise( + self.underlying_asset + .transfer(receiver.clone(), U128(collected).into()) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(AFTER_SEND_TO_USER_GAS) + .payment_01_reconcile_idle_or_refund( + op_id, + receiver.clone(), + U128(collected), + ), + ), + ) + } else { + or_else(self) + } + } +} + +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; diff --git a/contract/vault/src/storage_management.rs b/contract/vault/src/storage_management.rs new file mode 100644 index 00000000..88096341 --- /dev/null +++ b/contract/vault/src/storage_management.rs @@ -0,0 +1,90 @@ +use near_sdk::{env, require, AccountId}; +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; + +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_ft_account_entry() -> u64 { + let key = storage_bytes_for_account_id(); + let val = U128_BYTES; // balance: u128 + MAP_ENTRY_OVERHEAD + key + val +} + +#[must_use] +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 + let key = 8u64; + let val = PendingWithdrawal::encoded_size(); + MAP_ENTRY_OVERHEAD + key + val +} + +#[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_queue_additions(current: &BTreeSet, 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_account_id())) + } + }) +} + +#[must_use] +pub fn require_attached_at_least(required_yocto: u128, ctx: &str) -> u128 { + let attached = env::attached_deposit().as_yoctonear(); + require!( + attached >= required_yocto, + format!("Insufficient storage deposit for {ctx}: required {required_yocto}, attached {attached}") + ); + required_yocto +} + +#[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_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); + let _ = require_attached_at_least(yocto, ctx); + } + out +} + +#[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 new file mode 100644 index 00000000..4faca3b2 --- /dev/null +++ b/contract/vault/src/test_utils.rs @@ -0,0 +1,96 @@ +#![allow(clippy::all)] + +use std::collections::HashMap; + +use crate::Contract; +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::Nep145; +use test_utils::vault_configuration; + +pub fn mk(n: u32) -> AccountId { + format!("acc{n}.testnet").parse().unwrap() +} + +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(), + HashMap::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.clone(), + curator.clone(), + guardian.clone(), + underlying_token_id.clone(), + skim_recipient.clone(), + fee_recipient.clone(), + ); + + 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(), + HashMap::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) { + 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()); + 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 new file mode 100644 index 00000000..11b5234b --- /dev/null +++ b/contract/vault/src/tests.rs @@ -0,0 +1,2411 @@ +#![allow(clippy::pedantic)] + +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::test_utils::*; +use crate::wad::compute_fee_shares; +use crate::wad::Wad; +use crate::Contract; +use crate::MarketRecord; +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}; +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::asset::FungibleAsset; +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] +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 c = new_test_contract(&vault_id_fixture); + let owner = c + .own_get_owner() + .unwrap_or_else(|| env::panic_str("Owner not set")); + setup_env(&vault_id_fixture, &owner, vec![]); + c +} + +#[fixture] +fn c_asset_env(vault_id_fixture: AccountId) -> Contract { + 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 +} + +#[fixture] +fn enabled_market_100() -> (AccountId, MarketConfiguration) { + let m = mk(9001); + let cfg = MarketConfiguration { + cap: U128(100), + enabled: true, + removable_at: 0, + }; + (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) { + 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] +fn fee_accrues_only_on_growth_unit(c_vault_env: Contract) { + let mut c = c_vault_env; + + // 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.idle_balance = 1_000; + + // Set fee to 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; + 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 = compute_fee_shares( + c.get_total_assets().0.into(), + c.last_total_assets.into(), + c.performance_fee, + c.total_supply().into(), + ); + c.internal_accrue_fee(); + assert_eq!( + c.total_supply(), + ts_before + expect.as_u128_trunc(), + "fee shares minted must match compute_fee_shares" + ); +} + +#[rstest] +fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_env: Contract) { + let mut c = c_vault_env; + + let receiver = mk(7); + let owner = accounts(1); + + // Seed escrow into vault account (shares held by vault) + c.deposit_unchecked(&near_sdk::env::current_account_id(), 100) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + // Seed idle to cover payout + c.idle_balance = 1_000; + + // Partial payout scenario: collected/requested = 200/500 => burn 40% of escrowed shares + let amount = 200; + let op_id = 1; + c.op_state = OpState::Payout(PayoutState { + op_id, + receiver: receiver.clone(), + 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(()), 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 + assert!(matches!(c.op_state, OpState::Idle)); +} + +#[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)]); +} + +#[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 _ = c.execute_supply(sender.clone(), wrong_token.clone(), deposit); +} + +#[rstest] +fn start_allocation_reserves_only_amount(c_vault_env: Contract) { + let mut c = c_vault_env; + + // Configure a single market with cap = 80 in the supply queue + let m1 = mk(2000); + let cfg = MarketConfiguration { + cap: U128(80), + enabled: true, + removable_at: 0, + }; + c.markets.insert( + m1.clone(), + MarketRecord { + cfg, + pending_cap: None, + principal: 0, + }, + ); + c.supply_queue.insert(m1.clone()); + + // 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 + 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, + }, + ); + } + // 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 { + 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_or_else(|| env::panic_str("Owner not set")), + vec![], + ); + + // Supply queue has m1; stale plan points to m2 + let m1 = mk(3001); + let m2 = mk(3002); + + let cfg1 = MarketConfiguration { + cap: U128(10), + enabled: true, + removable_at: 0, + }; + c.markets.insert(m1.clone(), cfg1.into()); + c.supply_queue.insert(m1); + + // Stale plan (should be ignored for queue-based allocation) + c.plan = Some(vec![(m2.clone(), 1u128)]); + + c.idle_balance = 5; + + // Run queue-based allocation (weights empty) -> must clear any stale plan + let weights: templar_common::vault::AllocationWeights = vec![]; + let _ = c.allocate(weights, None); + + assert!( + c.plan.is_none(), + "queue-based allocate must ignore and clear any stale plan" + ); +} + +#[rstest( + escrow, collected, requested, expect, + case(100u128, 200u128, 500u128, 40u128), // 40% + 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![]); + + assert_eq!( + Contract::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 cur = 1_500u128.into(); + let last = 1_000u128.into(); + let perf = 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 = 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 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] +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 cfg = MarketConfiguration { + cap: U128(10), + enabled: true, + removable_at: 0, + }; + 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").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"); + + set_block_ts(&vault_id, &owner, 2); + + // Now we can schedule removal + c.submit_market_removal(m.clone()); + let cfg_after2 = c.markets.get(&m).expect("market must exist"); + assert!(cfg_after2.cfg.removable_at > 0, "removal must be scheduled"); +} +#[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.markets.insert( + m.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 0, + }, + ); + + // Submit raise -> pending + let raise = 5u128; + set_ctx(&vault_id, &owner, None, Some(yocto_for_bytes(10_000))); + c.submit_cap(m.clone(), U128(raise)); + + // Fast-forward timelock to accept the raise + set_ctx( + &vault_id, + &owner, + Some(env::block_timestamp() + 1_000_000_000), + 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"); + + // 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().cfg; + assert_eq!(cfg2.cap.0, 0); + assert!(cfg2.enabled, "enabled must remain true on cap=0"); +} + +#[rstest( + before, + new_principal, + need, + rem, + coll, + case(100u128, 55u128, 45u128, 50u128, 10u128), + case(100u128, 80u128, 40u128, 50u128, 10u128), + case(0u128, 0u128, 0u128, 0u128, 0u128), + case(1000u128, 1000u128, 500u128, 800u128, 100u128) +)] +fn reconcile_withdraw_outcome_invariants_cases( + before: u128, + new_principal: u128, + need: u128, + rem: u128, + coll: u128, +) { + 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!(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( + 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 cfg = MarketConfiguration { + cap: U128(cap), + enabled: cap > 0, + removable_at: 0, + }; + 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); + 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_sums_all_markets_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.markets.insert( + m.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal, + }, + ); + c.idle_balance = idle; + + assert_eq!(c.get_total_assets().0, idle.saturating_add(principal)); +} + +#[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 = Wad::one() / 10; + + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + 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); + + // 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.as_u128_trunc(), + "fees must accrue to the old recipient before switching" + ); + assert_eq!( + c.total_supply(), + ts_before + expect.as_u128_trunc(), + "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 = Wad::one() / 20; // 5% + + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + 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); + + // 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.as_u128_trunc(), + "fees must accrue to the old recipient before switching" + ); + assert_eq!( + c.total_supply(), + ts_before + expect.as_u128_trunc(), + "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")); + 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 = Wad::one() / 10; + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + 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(Wad::one() / 100); + + assert_eq!( + c.balance_of(&recipient), + 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.as_u128_trunc(), + "total supply must reflect fee shares minted at old rate" + ); + assert_eq!( + c.performance_fee, + crate::wad::Wad::one() / 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")); + 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 = Wad::one() / 20; // 5% + let cur = c.get_total_assets().0; + let ts_before = c.total_supply(); + 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(Wad::one() / 200); // 0.5% + + assert_eq!( + c.balance_of(&recipient), + 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.as_u128_trunc(), + "total supply must reflect fee shares minted at old rate" + ); + assert_eq!( + c.performance_fee, + crate::wad::Wad::one() / 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 = Wad::one() / 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" + ); +} + +#[rstest] +fn ft_on_transfer_supply_accepts_full_and_mints_shares( + 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.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(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" + ); +} + +#[rstest] +fn ft_on_transfer_supply_partial_refund_when_capped( + 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.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(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] +#[should_panic = "Invalid token ID"] +fn ft_on_transfer_wrong_token_full_refund_via_receiver() { + // Underlying token id != predecessor => full refund + let vault_id = accounts(0); + let mut c = new_test_contract(&mk(42)); // underlying differs from predecessor + setup_env(&vault_id, &vault_id, vec![]); + + c.mode = AllocationMode::Eager { + min_batch: U128(u128::MAX), + }; + + // Provide a market (not used due to wrong token) + let m = mk(9003); + let cfg = MarketConfiguration { + cap: U128(100), + enabled: true, + removable_at: 0, + }; + c.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(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()); +} + +#[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: AccountId = c.underlying_asset.contract_id().into(); + + c.ft_on_transfer( + sender.clone(), + U128(0), + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); +} + +#[rstest] +fn ft_on_transfer_eager_mode_triggers_allocation( + c_asset_env: Contract, + enabled_market_100: (AccountId, MarketConfiguration), +) { + let mut c = c_asset_env; + + // Trigger eager allocation with any positive deposit + c.mode = AllocationMode::Eager { min_batch: U128(1) }; + + // Valid market/cap + let (m, cfg) = enabled_market_100; + c.markets.insert(m.clone(), cfg.into()); + c.supply_queue.insert(m); + + let deposit = 5u128; + + let res = c.ft_on_transfer( + c.underlying_asset.contract_id().into(), + U128(deposit), + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ); + + match res { + PromiseOrValue::Value(U128(refund)) => assert_eq!(refund, 0), + _ => panic!("expected Value refund"), + } + + assert!( + matches!(c.op_state, OpState::Allocating { .. }), + "Eager mode must trigger allocation" + ); + assert_eq!( + c.idle_balance, 0, + "idle should be reserved by start_allocation" + ); +} + +#[test] +#[should_panic = "Invalid deposit msg"] +fn mt_on_transfer_invalid_msg_panics() { + 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); + 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), + vec![sender.clone()], // previous_owner_ids + vec![token_id], // 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] +#[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 asset_id = c.underlying_asset.contract_id().into(); + let sender_id = accounts(4); + c.execute_supply(sender_id.clone(), asset_id, 0); +} + +#[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 cfg = MarketConfiguration { + cap: U128(1), + enabled: true, + removable_at: 0, + }; + c.markets.insert(m1.clone(), cfg.into()); + + 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.iter().next(), 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 cfg = MarketConfiguration { + cap: U128(1), + enabled: true, + removable_at: 0, + }; + c.markets.insert(m1.clone(), cfg.into()); + + // Grant Allocator role + c.set_is_allocator(grantee.clone(), true); + + // Grantee can set supply queue + set_ctx( + &vault_id, + &grantee, + 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.iter().next(), 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 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 + 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 = "Timelock not elapsed 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_ns; + + // Increase applies immediately + c.submit_timelock((cur.0 + 1).into()); + assert_eq!( + c.get_configuration().initial_timelock_ns.0, + cur.0 + 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_ns, + 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_ns; + + // Force a pending by first increasing then decreasing + c.submit_timelock((cur.0 + 1).into()); + c.submit_timelock(cur); + + // Revoke the pending change; accept must now panic + c.revoke_pending_timelock(); + c.accept_timelock(); +} + +#[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 cfg = MarketConfiguration { + cap: U128(10), + enabled: true, + removable_at: 0, + }; + c.markets.insert(m.clone(), cfg.into()); + + c.submit_cap(m.clone(), U128(3)); + let after = c.markets.get(&m).unwrap(); + assert_eq!(after.cfg.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_bytes(20_000))); + c.submit_cap(m.clone(), U128(5)); + + // Advance timelock and accept; attach storage for withdraw queue addition + set_ctx( + &vault_id, + &owner, + Some(env::block_timestamp() + 1_000_000_000), + None, + ); + c.accept_cap(m.clone()); + + let cfg = &c.markets.get(&m).unwrap().cfg; + assert_eq!(cfg.cap.0, 5); + assert!( + cfg.enabled, + "market should be enabled after accepting raise" + ); +} + +#[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_bytes(20_000))); + c.submit_cap(m.clone(), U128(7)); + + // Revoke, then accepting should panic + set_ctx(&vault_id, &owner, None, None); + c.revoke_pending_cap(m.clone()); + c.accept_cap(m); +} + +#[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 cfg = MarketConfiguration { + cap: U128(0), + enabled: true, + removable_at: 0, + }; + c.markets.insert(m.clone(), cfg.into()); + + // Submit removal (schedules timelock) + c.submit_market_removal(m.clone()); + let after = c.markets.get(&m).unwrap(); + assert!(after.cfg.removable_at > 0, "removal must be scheduled"); + + // Revoke pending removal + c.revoke_pending_market_removal(m.clone()); + let after2 = c.markets.get(&m).unwrap(); + assert_eq!(after2.cfg.removable_at, 0, "removal must be revoked"); +} + +#[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); + + 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 = 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] +#[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(4); + c.set_skim_recipient(recipient.clone()); + + // Attempt to skim the underlying token -> must panic + let underlying: AccountId = c.underlying_asset.contract_id().into(); + let _ = c.skim(underlying); +} + +#[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![]); + + // Set a skim recipient + let recipient = accounts(4); + c.set_skim_recipient(recipient.clone()); + + // Attempt to skim the share token (the vault itself) -> must panic + let share_token: AccountId = vault_id.clone(); + let _ = c.skim(share_token); +} + +#[rstest] +fn after_supply_1_check_allocating_not_allocating(c_max: Contract) { + let mut c = c_max; + + c.op_state = OpState::Idle; + + 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); +} + +#[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; + + c.op_state = OpState::Allocating(AllocatingState { + op_id, + index: 0u32, + remaining: 0u128, + }); + + 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); +} + +#[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; + + c.op_state = OpState::Allocating(AllocatingState { + op_id, + index: 0u32, + remaining: 0u128, + }); + + c.supply_01_handle_transfer( + Ok(U128(1)), + accounts(3), + op_id, + 0, + Default::default(), + Default::default(), + ); + + assert_eq!( + c.op_state, + OpState::Allocating(AllocatingState { + op_id, + index: 0, + remaining: 0u128 + }) + ); + assert_eq!(c.plan, None); +} + +#[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, + }, + ); + + let op_id = 42; + let index = 0; + c.op_state = OpState::Withdrawing(WithdrawingState { + op_id, + index, + remaining: 60, + receiver: mk(9), + collected: 10, + owner: accounts(1), + escrow_shares: 50, + }); + + 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"), + } + + let res2 = c.execute_withdraw_03_settle( + Ok(U128(principal)), // observed after_balance + op_id, + index, + U128(principal), // before_principal + U128(0), + U128(0), + ); + + match res2 { + PromiseOrValue::Promise(_p) => {} + _ => panic!("Expected a Promise to send payout after settlement"), + } + + assert_eq!( + c.markets.get(&market).map_or(u128::MAX, |r| r.principal), + 0, + "Market principal should be updated to 0" + ); + + // Collected was 70, payouit is 70, idle is 30 + + assert_eq!( + c.idle_balance, 30, + "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(PayoutState { 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.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"), + } +} + +#[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.skim_01_read_balance(Ok(U128(123)), mk(10), mk(11)); + match res { + PromiseOrValue::Promise(_) => { //NOTE: one day we will be able to read the promise + //definition :< + } + _ => panic!("Skim with positive balance must return a Promise"), + } +} + +/// Property: Create-withdraw failure skips to next market and if collected>0 ends in Payout +#[rstest( + collected => [1u128, 10u128], + need => [1u128, 5u128] +)] +fn prop_after_create_withdraw_req_failure_skips(collected: u128, need: u128) { + let vault_id = accounts(0); + setup_env(&vault_id, &vault_id, vec![]); + let mut c = new_test_contract(&vault_id); + c.idle_balance = collected; + + // Single-market route so advancing index reaches end-of-route + let market = mk(8); + c.withdraw_route = vec![market.clone()]; + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 100, + }, + ); + + c.op_state = OpState::Withdrawing(WithdrawingState { + op_id: 7, + index: 0, + remaining: need, + receiver: mk(9), + collected, + owner: accounts(1), + escrow_shares: 0, + }); + + let res = + c.withdraw_01_handle_create_request(Err(near_sdk::PromiseError::Failed), 7, 0, U128(need)); + match res { + PromiseOrValue::Promise(_) => {} + _ => panic!("Expected Promise after skipping to payout at end-of-queue"), + } + assert_eq!(c.idle_balance, 0); + + match &c.op_state { + OpState::Payout(PayoutState { amount, .. }) => { + assert_eq!(*amount, collected, "Payout amount must equal collected"); + } + other => panic!("Unexpected state: {other:?}"), + } +} + +/// Property: Exec-withdraw read failure assumes unchanged principal and does not credit idle +#[rstest( + before => [0u128, 1u128, 100u128], + 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_route = vec![market.clone()]; + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: before, + }, + ); + + let initial_idle = c.idle_balance; + + c.op_state = OpState::Withdrawing(WithdrawingState { + op_id: 99, + index: 0, + remaining: need, + receiver: mk(9), + collected, + owner: accounts(1), + escrow_shares: 0, + }); + + let res = c.execute_withdraw_02_reconcile_position( + Err(near_sdk::PromiseError::Failed), + 99, + 0, + U128(before), + U128(0), + ); + match res { + PromiseOrValue::Value(()) => {} + _ => panic!("Expected Value(()) due to read failure and stop"), + } + + assert_eq!( + c.markets.get(&market).map_or(u128::MAX, |r| r.principal), + before, + "principal must remain unchanged on read failure" + ); + assert_eq!( + c.idle_balance, initial_idle, + "idle_balance must not change when nothing credited" + ); + + 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 +#[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_route = vec![market.clone()]; + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 10, + }, + ); + + let real_op = 5u64; + let real_idx = 0u32; + + c.op_state = OpState::Withdrawing(WithdrawingState { + 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.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), + "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); + 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, + index, + 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.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"), + } + + let res2 = c.execute_withdraw_03_settle( + Ok(U128(0)), // no inflow observed + op_id, + index, + U128(0), // before_principal + U128(0), // new_principal reported + U128(0), // before_balance + ); + match res2 { + 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(AllocatingState { + 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(WithdrawingState { + op_id: 7, + index: 1, + remaining: 50, + receiver: recv.clone(), + collected: 5, + owner: owner.clone(), + escrow_shares: 10, + }); + + let ctx = c + .ctx_withdrawing(7) + .expect("ctx_withdrawing should succeed"); + 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()); +} + +#[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); + + // Withdraw resolver uses withdraw_route only + let m1 = mk(1001); + let m2 = mk(1002); + 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!( + 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.insert(market.clone()); + + // Must be in Allocating ctx + c.op_state = OpState::Allocating(AllocatingState { + op_id: 1, + index: 0, + remaining: 10, + }); + + // Missing position -> stop_and_exit + 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"), + } + 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.insert(market); + + // Must be in Allocating ctx + c.op_state = OpState::Allocating(AllocatingState { + op_id: 7, + index: 0, + remaining: 100, + }); + + // 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(()) => {} + _ => 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_route = vec![market.clone()]; + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 100, + }, + ); + + c.op_state = OpState::Withdrawing(WithdrawingState { + op_id: 21, + index: 0, + remaining: 60, + receiver: receiver.clone(), + collected: 10, + owner: owner.clone(), + escrow_shares: 5, + }); + + 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 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_route = vec![market.clone()]; + c.markets.insert( + market.clone(), + MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 10, + }, + ); + + let op_id = 33; + c.op_state = OpState::Withdrawing(WithdrawingState { + op_id, + index: 0, + remaining: 5, + receiver: mk(9), + collected: 0, + owner: accounts(1), + escrow_shares: 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"), + } + assert!(matches!( + c.op_state, + OpState::Withdrawing(WithdrawingState { .. }) + )); +} + +#[rstest] +fn after_exec_withdraw_read_advances_when_remaining( + mut c: Contract, + owner: AccountId, + receiver: AccountId, +) { + let m1 = mk(70); + let record = MarketRecord { + cfg: MarketConfiguration::default(), + pending_cap: None, + principal: 10, + }; + c.markets.insert(m1.clone(), record.clone()); + + let m2 = mk(71); + c.withdraw_route = vec![m1.clone(), m2.clone()]; + + let op_id = 0; + let index = 0; + let before_balance = 0; + + c.op_state = OpState::Withdrawing(WithdrawingState { + op_id, + index, + remaining: 100, + receiver: receiver.clone(), + collected: 0, + owner: owner.clone(), + escrow_shares: 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 the inflow equal to the reported principal delta + // before = 0 + // after = 10 + let res2 = c.execute_withdraw_03_settle( + 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"), + } + + match &c.op_state { + OpState::Payout(PayoutState { + op_id, + receiver: r, + amount, + owner: o, + escrow_shares, + burn_shares, + }) => { + assert_eq!(*op_id, 0); + assert_eq!(*amount, before_balance + record.principal); + 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(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()); + 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(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()); + 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/wad.rs b/contract/vault/src/wad.rs new file mode 100644 index 00000000..164f286a --- /dev/null +++ b/contract/vault/src/wad.rs @@ -0,0 +1,493 @@ +use core::ops::Div; +use std::collections::BTreeMap; +use std::ops::{Add, Sub}; + +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; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +pub struct Number(pub U256); + +impl Number { + #[inline] + #[must_use] + pub fn zero() -> Self { + Number(U256::zero()) + } + #[inline] + #[must_use] + pub fn one() -> Self { + Number(U256::one()) + } + #[inline] + #[must_use] + pub fn is_zero(&self) -> bool { + self.0.is_zero() + } + #[inline] + #[must_use] + pub fn is_one(&self) -> bool { + self.0 == U256::one() + } + #[inline] + #[must_use] + pub fn as_u128_trunc(self) -> u128 { + let mut b32 = [0u8; 32]; + self.0.write_as_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.write_as_little_endian(&mut b64); + U256::from_little_endian(&b64[..32]) + } + #[inline] + #[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 { + 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)) + } + + #[allow(clippy::many_single_char_names)] + #[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 + } else { + base.saturating_add(Number::one()) + } + } +} + +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) + } +} + +impl BorshSerialize for Number { + #[inline] + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + let mut b32 = [0u8; 32]; + self.0.write_as_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))) + } +} + +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 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; + +/// A 24-decimal fixed-point value (1e24 = 100%), backed by U256. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde", transparent)] +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::from(Self::SCALE) + } + + #[inline] + #[must_use] + pub fn is_zero(&self) -> bool { + self.0.is_zero() + } + + #[inline] + #[must_use] + 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<()> { + BorshSerialize::serialize(&self.0, writer) + } +} + +impl BorshDeserialize for Wad { + #[inline] + fn deserialize_reader(reader: &mut R) -> std::io::Result { + let inner = ::deserialize_reader(reader)?; + Ok(Wad(inner)) + } +} + +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() + } +} + +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 +/// - `performance_fee`: WAD fraction (1e24 = 100%) +/// - `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`). +#[inline] +#[must_use] +pub fn compute_fee_shares( + 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.saturating_sub(last_total_assets); + if profit.is_zero() { + return Number::zero(); + } + 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) +} + +/// 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::*; + + #[test] + fn mul_wad_floor_rounds_down() { + // 0.3333... * 0.3333... ~= 0.1111... + 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] + 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: 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: 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!( + 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!( + 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!( + 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 = 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( + 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!(u128::from(minted), 1_000); + } +} diff --git a/contract/vault/tests/conversions.rs b/contract/vault/tests/conversions.rs new file mode 100644 index 00000000..34865789 --- /dev/null +++ b/contract/vault/tests/conversions.rs @@ -0,0 +1,171 @@ +use rstest::rstest; +use templar_vault_contract::{wad::compute_fee_shares, *}; + +#[test] +fn no_fee_returns_zero() { + 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.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.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!( + u128::from(compute_fee_shares( + 1200u128.into(), + 1000u128.into(), + Wad::one() / 10u128, + 1000u128.into() + )), + 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!( + 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.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: 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:?})" + ); +} + +// Property: Fee shares are 0 when not profitable (cur_total_assets <= last_total_assets) +#[rstest( + 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(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.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: Number, a: Number, k: Number) { + // More assets never produce fewer shares (with fixed totals & offsets). + 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 => [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: 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:?})" + ); +} + +// 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 =>[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: 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.is_zero()); +} diff --git a/contract/vault/tests/happy_path.rs b/contract/vault/tests/happy_path.rs new file mode 100644 index 00000000..6769e3c7 --- /dev/null +++ b/contract/vault/tests/happy_path.rs @@ -0,0 +1,166 @@ +#![allow(clippy::all, clippy::pedantic)] + +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, worker, ContractController, + UnifiedMarketController, +}; + +#[rstest] +#[tokio::test] +async fn happy(#[future(awt)] worker: Worker) { + setup_test!( + worker + 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 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))]; + 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_idle_balance().await.0, + 0, + "Vault should not have idle balance after allocation" + ); + 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; + // 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()]; + 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 + .execute_next_market_withdrawal(&vault_curator, op_id) + .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; + + // 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()]; + 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 + .execute_next_market_withdrawal(&vault_curator, op_id) + .await; +} + +pub async fn harvest(c: &UnifiedMarketController, vault: &UnifiedVaultController) { + // Wait for activation. + 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; + } +} diff --git a/contract/vault/tests/invariants.rs b/contract/vault/tests/invariants.rs new file mode 100644 index 00000000..3ad7f04e --- /dev/null +++ b/contract/vault/tests/invariants.rs @@ -0,0 +1,38 @@ +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(#[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_supply_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( + #[future(awt)] worker: Worker, +) { + setup_test!( + worker + extract(vault, c, vault_owner) + accounts(supply_user, borrow_user) + ); + let amount = 1000; + vault.supply(&supply_user, amount).await; + + futures::future::select_all( + (0..100).map(|_| Box::pin(vault.allocate(&vault_owner, vec![], Some(1.into())))), + ) + .await; +} 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 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 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..8d64354f 100644 --- a/test-utils/src/controller/mod.rs +++ b/test-utils/src/controller/mod.rs @@ -15,6 +15,7 @@ pub mod registry; pub mod storage_management; pub mod token; pub mod universal_account; +pub mod vault; pub trait ContractController { fn contract(&self) -> &Contract; diff --git a/test-utils/src/controller/vault.rs b/test-utils/src/controller/vault.rs new file mode 100644 index 00000000..e3a61556 --- /dev/null +++ b/test-utils/src/controller/vault.rs @@ -0,0 +1,389 @@ +use super::ContractController; +use crate::{ + controller::storage_management::StorageManagementController, define, get_contract, + print_execution, UnifiedMarketController, +}; +use near_sdk::{ + json_types::{U128, U64}, + serde_json::{self, json}, + AccountId, NearToken, +}; +use near_workspaces::{ + network::Sandbox, result::ExecutionSuccess, types::SecretKey, Account, Contract, Worker, +}; +use std::{env, ops::Deref}; +use templar_common::vault::{AllocationWeights, DepositMsg, VaultConfiguration}; +use 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_max_deposit() -> U128; + #[view] pub fn get_idle_balance() -> U128; + #[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; + #[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. + #[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(weights: AllocationWeights, amount: Option); + + #[call(exec, tgas(30), deposit(NearToken::from_yoctonear(2560000000000000000000)))] + pub fn withdraw(amount: U128, receiver: AccountId); + + #[call(exec, tgas(300))] + pub fn execute_next_withdrawal_request(route: Vec); + + #[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); + + #[call(exec, tgas(50))] + pub fn skim["skim"](token: AccountId); + + #[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(840000000000000000000)))] + 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); + + #[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_ns: U64); + + #[call(exec, tgas(50))] + pub fn accept_timelock(); + + #[call(exec, tgas(50))] + pub fn revoke_pending_timelock(); + + #[call(exec, tgas(50), deposit(NearToken::from_yoctonear(840000000000000000000)))] + 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(20))] + pub fn after_create_withdraw_req(op_id: u64, index: u32, amount: U128); + + // After payout to the user completes. + #[call(exec, tgas(5))] + 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, + pub debug: bool, +} + +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, + debug: is_debug(), + } + } + + #[must_use] + pub fn new( + vault: VaultController, + configuration: VaultConfiguration, + market: UnifiedMarketController, + ) -> Self { + Self { + vault, + configuration, + market, + debug: is_debug(), + } + } + + 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_deposits(account).await; + } + + pub async fn supply(&self, supply_user: &Account, amount: u128) -> ExecutionSuccess { + eprintln!( + "{} transferring {amount} tokens for supply...", + supply_user.id() + ); + let e = self + .market + .borrow_asset + .transfer_call( + supply_user, + self.vault.contract().id(), + amount, + serde_json::to_string(&DepositMsg::Supply).unwrap(), + ) + .await; + if self.debug { + print_execution(&e); + } + e + } + + pub async fn setup_caps(&self, owner: &Account, markets: &[AccountId], amount: u128) { + for mkt in markets { + self.submit_cap(owner, mkt.clone(), amount.into()).await; + self.accept_cap(owner, mkt.clone()).await; + } + + self.set_supply_queue(owner, markets).await; + } + + pub async fn allocate( + &self, + allocator: &Account, + weights: AllocationWeights, + amount: Option, + ) -> ExecutionSuccess { + let e = self + .vault + .allocate(allocator, weights, amount.unwrap_or(1000.into())) + .await; + if self.debug { + print_execution(&e); + } + e + } + + pub async fn withdraw( + &self, + withdrawer: &Account, + amount: U128, + receiver: Option, + ) -> ExecutionSuccess { + let e = self + .vault + .withdraw( + withdrawer, + amount, + receiver.unwrap_or(withdrawer.id().clone()), + ) + .await; + if self.debug { + print_execution(&e); + } + e + } + + 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); + } + e + } + + 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) -> 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], + ) -> 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], + ) -> ExecutionSuccess { + let e = self.vault.set_withdraw_queue(allocator, markets).await; + if self.debug { + print_execution(&e); + } + e + } +} + +fn is_debug() -> bool { + env::var("RUST_LOG").is_ok_and(|s| s.contains("debug")) || env::var("DEBUG").is_ok() +} diff --git a/test-utils/src/lib.rs b/test-utils/src/lib.rs index f9e183ab..90b31e7e 100644 --- a/test-utils/src/lib.rs +++ b/test-utils/src/lib.rs @@ -1,5 +1,6 @@ -use std::{path::Path, str::FromStr}; +use std::{num::NonZero, path::Path, str::FromStr}; +use crate::controller::vault::{UnifiedVaultController, VaultController}; pub use controller::{ ft::FtController, market::{MarketController, UnifiedMarketController}, @@ -28,6 +29,7 @@ use templar_common::{ number::Decimal, oracle::pyth::{self, PriceIdentifier}, registry::DeployMode, + vault::VaultConfiguration, }; pub const DEFAULT_COLLATERAL_PRICE_ID: PriceIdentifier = PriceIdentifier(hex_literal::hex!( @@ -81,16 +83,19 @@ macro_rules! accounts { #[macro_export] macro_rules! setup_test { - ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { + ($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).await; + 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; }; + ($w:ident extract($($e:ident),*) accounts($($n:ident),*) config($f:expr)) => { + $crate::setup_test!($w extract($($e),*) accounts($($n),*) config($f) vconfig(|_| {})); + }; ($w:ident extract($($e:ident),*) accounts($($n:ident),*)) => { - $crate::setup_test!($w extract($($e),*) accounts($($n),*) config(|_| {})) + $crate::setup_test!($w extract($($e),*) accounts($($n),*) config(|_| {}) vconfig(|_| {})); }; } @@ -135,6 +140,29 @@ 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_ns: templar_common::vault::MIN_TIMELOCK_NS.into(), + fee_recipient: fee_recipient_id, + skim_recipient: skim_recipient_id, + name: "Vault".to_string(), + symbol: "VAULT".to_string(), + decimals: NonZero::new(24).unwrap(), + mode: templar_common::vault::AllocationMode::Lazy, + } +} + 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 +191,18 @@ pub struct SetupEverything { pub c: UnifiedMarketController, 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( worker: &Worker, customize_market_configuration: impl FnOnce(&mut MarketConfiguration), + customize_vault_configuration: impl FnOnce(&mut VaultConfiguration), ) -> SetupEverything { accounts!( worker, @@ -176,7 +211,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 +230,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 +272,7 @@ pub async fn setup_everything( } } }, + VaultController::deploy(vault, &vault_config) ); let c = @@ -229,17 +281,32 @@ 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()); + + 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()), + v.storage_deposits(&skim_recipient), + v.storage_deposits(&fee_recipient), ); + v.setup_caps(&vault_owner, &[mkt.id().clone()], u128::MAX) + .await; + SetupEverything { c, protocol_yield_user, insurance_yield_user, + vault: v, + vault_owner, + vault_curator, + vault_guardian, + skim_recipient, + fee_recipient, } }