diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ad7232..8fdc5ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: contracts + # The sandbox tests build the contracts with `cargo near` (near-workspaces compile_project). + - name: Install cargo-near 0.22.0 + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/download/cargo-near-v0.22.0/cargo-near-installer.sh | sh - run: cargo check --workspace --all-targets - run: cargo test --workspace diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2af755a..d80203b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,10 +68,10 @@ Everything in a vault belongs to its owner. There is no split and no shared bala ### 3.2 Factory ```rust pub struct Factory { - admin: AccountId, // can publish new vault code hashes only (see B12) + admin: AccountId, // uploads vault code for FUTURE vaults only; no path to funds (I5) keeper: AccountId, usdc: AccountId, - vault_code: VaultCode, // embedded vault wasm — regular deploy (KD-8) + vault_code: LazyOption>, // vault wasm uploaded once by the admin; regular deploy (KD-8) vaults: LookupMap, created_today: (u64 /*day*/, u32 /*count*/), daily_cap: u32, @@ -79,8 +79,10 @@ pub struct Factory { ``` | Method | Caller | Behaviour | |---|---|---| -| `create_vault_for(owner)` | keeper | One per owner; daily cap; create sub-account; deploy code; `vault.new(owner, keeper, usdc)`; `storage_deposit` on USDC for the vault | -| `get_vault(owner)` | view | | +| `set_vault_code()` | admin | Raw wasm as the call input (not JSON). Emits `vault_code_updated {sha256, size}`. Affects only vaults created afterwards | +| `create_vault_for(owner)` | keeper | One per owner; daily cap. Batch: create `v.` → fund (code storage + 0.1 NEAR) → deploy → `new(owner, keeper, usdc)`. Then `on_vault_deployed`: on failure forget the vault and free the day's slot; on success `storage_deposit` the vault on USDC (registration_only) → `on_vault_registered` emits `vault_created` or `vault_registration_failed` | +| `retry_registration(owner)` | keeper | Re-run the USDC registration for an existing vault | +| `get_vault(owner)` · `get_vault_code_hash()` · `get_config()` | view | Code hash lets anyone compare it with a vault's on-chain code hash | ### 3.3 Vault ```rust @@ -104,9 +106,9 @@ pub struct Vault { | `set_plan(target_asset, destination_address, amount_per_run, interval_s, max_cost_bps)` | owner | **1 yocto** | Validates ranges; emits `plan_updated` | | `resume()` | owner | **1 yocto** | Requires a complete plan | | `pause()` | owner | – | Always available | -| `execute_buy(quote_ref, deposit_address, amount)` | keeper | storage deposit if needed | Requires `!paused`, plan set, `now ≥ last_run_at_s + interval_s`, `amount ≤ amount_per_run`. Sets `last_run_at_s = now`; registers `deposit_address` on USDC if needed **[verify whether 1Click pre-registers]**; `ft_transfer(deposit_address, amount)`; callback: on failure restore `last_run_at_s`, emit `buy_failed` | +| `execute_buy(quote_ref, deposit_address, amount)` | keeper | storage deposit if needed | Requires `!paused`, plan set, `now ≥ last_run_at_s + interval_s`, `amount ≤ amount_per_run`. Sets `last_run_at_s = now`. If the keeper attaches NEAR, first `storage_deposit(deposit_address, registration_only)` with it (refunded if already registered); with no deposit, an unregistered address makes the transfer fail cleanly. Then `ft_transfer(deposit_address, amount)`; callback `on_buy_transfer`: success → `buy_sent`; failure → restore `last_run_at_s`, `buy_failed`. **[verify whether 1Click pre-registers, K0.9]** | | `withdraw(amount)` | owner | **1 yocto** | `ft_transfer(owner, amount)` USDC | -| `recover_token(token, amount)` | owner | **1 yocto** | Send any other NEP-141 to the owner | +| `recover_token(token, amount)` | owner | **1 yocto** | Send any other NEP-141 to the owner; refuses USDC (use `withdraw`) | | `get_state()` | view | – | Plan, paused, `next_run_at_s` | Balance is read with `ft_balance_of(vault)` by the app; the contract doesn't track it. An `execute_buy` larger than the balance simply fails in the USDC contract and the callback restores state. @@ -121,7 +123,12 @@ Balance is read with `ft_balance_of(vault)` by the app; the contract doesn't tra - I7. A new vault is paused with no plan. ### 3.5 Events (NEP-297) -`vault_created`, `deposited`, `plan_updated`, `paused`, `resumed`, `buy_sent`, `buy_failed`, `withdrawn`, `token_recovered`. +Vault (`standard: stack_vault`): `deposited`, `plan_updated`, `paused`, `resumed`, `buy_sent`, `buy_failed`, `withdrawn`, `withdraw_failed`, `token_recovered`. +Factory (`standard: stack_factory`): `vault_code_updated`, `vault_created`, `vault_create_failed`, `vault_registration_failed`. + +Plan limits enforced on-chain: `interval_s ≥ 60`, `0 < max_cost_bps ≤ 5000`, `amount_per_run > 0`, string fields ≤ 128 chars. + +**Callback rule:** a callback awaiting a call that returns data (e.g. `storage_deposit` → StorageBalance) must read `env::promise_result_checked` instead of `#[callback_result] Result<(), _>`, which would panic on deserialization. ### 3.6 Known trust assumption (README) The keeper chooses `deposit_address`. A stolen keeper key could redirect at most one `amount_per_run` per interval per vault until the owner pauses. Off-chain: the worker verifies the quote signature and that its recipient/asset match `get_state()` before calling `execute_buy`. Stretch: verify the signature in the contract. diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index edfef17..8252369 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -18,7 +18,7 @@ Separate repo from **Settle** (track 08). Never import Settle code; README names - **KD-5** No vault upgrades: vaults are immutable; owners withdraw and open a new vault instead - **KD-6** Targets: BTC `nep141:btc.omft.near`, ETH `nep141:eth.omft.near`, NEAR `nep141:wrap.near` - **KD-7** Name: Stack -- **KD-8** Regular deploy: factory embeds the vault wasm (~1.1 NEAR locked per vault). Never a global contract by account +- **KD-8** Regular deploy: admin uploads the vault wasm to the factory once (`set_vault_code`); ~1.5 NEAR locked per 137 KB vault. Never a global contract by account ## ⛔ Pending — don't build until the human decides _None._ @@ -41,9 +41,10 @@ Rust near-sdk =5.30.0 + cargo-near 0.22 + near-workspaces =0.24.0 · Next.js 15. ## Contract rules (non-negotiable) 1. USDC leaves only via `withdraw` (to owner) or `execute_buy` (bounded). -2. `execute_buy`: keeper · not paused · plan set · once per `interval_s` · `amount ≤ amount_per_run` · register deposit address before `ft_transfer` · callback restores `last_run_at_s` on failure. +2. `execute_buy`: keeper · not paused · plan set · once per `interval_s` · `amount ≤ amount_per_run` · keeper attaches ~0.013 NEAR so the deposit address gets registered before `ft_transfer` · callback restores `last_run_at_s` on failure. 3. `set_plan`, `resume`, `withdraw`, `recover_token`: owner + 1 yocto. `pause`: owner, no deposit. -4. `create_vault_for`: keeper only · one per owner · daily cap. +4. `create_vault_for`: keeper only · one per owner · daily cap · vault code uploaded once by the admin (`set_vault_code`, future vaults only) · USDC registration chained after deploy. +6. Callbacks on calls that return data read `env::promise_result_checked`, never `Result<(), _>`. 5. No admin path to funds. Checked math. Events for every state change. ## 1Click rules diff --git a/ROADMAP.md b/ROADMAP.md index b7775dd..dd17c29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,7 @@ |---|---|---|---| | D. Design & docs | Nov 4 | 100% | ✅ done | | 0. Setup & verification | Nov 4–5 | 80% | in progress (K0.9 blocked on K0.8; K0.8, K0.14 👤) | -| 1. Contracts | Nov 5–7 | 0% | in progress (state drafted) | +| 1. Contracts | Nov 5–7 | 92% | in progress (K1.13 deploy is 👤) | | 2. Core logic (`lib/core`) | Nov 6–7 | 0% | in progress (drafted) | | 3. Integrations (1Click, NEAR, DB) | Nov 7–8 | 0% | in progress (schema drafted) | | 4. API routes | Nov 8–9 | 0% | not started | @@ -27,7 +27,7 @@ | 10. README & public repo | Nov 12–13 | 14% | in progress | | 11. Launch: video, post, submission | Nov 13–14 | 0% | not started | | 12. After submission | Nov 14–25 | 0% | not started | -| **Overall (MVP)** | | **~13%** | | +| **Overall (MVP)** | | **~20%** | | Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of all 14 rows (D + phases 0–12). (Earlier entries over-estimated the overall %; recalculated 2026-11-04.) @@ -61,7 +61,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` | KD-5 [#2](https://github.com/MFrancis-dev/stack/issues/2) | Owner-approved vault upgrades in MVP? → **No.** Vaults are immutable; an owner can always withdraw and open a new vault | ✅ decided 2026-11-04 | | KD-6 [#3](https://github.com/MFrancis-dev/stack/issues/3) | MVP target assets → **BTC (native) `nep141:btc.omft.near`, ETH (Ethereum) `nep141:eth.omft.near`, NEAR `nep141:wrap.near`** | ✅ decided 2026-11-04 | | KD-7 [#4](https://github.com/MFrancis-dev/stack/issues/4) | Working name → **"Stack" is final** | ✅ decided 2026-11-04 | -| KD-8 [#106](https://github.com/MFrancis-dev/stack/issues/106) | Vault deploy → **regular deploy** (factory embeds the vault wasm; ~1.1 NEAR locked per vault, recoverable). Never a global contract by account | ✅ decided 2026-11-04 | +| KD-8 [#106](https://github.com/MFrancis-dev/stack/issues/106) | Vault deploy → **regular deploy** (admin uploads the vault wasm to the factory once; ~1.5 NEAR locked per 137 KB vault, recoverable). Never a global contract by account | ✅ decided 2026-11-04 | --- @@ -85,18 +85,18 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Phase 1 — Contracts (Nov 5–7) — **critical path** -- [~] **K1.1** [#20](https://github.com/MFrancis-dev/stack/issues/20) (2026-11-04: `Vault` + `VaultView` structs and `new` written; `get_state` stubbed) Vault state + `new` + `get_state` (paused, no plan) -- [ ] **K1.2** [#21](https://github.com/MFrancis-dev/stack/issues/21) `set_plan` (1 yocto, range checks), `resume` (1 yocto, plan required), `pause` (owner) -- [ ] **K1.3** [#22](https://github.com/MFrancis-dev/stack/issues/22) `ft_on_transfer` (accept any NEP-141, event) -- [ ] **K1.4** [#23](https://github.com/MFrancis-dev/stack/issues/23) `execute_buy`: keeper, bounds, deposit-address storage registration, `ft_transfer`, failure callback restoring `last_run_at_s` -- [ ] **K1.5** [#24](https://github.com/MFrancis-dev/stack/issues/24) `withdraw`, `recover_token` (owner, 1 yocto) -- [ ] **K1.6** [#25](https://github.com/MFrancis-dev/stack/issues/25) NEP-297 events for every state change -- [~] **K1.7** [#26](https://github.com/MFrancis-dev/stack/issues/26) (2026-11-04: `Factory` state, `new`, `get_vault` written; `create_vault_for` stubbed) Factory: `create_vault_for` (keeper, one per owner, daily cap), deploy (global or embedded), USDC storage for the vault, `get_vault` · 2026-11-04: USDC storage registration for the vault is **mandatory**: Intents gives no refund to an unregistered receiver (§12). Deploy method per KD-8 -- [ ] **K1.8** [#27](https://github.com/MFrancis-dev/stack/issues/27) near-workspaces setup with mock USDC -- [ ] **K1.9** [#28](https://github.com/MFrancis-dev/stack/issues/28) Tests: I1–I7 one each -- [ ] **K1.10** [#29](https://github.com/MFrancis-dev/stack/issues/29) Tests: failed transfer callback, insufficient balance, stray token recovery, daily cap, unauthorized callers +- [x] **K1.1** [#20](https://github.com/MFrancis-dev/stack/issues/20) (2026-11-04: `Vault` + `VaultView` structs and `new` written; `get_state` stubbed) Vault state + `new` + `get_state` (paused, no plan) · 2026-11-04: state, `new`, `get_state` (next_run_at_s) + unit tests +- [x] **K1.2** [#21](https://github.com/MFrancis-dev/stack/issues/21) `set_plan` (1 yocto, range checks), `resume` (1 yocto, plan required), `pause` (owner) · 2026-11-04: range checks (interval ≥ 60 s, 0 < cost ≤ 5000 bps), set_plan never unpauses +- [x] **K1.3** [#22](https://github.com/MFrancis-dev/stack/issues/22) `ft_on_transfer` (accept any NEP-141, event) · 2026-11-04: accepts any NEP-141, emits `deposited` +- [x] **K1.4** [#23](https://github.com/MFrancis-dev/stack/issues/23) `execute_buy`: keeper, bounds, deposit-address storage registration, `ft_transfer`, failure callback restoring `last_run_at_s` · 2026-11-04: bounds, optional storage_deposit for the deposit address, `on_buy_transfer` restores schedule on failure +- [x] **K1.5** [#24](https://github.com/MFrancis-dev/stack/issues/24) `withdraw`, `recover_token` (owner, 1 yocto) · 2026-11-04: `withdraw` (always available), `recover_token` (refuses USDC), both with result events +- [x] **K1.6** [#25](https://github.com/MFrancis-dev/stack/issues/25) NEP-297 events for every state change · 2026-11-04: NEP-297 `stack_vault` + `stack_factory` events +- [x] **K1.7** [#26](https://github.com/MFrancis-dev/stack/issues/26) (2026-11-04: `Factory` state, `new`, `get_vault` written; `create_vault_for` stubbed) Factory: `create_vault_for` (keeper, one per owner, daily cap), deploy (global or embedded), USDC storage for the vault, `get_vault` · 2026-11-04: USDC storage registration for the vault is **mandatory**: Intents gives no refund to an unregistered receiver (§12). Deploy method per KD-8 · 2026-11-04: admin uploads code once (`set_vault_code`, future vaults only), keeper `create_vault_for`, rollback on failed deploy, chained USDC registration + `retry_registration` +- [x] **K1.8** [#27](https://github.com/MFrancis-dev/stack/issues/27) near-workspaces setup with mock USDC · 2026-11-04: near-workspaces sandbox + `compile_project` + `mock-ft` crate +- [x] **K1.9** [#28](https://github.com/MFrancis-dev/stack/issues/28) Tests: I1–I7 one each · 2026-11-04: I1–I7 + private callbacks, all passing on the sandbox +- [x] **K1.10** [#29](https://github.com/MFrancis-dev/stack/issues/29) Tests: failed transfer callback, insufficient balance, stray token recovery, daily cap, unauthorized callers · 2026-11-04: deposit-address registration, insufficient balance, factory rules, recovery, withdraw, non-owner — 49 contract tests total (24 vault unit, 9 factory unit, 16 sandbox) - [-] **K1.11** [#30](https://github.com/MFrancis-dev/stack/issues/30) If KD-5 = yes: owner-approved `upgrade_to(hash)` + tests · 2026-11-04: dropped: KD-5 = no upgrades in MVP -- [ ] **K1.12** [#31](https://github.com/MFrancis-dev/stack/issues/31) `contracts/README.md`: methods, build, test +- [x] **K1.12** [#31](https://github.com/MFrancis-dev/stack/issues/31) `contracts/README.md`: methods, build, test · 2026-11-04: `contracts/README.md` build/test/deploy order; method tables in ARCHITECTURE §3 - [ ] **K1.13** [#32](https://github.com/MFrancis-dev/stack/issues/32) 👤 Approve deployment; deploy factory (+ global vault code if used); record account ids ## Phase 2 — Core logic `lib/core` (Nov 6–7) @@ -214,6 +214,7 @@ Format: `- [task id] what blocks it · who can unblock · since date` ## Update log One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-04 · agent · Phase 1 contracts: vault + factory implemented, mock-ft crate, 49 contract tests passing (unit + near-workspaces sandbox); wasm vault 137 KB / factory 146 KB · K1.1–K1.10, K1.12 - 2026-11-04 · agent · Decisions accepted by the human: KD-5 no upgrades, KD-6 BTC/ETH/NEAR, KD-7 name Stack, KD-8 regular deploy; K1.11 dropped; update-log actor made neutral · K0.1, K0.10, K1.11 - 2026-11-04 · agent · CI green on main (web, contracts, gitleaks) · K0.7 - 2026-11-04 · agent · Phase 0 verification: live `/v0/tokens` + dry quotes (cost data), SDK status enum, NEAR docs (global contracts, near-connect); added near-connect + near-api-js; db client + check-env + test; react-server condition for worker/scripts; CI workflow; ARCHITECTURE §12 + ESSENTIALS updated; new decision KD-8 · K0.6, K0.7, K0.9, K0.10, K0.11, K0.12, K0.13, K1.7, K4.5, K6.3, K8.2 diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 23eb4fd..1140202 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -191,6 +191,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bon" +version = "3.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05442cd7005532d0cfedec676b5b9333d2b4c7c3afabe2b1a9ce2742fbe2ee6" +dependencies = [ + "bon-macros", +] + +[[package]] +name = "bon-macros" +version = "3.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93833e97ac7c46ba97045afcf247238c27057538fcf2f06344662ddd5f567be6" +dependencies = [ + "darling 0.24.1", + "ident_case", + "prettyplease 0.3.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "borsh" version = "1.8.1" @@ -257,6 +280,77 @@ dependencies = [ "serde", ] +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-near-build" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663db26ba261f4ca79499cb6847b91b9c40976e0b9da7c01a40fb3f063964967" +dependencies = [ + "bon", + "bs58 0.5.1", + "bytesize", + "camino", + "cargo_metadata", + "colored", + "dunce", + "eyre", + "hex", + "indenter", + "pathdiff", + "rustc_version", + "sha2 0.10.9", + "tracing", +] + +[[package]] +name = "cargo-platform" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84982c6c0ae343635a3a4ee6dedef965513735c8b183caa7289fa6e27399ebd4" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-util-schemas" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dc1a6f7b5651af85774ae5a34b4e8be397d9cf4bc063b7e6dbd99a841837830" +dependencies = [ + "semver", + "serde", + "serde-untagged", + "serde-value", + "thiserror 2.0.21", + "toml", + "unicode-xid", + "url", +] + +[[package]] +name = "cargo_metadata" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cfca2aaa699835ba88faf58a06342a314a950d2b9686165e038286c30316868" +dependencies = [ + "camino", + "cargo-platform", + "cargo-util-schemas", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "cc" version = "1.11.0" @@ -332,6 +426,15 @@ dependencies = [ "cc", ] +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "combine" version = "4.6.9" @@ -866,6 +969,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "erased-serde" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -876,6 +990,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1441,6 +1566,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -1815,7 +1946,7 @@ dependencies = [ "convert_case 0.5.0", "near-abi-client-impl", "near-abi-client-macros", - "prettyplease", + "prettyplease 0.1.25", "quote", "syn 1.0.109", ] @@ -1892,6 +2023,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "near-contract-standards" +version = "5.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea737f770ef1dc8f55325ed2200c554f26c14d65e36c4c1342f6878365f1fd11" +dependencies = [ + "near-sdk", +] + [[package]] name = "near-crypto" version = "0.38.0" @@ -2073,7 +2213,7 @@ dependencies = [ "near-stdx", "near-time", "num-rational", - "ordered-float", + "ordered-float 4.6.0", "primitive-types", "rand 0.8.8", "rand_chacha", @@ -2318,6 +2458,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bs58 0.5.1", + "cargo-near-build", "chrono", "fs2", "json-patch 2.0.0", @@ -2478,6 +2619,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -2525,6 +2675,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +dependencies = [ + "camino", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2602,6 +2761,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.9", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2627,7 +2796,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.18+spec-1.1.0", ] [[package]] @@ -3216,6 +3385,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3227,6 +3400,28 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -3284,6 +3479,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3504,6 +3708,14 @@ dependencies = [ "near-sdk", ] +[[package]] +name = "stack-mock-ft" +version = "0.1.0" +dependencies = [ + "near-contract-standards", + "near-sdk", +] + [[package]] name = "stack-vault" version = "0.1.0" @@ -3806,6 +4018,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -3815,6 +4048,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.18+spec-1.1.0" @@ -3822,9 +4069,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" dependencies = [ "indexmap 2.14.2", - "toml_datetime", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -3833,9 +4080,15 @@ version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -3918,6 +4171,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -4370,6 +4629,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 318785b..f4e83ef 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["factory", "vault", "tests"] +members = ["factory", "vault", "mock-ft", "tests"] [profile.release] codegen-units = 1 diff --git a/contracts/README.md b/contracts/README.md index b1bcc54..03c111c 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -7,6 +7,10 @@ Spec: `../ARCHITECTURE.md` §3 · Roadmap: Phase 1 (K1.x) ```bash -cargo near build # in factory/ and vault/ (K1.x) -cargo test # unit + integration (K1.9–K1.10) +pnpm contracts:build # cargo near build for vault + factory → target/near/ +pnpm contracts:test # unit tests + near-workspaces sandbox tests (builds wasm via the `cargo near` CLI) ``` + +- `mock-ft/` — test-only NEP-141/NEP-145 token standing in for USDC. + +Deploy order (human-approved, K1.13): factory `new(admin, keeper, usdc, daily_cap)` → admin `set_vault_code` with the vault wasm as raw input → keeper `create_vault_for(owner)` per user. diff --git a/contracts/factory/src/deploy.rs b/contracts/factory/src/deploy.rs index 9d5b364..2541f11 100644 --- a/contracts/factory/src/deploy.rs +++ b/contracts/factory/src/deploy.rs @@ -1 +1,48 @@ -//! Sub-account creation + vault code deploy (global contract or embedded wasm). Roadmap: K1.7 +//! Sub-account creation + vault code deploy (regular deploy, KD-8). Roadmap: K1.7 +use crate::*; +use near_sdk::{serde_json::json, PromiseError}; + +impl Factory { + /// create account → fund → deploy → `new(owner, keeper, usdc)` in one batch, then + /// `on_vault_deployed` registers it on USDC or rolls the bookkeeping back. + pub(crate) fn deploy_vault(&mut self, owner: AccountId, vault: AccountId) -> Promise { + let code = self.vault_code.get().clone().unwrap_or_else(|| env::panic_str("Vault code not set")); + let storage = env::storage_byte_cost().saturating_mul(code.len() as u128 + 1_000); + let init_balance = storage.saturating_add(VAULT_BALANCE_BUFFER); + let args = json!({ "owner": owner, "keeper": self.keeper, "usdc": self.usdc }) + .to_string() + .into_bytes(); + + Promise::new(vault.clone()) + .create_account() + .transfer(init_balance) + .deploy_contract(code) + .function_call("new".to_string(), args, NearToken::from_yoctonear(0), GAS_VAULT_INIT) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_CALLBACK.saturating_add(GAS_STORAGE_DEPOSIT)) + .on_vault_deployed(owner, vault), + ) + } +} + +#[near] +impl Factory { + /// If the deploy batch failed: forget the vault and give the day's slot back + /// (the attached balance returns to the factory automatically). + #[private] + pub fn on_vault_deployed( + &mut self, + owner: AccountId, + vault: AccountId, + #[callback_result] result: Result<(), PromiseError>, + ) -> near_sdk::PromiseOrValue { + if result.is_err() { + self.vaults.remove(&owner); + self.created_today.1 = self.created_today.1.saturating_sub(1); + FactoryEvent::VaultCreateFailed { owner, vault }.emit(); + return near_sdk::PromiseOrValue::Value(false); + } + near_sdk::PromiseOrValue::Promise(self.register_vault_on_usdc(owner, vault)) + } +} diff --git a/contracts/factory/src/lib.rs b/contracts/factory/src/lib.rs index 8dbf842..3469d26 100644 --- a/contracts/factory/src/lib.rs +++ b/contracts/factory/src/lib.rs @@ -1,28 +1,55 @@ -//! Stack factory: creates one vault per owner (keeper only), pays storage. -//! Spec: ARCHITECTURE.md §3.2 · Roadmap: K1.7 -//! Status: scaffold — state is final-draft, methods are stubs. +//! Stack factory: creates one vault per owner (keeper only, daily cap) and pays its storage. +//! Spec: ARCHITECTURE.md §3.2 · Roadmap: K1.7 · Decision KD-8 (regular deploy) +//! +//! The admin uploads the vault wasm once (`set_vault_code`). Uploading new code only affects +//! vaults created afterwards; existing vaults are immutable (KD-5). The factory has no path to +//! any vault's funds (I5). -use near_sdk::store::LookupMap; -use near_sdk::{env, near, require, AccountId, BorshStorageKey, PanicOnDefault, Promise}; +use near_sdk::store::{LazyOption, LookupMap}; +use near_sdk::{env, near, require, AccountId, BorshStorageKey, Gas, NearToken, PanicOnDefault, Promise}; mod deploy; mod storage; +#[cfg(test)] +mod tests; + +pub(crate) const GAS_VAULT_INIT: Gas = Gas::from_tgas(20); +pub(crate) const GAS_STORAGE_DEPOSIT: Gas = Gas::from_tgas(10); +pub(crate) const GAS_CALLBACK: Gas = Gas::from_tgas(20); +/// Extra balance for the vault's own state and the 1-yocto deposits its transfers attach. +pub(crate) const VAULT_BALANCE_BUFFER: NearToken = NearToken::from_millinear(100); +/// NEP-145 deposit for registering the vault on USDC; `registration_only` refunds the excess. +pub(crate) const USDC_STORAGE_DEPOSIT: NearToken = NearToken::from_millinear(13); +pub(crate) const SECONDS_PER_DAY: u64 = 86_400; + #[near] #[derive(BorshStorageKey)] enum StorageKey { Vaults, + VaultCode, +} + +#[near(event_json(standard = "stack_factory"))] +pub enum FactoryEvent { + #[event_version("1.0.0")] + VaultCodeUpdated { sha256: String, size: u64 }, + #[event_version("1.0.0")] + VaultCreated { owner: AccountId, vault: AccountId }, + #[event_version("1.0.0")] + VaultCreateFailed { owner: AccountId, vault: AccountId }, + #[event_version("1.0.0")] + VaultRegistrationFailed { owner: AccountId, vault: AccountId }, } #[near(contract_state)] #[derive(PanicOnDefault)] pub struct Factory { - /// Can publish new vault code only. No path to vault funds (I5). + /// Can upload vault code for future vaults only. No path to vault funds (I5). admin: AccountId, keeper: AccountId, usdc: AccountId, - /// Global contract hash if available (🔍 K0.12), else embedded wasm. - vault_code_hash: Option, + vault_code: LazyOption>, vaults: LookupMap, /// (day number, vaults created that day) created_today: (u64, u32), @@ -33,27 +60,73 @@ pub struct Factory { impl Factory { #[init] pub fn new(admin: AccountId, keeper: AccountId, usdc: AccountId, daily_cap: u32) -> Self { + require!(daily_cap > 0, "daily_cap must be positive"); Self { admin, keeper, usdc, - vault_code_hash: None, + vault_code: LazyOption::new(StorageKey::VaultCode, None), vaults: LookupMap::new(StorageKey::Vaults), created_today: (0, 0), daily_cap, } } - /// Keeper only · one vault per owner · daily cap. Creates `v.`, - /// deploys vault code, calls `new(owner, keeper, usdc)`, registers USDC storage for the vault. - #[payable] + /// Admin only. The raw vault wasm is the call's input (not JSON), e.g. + /// `near contract call-function as-transaction set_vault_code file-args vault.wasm …`. + pub fn set_vault_code(&mut self) { + require!(env::predecessor_account_id() == self.admin, "Only the admin"); + let code = env::input().unwrap_or_default(); + require!(!code.is_empty(), "Empty code"); + let sha256 = hex(&env::sha256(&code)); + let size = code.len() as u64; + self.vault_code.set(Some(code)); + FactoryEvent::VaultCodeUpdated { sha256, size }.emit(); + } + + /// Keeper only · one vault per owner · daily cap. The factory pays for the account, + /// code storage and USDC registration. A new vault is paused with no plan. pub fn create_vault_for(&mut self, owner: AccountId) -> Promise { require!(env::predecessor_account_id() == self.keeper, "Only the keeper"); require!(self.vaults.get(&owner).is_none(), "Vault already exists"); - todo!("K1.7") + require!(self.vault_code.get().is_some(), "Vault code not set"); + + let day = env::block_timestamp() / 1_000_000_000 / SECONDS_PER_DAY; + if self.created_today.0 != day { + self.created_today = (day, 0); + } + require!(self.created_today.1 < self.daily_cap, "Daily vault cap reached"); + self.created_today.1 += 1; + + let vault = self.vault_account_for(&owner); + self.vaults.insert(owner.clone(), vault.clone()); + self.deploy_vault(owner, vault) } pub fn get_vault(&self, owner: AccountId) -> Option { self.vaults.get(&owner).cloned() } + + /// sha256 (hex) of the code new vaults get, so anyone can compare it with a vault's code hash. + pub fn get_vault_code_hash(&self) -> Option { + self.vault_code.get().as_ref().map(|c| hex(&env::sha256(c))) + } + + pub fn get_config(&self) -> (AccountId, AccountId, AccountId, u32) { + (self.admin.clone(), self.keeper.clone(), self.usdc.clone(), self.daily_cap) + } +} + +impl Factory { + /// `v.` — short enough for any owner id. + pub(crate) fn vault_account_for(&self, owner: &AccountId) -> AccountId { + let digest = hex(&env::sha256(owner.as_bytes())); + format!("v{}.{}", &digest[..16], env::current_account_id()) + .parse() + .unwrap_or_else(|_| env::panic_str("Invalid vault account id")) + } +} + +pub(crate) fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() } diff --git a/contracts/factory/src/storage.rs b/contracts/factory/src/storage.rs index f010ed5..02a7528 100644 --- a/contracts/factory/src/storage.rs +++ b/contracts/factory/src/storage.rs @@ -1 +1,47 @@ -//! USDC `storage_deposit` for new vaults. Roadmap: K1.7 +//! USDC `storage_deposit` for new vaults. Mandatory: Intents gives no refund to an +//! unregistered NEAR receiver (ARCHITECTURE §12). Roadmap: K1.7 +use crate::*; +use near_sdk::ext_contract; + +#[allow(dead_code)] +#[ext_contract(ext_ft)] +trait StorageManagement { + fn storage_deposit(&mut self, account_id: Option, registration_only: Option); +} + +impl Factory { + pub(crate) fn register_vault_on_usdc(&self, owner: AccountId, vault: AccountId) -> Promise { + ext_ft::ext(self.usdc.clone()) + .with_attached_deposit(USDC_STORAGE_DEPOSIT) + .with_static_gas(GAS_STORAGE_DEPOSIT) + .storage_deposit(Some(vault.clone()), Some(true)) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_CALLBACK) + .on_vault_registered(owner, vault), + ) + } +} + +#[near] +impl Factory { + /// `storage_deposit` returns a StorageBalance object, so read the raw result instead of + /// deserializing it into `()` (which would panic). + #[private] + pub fn on_vault_registered(&mut self, owner: AccountId, vault: AccountId) -> bool { + if env::promise_result_checked(0, 1_024).is_ok() { + FactoryEvent::VaultCreated { owner, vault }.emit(); + true + } else { + FactoryEvent::VaultRegistrationFailed { owner, vault }.emit(); + false + } + } + + /// Keeper only: retry USDC registration for a vault whose registration failed. + pub fn retry_registration(&mut self, owner: AccountId) -> Promise { + require!(env::predecessor_account_id() == self.keeper, "Only the keeper"); + let vault = self.vaults.get(&owner).cloned().unwrap_or_else(|| env::panic_str("No vault")); + self.register_vault_on_usdc(owner, vault) + } +} diff --git a/contracts/factory/src/tests.rs b/contracts/factory/src/tests.rs new file mode 100644 index 0000000..4e87f3c --- /dev/null +++ b/contracts/factory/src/tests.rs @@ -0,0 +1,114 @@ +//! Unit tests for factory rules. Real account creation is tested in contracts/tests. +use super::*; +use near_sdk::test_utils::VMContextBuilder; +use near_sdk::testing_env; + +const DAY: u64 = 86_400 * 1_000_000_000; + +fn id(s: &str) -> AccountId { + s.parse().unwrap() +} + +fn ctx(predecessor: &str, input: Vec, ts: u64) { + let mut b = VMContextBuilder::new(); + b.current_account_id(id("factory.stack.near")) + .predecessor_account_id(id(predecessor)) + .account_balance(NearToken::from_near(100)) + .block_timestamp(ts); + let mut c = b.build(); + c.input = input.into(); + testing_env!(c); +} + +fn factory(cap: u32) -> Factory { + ctx("factory.stack.near", vec![], DAY); + let mut f = Factory::new(id("admin.near"), id("keeper.stack.near"), id("usdc.near"), cap); + ctx("admin.near", vec![0, 97, 115, 109], DAY); + f.set_vault_code(); + f +} + +#[test] +#[should_panic(expected = "Only the admin")] +fn only_admin_sets_code() { + let mut f = factory(5); + ctx("keeper.stack.near", vec![1, 2, 3], DAY); + f.set_vault_code(); +} + +#[test] +fn code_hash_is_exposed() { + let f = factory(5); + assert_eq!(f.get_vault_code_hash().unwrap().len(), 64); +} + +#[test] +#[should_panic(expected = "Only the keeper")] +fn only_keeper_creates() { + let mut f = factory(5); + ctx("alice.near", vec![], DAY); + let _ = f.create_vault_for(id("alice.near")); +} + +#[test] +#[should_panic(expected = "Vault code not set")] +fn needs_code_first() { + ctx("factory.stack.near", vec![], DAY); + let mut f = Factory::new(id("admin.near"), id("keeper.stack.near"), id("usdc.near"), 5); + ctx("keeper.stack.near", vec![], DAY); + let _ = f.create_vault_for(id("alice.near")); +} + +#[test] +#[should_panic(expected = "Vault already exists")] +fn one_vault_per_owner() { + let mut f = factory(5); + ctx("keeper.stack.near", vec![], DAY); + let _ = f.create_vault_for(id("alice.near")); + let _ = f.create_vault_for(id("alice.near")); +} + +#[test] +#[should_panic(expected = "Daily vault cap reached")] +fn daily_cap_enforced() { + let mut f = factory(2); + ctx("keeper.stack.near", vec![], DAY); + let _ = f.create_vault_for(id("a.near")); + let _ = f.create_vault_for(id("b.near")); + let _ = f.create_vault_for(id("c.near")); +} + +#[test] +fn daily_cap_resets_next_day() { + let mut f = factory(1); + ctx("keeper.stack.near", vec![], DAY); + let _ = f.create_vault_for(id("a.near")); + ctx("keeper.stack.near", vec![], 2 * DAY); + let _ = f.create_vault_for(id("b.near")); + assert!(f.get_vault(id("b.near")).is_some()); +} + +#[test] +fn vault_ids_are_short_and_deterministic() { + let f = factory(5); + let long_owner = id("0000000000000000000000000000000000000000000000000000000000000abc"); + let v = f.vault_account_for(&long_owner); + assert!(v.as_str().starts_with('v')); + assert!(v.as_str().ends_with(".factory.stack.near")); + assert_eq!(v.as_str().split('.').next().unwrap().len(), 17); + assert_eq!(v, f.vault_account_for(&long_owner)); +} + +#[test] +fn failed_deploy_rolls_back() { + let mut f = factory(1); + ctx("keeper.stack.near", vec![], DAY); + let _ = f.create_vault_for(id("a.near")); + let vault = f.get_vault(id("a.near")).unwrap(); + ctx("factory.stack.near", vec![], DAY); + let _ = f.on_vault_deployed(id("a.near"), vault, Err(near_sdk::PromiseError::Failed)); + assert!(f.get_vault(id("a.near")).is_none()); + // slot returned: another vault fits under cap 1 + ctx("keeper.stack.near", vec![], DAY); + let _ = f.create_vault_for(id("b.near")); +} diff --git a/contracts/mock-ft/Cargo.toml b/contracts/mock-ft/Cargo.toml new file mode 100644 index 0000000..3dffc4f --- /dev/null +++ b/contracts/mock-ft/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "stack-mock-ft" +version = "0.1.0" +edition = "2021" +publish = false +description = "NEP-141/NEP-145 token used only by the integration tests (stands in for USDC)." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-sdk = "=5.30.0" +near-contract-standards = "=5.30.0" + +[dev-dependencies] +near-sdk = { version = "=5.30.0", features = ["unit-testing"] } diff --git a/contracts/mock-ft/src/lib.rs b/contracts/mock-ft/src/lib.rs new file mode 100644 index 0000000..892419d --- /dev/null +++ b/contracts/mock-ft/src/lib.rs @@ -0,0 +1,84 @@ +//! Test-only NEP-141 token with NEP-145 storage management, standing in for USDC. +use near_contract_standards::fungible_token::{FungibleToken, FungibleTokenCore, FungibleTokenResolver}; +use near_contract_standards::storage_management::{StorageBalance, StorageBalanceBounds, StorageManagement}; +use near_sdk::json_types::U128; +use near_sdk::{near, AccountId, BorshStorageKey, NearToken, PanicOnDefault, PromiseOrValue}; + +#[near] +#[derive(BorshStorageKey)] +enum StorageKey { + Ft, +} + +#[near(contract_state)] +#[derive(PanicOnDefault)] +pub struct MockFt { + token: FungibleToken, +} + +#[near] +impl MockFt { + #[init] + pub fn new(owner_id: AccountId, total_supply: U128) -> Self { + let mut token = FungibleToken::new(StorageKey::Ft); + token.internal_register_account(&owner_id); + token.internal_deposit(&owner_id, total_supply.0); + Self { token } + } +} + +#[near] +impl FungibleTokenCore for MockFt { + #[payable] + fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option) { + self.token.ft_transfer(receiver_id, amount, memo) + } + + #[payable] + fn ft_transfer_call(&mut self, receiver_id: AccountId, amount: U128, memo: Option, msg: String) -> PromiseOrValue { + self.token.ft_transfer_call(receiver_id, amount, memo, msg) + } + + fn ft_total_supply(&self) -> U128 { + self.token.ft_total_supply() + } + + fn ft_balance_of(&self, account_id: AccountId) -> U128 { + self.token.ft_balance_of(account_id) + } +} + +#[near] +impl FungibleTokenResolver for MockFt { + #[private] + fn ft_resolve_transfer(&mut self, sender_id: AccountId, receiver_id: AccountId, amount: U128) -> U128 { + let (used, _burned) = self.token.internal_ft_resolve_transfer(&sender_id, receiver_id, amount); + used.into() + } +} + +#[near] +impl StorageManagement for MockFt { + #[payable] + fn storage_deposit(&mut self, account_id: Option, registration_only: Option) -> StorageBalance { + self.token.storage_deposit(account_id, registration_only) + } + + #[payable] + fn storage_withdraw(&mut self, amount: Option) -> StorageBalance { + self.token.storage_withdraw(amount) + } + + #[payable] + fn storage_unregister(&mut self, force: Option) -> bool { + self.token.internal_storage_unregister(force).is_some() + } + + fn storage_balance_bounds(&self) -> StorageBalanceBounds { + self.token.storage_balance_bounds() + } + + fn storage_balance_of(&self, account_id: AccountId) -> Option { + self.token.storage_balance_of(account_id) + } +} diff --git a/contracts/tests/Cargo.toml b/contracts/tests/Cargo.toml index faceb44..26856fe 100644 --- a/contracts/tests/Cargo.toml +++ b/contracts/tests/Cargo.toml @@ -5,7 +5,9 @@ edition = "2021" publish = false [dev-dependencies] -near-workspaces = "=0.24.0" +# `unstable` enables compile_project, which builds the contracts by running the `cargo near` CLI +# (installed locally and in CI: cargo-near 0.22.0). +near-workspaces = { version = "=0.24.0", features = ["unstable"] } tokio = { version = "1", features = ["full"] } anyhow = "1" serde_json = "1" diff --git a/contracts/tests/src/buy.rs b/contracts/tests/src/buy.rs index a1ddce9..36ff621 100644 --- a/contracts/tests/src/buy.rs +++ b/contracts/tests/src/buy.rs @@ -1,13 +1,43 @@ -//! Roadmap: K1.10 +//! Buy edge cases. Roadmap: K1.10 +use crate::setup::*; +use serde_json::json; #[tokio::test] -#[ignore = "K1.10: not implemented"] async fn registers_deposit_address_before_transfer() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + env.fund_vault(500_000_000).await?; + env.set_plan_and_resume(PER_RUN).await?; + let dep = env.deposit_account("dep1").await?; + let before: serde_json::Value = env + .usdc + .view("storage_balance_of") + .args_json(json!({ "account_id": dep.id() })) + .await? + .json()?; + assert_eq!(before, json!(null)); + + assert_ok(&env.buy("q1", dep.id(), PER_RUN, STORAGE_FOR_DEPOSIT_ADDR).await?); + let after: serde_json::Value = env + .usdc + .view("storage_balance_of") + .args_json(json!({ "account_id": dep.id() })) + .await? + .json()?; + assert_ne!(after, json!(null)); + assert_eq!(balance(&env.usdc, dep.id()).await?, PER_RUN); + Ok(()) } #[tokio::test] -#[ignore = "K1.10: not implemented"] async fn insufficient_balance_fails_cleanly() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + env.fund_vault(50_000_000).await?; // less than one run + env.set_plan_and_resume(PER_RUN).await?; + let dep = env.deposit_account("dep1").await?; + + let r = env.buy("q1", dep.id(), PER_RUN, STORAGE_FOR_DEPOSIT_ADDR).await?; + assert!(logs_contain(&r, "\"event\":\"buy_failed\""), "logs: {:?}", r.logs()); + assert_eq!(env.state().await?["last_run_at_s"], json!(0)); + assert_eq!(balance(&env.usdc, &env.vault_id).await?, 50_000_000); + Ok(()) } diff --git a/contracts/tests/src/factory.rs b/contracts/tests/src/factory.rs index 74cbc85..fc39191 100644 --- a/contracts/tests/src/factory.rs +++ b/contracts/tests/src/factory.rs @@ -1,19 +1,45 @@ -//! Roadmap: K1.10 +//! Factory behaviour on a real sandbox. Roadmap: K1.10 +use crate::setup::*; +use serde_json::json; #[tokio::test] -#[ignore = "K1.10: not implemented"] -async fn one_vault_per_owner() -> anyhow::Result<()> { - todo!() +async fn vault_is_created_and_registered_on_usdc() -> anyhow::Result<()> { + let env = setup().await?; + let reg: serde_json::Value = env + .usdc + .view("storage_balance_of") + .args_json(json!({ "account_id": env.vault_id })) + .await? + .json()?; + assert_ne!(reg, json!(null), "vault must be registered on USDC (no refund otherwise)"); + assert!(env.vault_id.as_str().ends_with(env.factory.id().as_str())); + Ok(()) } #[tokio::test] -#[ignore = "K1.10: not implemented"] -async fn daily_cap_enforced() -> anyhow::Result<()> { - todo!() +async fn one_vault_per_owner() -> anyhow::Result<()> { + let env = setup().await?; + let r = env + .keeper + .call(env.factory.id(), "create_vault_for") + .args_json(json!({ "owner": env.alice.id() })) + .gas(TGAS_300) + .transact() + .await?; + assert!(r.is_failure()); + Ok(()) } #[tokio::test] -#[ignore = "K1.10: not implemented"] async fn only_keeper_creates() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + let r = env + .alice + .call(env.factory.id(), "create_vault_for") + .args_json(json!({ "owner": env.funder.id() })) + .gas(TGAS_300) + .transact() + .await?; + assert!(r.is_failure()); + Ok(()) } diff --git a/contracts/tests/src/invariants.rs b/contracts/tests/src/invariants.rs index 478f6dc..49ee5ad 100644 --- a/contracts/tests/src/invariants.rs +++ b/contracts/tests/src/invariants.rs @@ -1,43 +1,163 @@ -//! Roadmap: K1.9 +//! One test per invariant I1–I7 (ARCHITECTURE.md §3.4), on a local sandbox. Roadmap: K1.9 +use crate::setup::*; +use near_workspaces::types::NearToken; +use serde_json::json; #[tokio::test] -#[ignore = "K1.9: not implemented"] async fn i1_usdc_leaves_only_to_owner_or_bounded_buy() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + env.fund_vault(500_000_000).await?; + env.set_plan_and_resume(PER_RUN).await?; + let dep = env.deposit_account("dep1").await?; + + // Bounded buy: exactly amount moves to the deposit address. + let r = env.buy("q1", dep.id(), PER_RUN, STORAGE_FOR_DEPOSIT_ADDR).await?; + assert_ok(&r); + assert!(logs_contain(&r, "\"event\":\"buy_sent\"")); + assert_eq!(balance(&env.usdc, dep.id()).await?, PER_RUN); + + // Owner withdraw: the rest goes to alice and nowhere else. + let r = env + .alice + .call(&env.vault_id, "withdraw") + .args_json(json!({ "amount": "400000000" })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await?; + assert_ok(&r); + assert_eq!(balance(&env.usdc, env.alice.id()).await?, 400_000_000); + assert_eq!(balance(&env.usdc, &env.vault_id).await?, 0); + Ok(()) } #[tokio::test] -#[ignore = "K1.9: not implemented"] async fn i2_one_buy_per_interval_within_amount() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + env.fund_vault(500_000_000).await?; + env.set_plan_and_resume(PER_RUN).await?; + let dep = env.deposit_account("dep1").await?; + + let too_big = env.buy("q0", dep.id(), PER_RUN + 1, STORAGE_FOR_DEPOSIT_ADDR).await?; + assert!(too_big.is_failure()); + + assert_ok(&env.buy("q1", dep.id(), PER_RUN, STORAGE_FOR_DEPOSIT_ADDR).await?); + let again = env.buy("q2", dep.id(), PER_RUN, NearToken::from_yoctonear(0)).await?; + assert!(again.is_failure(), "second buy in the same interval must fail"); + assert_eq!(balance(&env.usdc, dep.id()).await?, PER_RUN); + Ok(()) } #[tokio::test] -#[ignore = "K1.9: not implemented"] async fn i3_no_buy_when_paused_or_without_plan() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + env.fund_vault(500_000_000).await?; + let dep = env.deposit_account("dep1").await?; + + // No plan yet (and paused at creation). + assert!(env.buy("q1", dep.id(), 1, STORAGE_FOR_DEPOSIT_ADDR).await?.is_failure()); + + env.set_plan_and_resume(PER_RUN).await?; + assert_ok(&env.alice.call(&env.vault_id, "pause").transact().await?); + assert!(env.buy("q2", dep.id(), 1, STORAGE_FOR_DEPOSIT_ADDR).await?.is_failure()); + assert_eq!(balance(&env.usdc, dep.id()).await?, 0); + Ok(()) } #[tokio::test] -#[ignore = "K1.9: not implemented"] async fn i4_owner_methods_need_one_yocto_except_pause() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + let no_yocto = env + .alice + .call(&env.vault_id, "set_plan") + .args_json(json!({ "target_asset": "a", "destination_address": "b", "amount_per_run": "1", "interval_s": 3600, "max_cost_bps": 100 })) + .transact() + .await?; + assert!(no_yocto.is_failure()); + assert!(env.alice.call(&env.vault_id, "resume").transact().await?.is_failure()); + assert!(env + .alice + .call(&env.vault_id, "withdraw") + .args_json(json!({ "amount": "1" })) + .transact() + .await? + .is_failure()); + assert_ok(&env.alice.call(&env.vault_id, "pause").transact().await?); + Ok(()) } #[tokio::test] -#[ignore = "K1.9: not implemented"] async fn i5_admin_and_keeper_cannot_take_funds() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + env.fund_vault(500_000_000).await?; + for who in [&env.admin, &env.keeper] { + let r = who + .call(&env.vault_id, "withdraw") + .args_json(json!({ "amount": "1" })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await?; + assert!(r.is_failure(), "{} must not withdraw", who.id()); + let r = who + .call(&env.vault_id, "recover_token") + .args_json(json!({ "token": env.usdc.id(), "amount": "1" })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await?; + assert!(r.is_failure()); + } + assert_eq!(balance(&env.usdc, &env.vault_id).await?, 500_000_000); + Ok(()) } #[tokio::test] -#[ignore = "K1.9: not implemented"] async fn i6_failed_transfer_keeps_vault_runnable() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + env.fund_vault(500_000_000).await?; + env.set_plan_and_resume(PER_RUN).await?; + let dep = env.deposit_account("dep1").await?; + + // No storage deposit attached + unregistered receiver → ft_transfer fails. + let r = env.buy("q1", dep.id(), PER_RUN, NearToken::from_yoctonear(0)).await?; + assert!(logs_contain(&r, "\"event\":\"buy_failed\""), "logs: {:?}", r.logs()); + assert_eq!(env.state().await?["last_run_at_s"], json!(0)); + assert_eq!(balance(&env.usdc, &env.vault_id).await?, 500_000_000); + + // Same interval, now with registration: allowed because the schedule was restored. + assert_ok(&env.buy("q2", dep.id(), PER_RUN, STORAGE_FOR_DEPOSIT_ADDR).await?); + assert_eq!(balance(&env.usdc, dep.id()).await?, PER_RUN); + Ok(()) } #[tokio::test] -#[ignore = "K1.9: not implemented"] async fn i7_new_vault_is_paused_without_plan() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + let s = env.state().await?; + assert_eq!(s["paused"], json!(true)); + assert_eq!(s["target_asset"], json!(null)); + assert_eq!(s["destination_address"], json!(null)); + assert_eq!(s["owner"], json!(env.alice.id())); + Ok(()) +} + +#[tokio::test] +async fn callbacks_are_private() -> anyhow::Result<()> { + let env = setup().await?; + let r = env + .keeper + .call(&env.vault_id, "on_buy_transfer") + .args_json(json!({ "quote_ref": "x", "deposit_address": env.keeper.id(), "amount": "1", "previous_last_run_at_s": 0 })) + .transact() + .await?; + assert!(r.is_failure()); + let r = env + .keeper + .call(env.factory.id(), "on_vault_registered") + .args_json(json!({ "owner": env.alice.id(), "vault": env.vault_id })) + .transact() + .await?; + assert!(r.is_failure()); + Ok(()) } diff --git a/contracts/tests/src/owner.rs b/contracts/tests/src/owner.rs index 66d23b5..13d7425 100644 --- a/contracts/tests/src/owner.rs +++ b/contracts/tests/src/owner.rs @@ -1,13 +1,37 @@ -//! Roadmap: K1.10 +//! Owner fund movements. Roadmap: K1.10 +use crate::setup::*; +use near_workspaces::types::NearToken; +use serde_json::json; #[tokio::test] -#[ignore = "K1.10: not implemented"] -async fn withdraw_sends_to_owner() -> anyhow::Result<()> { - todo!() +async fn withdraw_sends_to_owner_even_when_paused() -> anyhow::Result<()> { + let env = setup().await?; + env.fund_vault(250_000_000).await?; + let r = env + .alice + .call(&env.vault_id, "withdraw") + .args_json(json!({ "amount": "250000000" })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await?; + assert_ok(&r); + assert!(logs_contain(&r, "\"event\":\"withdrawn\"")); + assert_eq!(balance(&env.usdc, env.alice.id()).await?, 250_000_000); + Ok(()) } #[tokio::test] -#[ignore = "K1.10: not implemented"] async fn non_owner_rejected() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + let r = env + .funder + .call(&env.vault_id, "set_plan") + .args_json(json!({ "target_asset": "a", "destination_address": "b", "amount_per_run": "1", "interval_s": 3600, "max_cost_bps": 100 })) + .deposit(NearToken::from_yoctonear(1)) + .transact() + .await?; + assert!(r.is_failure()); + assert!(env.funder.call(&env.vault_id, "pause").transact().await?.is_failure()); + Ok(()) } diff --git a/contracts/tests/src/recover.rs b/contracts/tests/src/recover.rs index 783225a..fe618a1 100644 --- a/contracts/tests/src/recover.rs +++ b/contracts/tests/src/recover.rs @@ -1,7 +1,37 @@ -//! Roadmap: K1.10 +//! Stray tokens can be recovered by the owner only. Roadmap: K1.10 +use crate::setup::*; +use near_workspaces::types::NearToken; +use serde_json::json; #[tokio::test] -#[ignore = "K1.10: not implemented"] async fn stray_token_recoverable_by_owner() -> anyhow::Result<()> { - todo!() + let env = setup().await?; + let w = wasm().await?; + let root = env.worker.root_account()?; + let other_acc = sub(&root, "other", 10).await?; + let other = other_acc.deploy(&w.ft).await?.into_result()?; + assert_ok(&other.call("new").args_json(json!({ "owner_id": env.funder.id(), "total_supply": "1000" })).transact().await?); + register(&other, &env.funder, &env.vault_id).await?; + register(&other, &env.funder, env.alice.id()).await?; + assert_ok( + &env.funder + .call(other.id(), "ft_transfer") + .args_json(json!({ "receiver_id": env.vault_id, "amount": "400" })) + .deposit(NearToken::from_yoctonear(1)) + .transact() + .await?, + ); + + let r = env + .alice + .call(&env.vault_id, "recover_token") + .args_json(json!({ "token": other.id(), "amount": "400" })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await?; + assert_ok(&r); + assert!(logs_contain(&r, "\"event\":\"token_recovered\"")); + assert_eq!(balance(&other, env.alice.id()).await?, 400); + Ok(()) } diff --git a/contracts/tests/src/setup.rs b/contracts/tests/src/setup.rs index 65aab9a..402f490 100644 --- a/contracts/tests/src/setup.rs +++ b/contracts/tests/src/setup.rs @@ -1 +1,181 @@ -//! Deploy mock USDC (res/mock_ft.wasm), factory, and one vault. Roadmap: K1.8 +//! Shared sandbox setup: mock USDC, factory with vault code, one vault for alice. Roadmap: K1.8 +use near_workspaces::network::Sandbox; +use near_workspaces::result::ExecutionFinalResult; +use near_workspaces::types::{Gas, NearToken}; +use near_workspaces::{Account, Contract, Worker}; +use serde_json::json; +use std::sync::OnceLock; +use tokio::sync::Mutex; + +pub const USDC_SUPPLY: u128 = 1_000_000_000_000; // 1M USDC (6 decimals) +pub const PER_RUN: u128 = 100_000_000; // 100 USDC +pub const TGAS_300: Gas = Gas::from_tgas(300); +pub const STORAGE_FOR_DEPOSIT_ADDR: NearToken = NearToken::from_millinear(13); + +pub struct Wasm { + pub vault: Vec, + pub factory: Vec, + pub ft: Vec, +} + +static WASM: OnceLock = OnceLock::new(); +static BUILD_LOCK: Mutex<()> = Mutex::const_new(()); + +/// Builds each contract once per test binary. +pub async fn wasm() -> anyhow::Result<&'static Wasm> { + let _guard = BUILD_LOCK.lock().await; + if let Some(w) = WASM.get() { + return Ok(w); + } + let w = Wasm { + vault: near_workspaces::compile_project("../vault").await?, + factory: near_workspaces::compile_project("../factory").await?, + ft: near_workspaces::compile_project("../mock-ft").await?, + }; + Ok(WASM.get_or_init(|| w)) +} + +pub struct Env { + pub worker: Worker, + pub usdc: Contract, + pub factory: Contract, + pub admin: Account, + pub keeper: Account, + pub alice: Account, + pub funder: Account, + pub vault: Account, // handle to call the vault as a contract id + pub vault_id: near_workspaces::AccountId, +} + +pub async fn sub(root: &Account, name: &str, near: u128) -> anyhow::Result { + Ok(root + .create_subaccount(name) + .initial_balance(NearToken::from_near(near)) + .transact() + .await? + .into_result()?) +} + +pub fn assert_ok(r: &ExecutionFinalResult) { + assert!(r.is_success(), "expected success, got {:#?}", r.clone().into_result().err()); +} + +pub fn logs_contain(r: &ExecutionFinalResult, needle: &str) -> bool { + r.logs().iter().any(|l| l.contains(needle)) +} + +pub async fn register(usdc: &Contract, who: &Account, account_id: &near_workspaces::AccountId) -> anyhow::Result<()> { + let r = who + .call(usdc.id(), "storage_deposit") + .args_json(json!({ "account_id": account_id, "registration_only": true })) + .deposit(NearToken::from_millinear(13)) + .transact() + .await?; + assert_ok(&r); + Ok(()) +} + +pub async fn balance(usdc: &Contract, id: &near_workspaces::AccountId) -> anyhow::Result { + let v: String = usdc.view("ft_balance_of").args_json(json!({ "account_id": id })).await?.json()?; + Ok(v.parse()?) +} + +/// Deploys everything and creates alice's vault through the factory, like production. +pub async fn setup() -> anyhow::Result { + let w = wasm().await?; + let worker = near_workspaces::sandbox().await?; + let root = worker.root_account()?; + + let admin = sub(&root, "admin", 10).await?; + let keeper = sub(&root, "keeper", 10).await?; + let alice = sub(&root, "alice", 10).await?; + let funder = sub(&root, "funder", 10).await?; + + let usdc_acc = sub(&root, "usdc", 20).await?; + let usdc = usdc_acc.deploy(&w.ft).await?.into_result()?; + assert_ok(&usdc.call("new").args_json(json!({ "owner_id": funder.id(), "total_supply": USDC_SUPPLY.to_string() })).transact().await?); + + let factory_acc = sub(&root, "factory", 50).await?; + let factory = factory_acc.deploy(&w.factory).await?.into_result()?; + assert_ok( + &factory + .call("new") + .args_json(json!({ "admin": admin.id(), "keeper": keeper.id(), "usdc": usdc.id(), "daily_cap": 10 })) + .transact() + .await?, + ); + assert_ok(&admin.call(factory.id(), "set_vault_code").args(w.vault.clone()).max_gas().transact().await?); + + let r = keeper + .call(factory.id(), "create_vault_for") + .args_json(json!({ "owner": alice.id() })) + .gas(TGAS_300) + .transact() + .await?; + assert_ok(&r); + assert!(logs_contain(&r, "\"event\":\"vault_created\""), "logs: {:?}", r.logs()); + + let vault_id: near_workspaces::AccountId = factory + .view("get_vault") + .args_json(json!({ "owner": alice.id() })) + .await? + .json::>()? + .expect("vault recorded"); + let vault = Account::from_secret_key(vault_id.clone(), near_workspaces::types::SecretKey::from_random(near_workspaces::types::KeyType::ED25519), &worker); + + register(&usdc, &alice, alice.id()).await?; + Ok(Env { worker, usdc, factory, admin, keeper, alice, funder, vault, vault_id }) +} + +impl Env { + pub async fn fund_vault(&self, amount: u128) -> anyhow::Result<()> { + let r = self + .funder + .call(self.usdc.id(), "ft_transfer") + .args_json(json!({ "receiver_id": self.vault_id, "amount": amount.to_string() })) + .deposit(NearToken::from_yoctonear(1)) + .transact() + .await?; + assert_ok(&r); + Ok(()) + } + + pub async fn set_plan_and_resume(&self, per_run: u128) -> anyhow::Result<()> { + let r = self + .alice + .call(&self.vault_id, "set_plan") + .args_json(json!({ + "target_asset": "nep141:btc.omft.near", + "destination_address": "bc1qexample", + "amount_per_run": per_run.to_string(), + "interval_s": 3600, + "max_cost_bps": 300 + })) + .deposit(NearToken::from_yoctonear(1)) + .transact() + .await?; + assert_ok(&r); + let r = self.alice.call(&self.vault_id, "resume").deposit(NearToken::from_yoctonear(1)).transact().await?; + assert_ok(&r); + Ok(()) + } + + pub async fn deposit_account(&self, name: &str) -> anyhow::Result { + sub(&self.worker.root_account()?, name, 2).await + } + + pub async fn buy(&self, quote_ref: &str, to: &near_workspaces::AccountId, amount: u128, storage: NearToken) -> anyhow::Result { + Ok(self + .keeper + .call(&self.vault_id, "execute_buy") + .args_json(json!({ "quote_ref": quote_ref, "deposit_address": to, "amount": amount.to_string() })) + .deposit(storage) + .gas(TGAS_300) + .transact() + .await?) + } + + pub async fn state(&self) -> anyhow::Result { + Ok(self.vault.view(&self.vault_id, "get_state").await?.json()?) + } +} diff --git a/contracts/vault/src/buy.rs b/contracts/vault/src/buy.rs index 1874f84..9318185 100644 --- a/contracts/vault/src/buy.rs +++ b/contracts/vault/src/buy.rs @@ -1,28 +1,78 @@ //! Bounded scheduled buys. Roadmap: K1.4 · Invariants: I1, I2, I3, I6 +use crate::ft::ext_ft; use crate::*; use near_sdk::{Promise, PromiseError}; #[near] impl Vault { - /// Keeper only. Requires !paused, a plan, `now >= last_run_at_s + interval_s`, - /// `amount <= amount_per_run`. Registers `deposit_address` on USDC if needed - /// (🔍 K0.9), then `ft_transfer`s USDC to it. + /// Keeper only. Requires: not paused, a plan, `now >= last_run_at_s + interval_s`, + /// `0 < amount <= amount_per_run`. + /// + /// If the keeper attaches NEAR, it is used to storage-register `deposit_address` on USDC + /// first (registration_only, so an already-registered address gets it refunded). An + /// unregistered receiver would make the transfer fail. + /// + /// Sends `amount` USDC to `deposit_address`. If the transfer fails, the callback restores + /// `last_run_at_s` so the vault can run again at the next interval (I6). #[payable] pub fn execute_buy(&mut self, quote_ref: String, deposit_address: AccountId, amount: U128) -> Promise { self.assert_keeper(); - let _ = (quote_ref, deposit_address, amount); - todo!("K1.4") + require!(!self.paused, "Vault is paused"); + require!(self.has_plan(), "No plan"); + require!( + !quote_ref.is_empty() && quote_ref.len() <= MAX_FIELD_LEN, + "Invalid quote_ref" + ); + require!(amount.0 > 0 && amount.0 <= self.amount_per_run, "Amount above amount_per_run"); + let now = Self::now_s(); + require!( + now >= self.last_run_at_s.saturating_add(self.interval_s), + "Too early for the next buy" + ); + + let previous_last_run_at_s = self.last_run_at_s; + self.last_run_at_s = now; + + let transfer = ext_ft::ext(self.usdc.clone()) + .with_attached_deposit(ONE_YOCTO) + .with_static_gas(GAS_FT_TRANSFER) + .ft_transfer(deposit_address.clone(), amount, Some(format!("stack buy {quote_ref}"))); + + let deposit = env::attached_deposit(); + let send = if deposit.is_zero() { + transfer + } else { + ext_ft::ext(self.usdc.clone()) + .with_attached_deposit(deposit) + .with_static_gas(GAS_STORAGE_DEPOSIT) + .storage_deposit(Some(deposit_address.clone()), Some(true)) + .then(transfer) + }; + + send.then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_CALLBACK) + .on_buy_transfer(quote_ref, deposit_address, amount, previous_last_run_at_s), + ) } - /// Restores `last_run_at_s` if the transfer failed (I6) and emits `buy_failed`. + /// Emits `buy_sent`, or restores `last_run_at_s` and emits `buy_failed` (I6). #[private] pub fn on_buy_transfer( &mut self, quote_ref: String, + deposit_address: AccountId, + amount: U128, previous_last_run_at_s: u64, #[callback_result] result: Result<(), PromiseError>, - ) { - let _ = (quote_ref, previous_last_run_at_s, result); - todo!("K1.4") + ) -> bool { + if result.is_ok() { + VaultEvent::BuySent { quote_ref, deposit_address, amount }.emit(); + true + } else { + self.last_run_at_s = previous_last_run_at_s; + VaultEvent::BuyFailed { quote_ref, deposit_address, amount }.emit(); + false + } } } diff --git a/contracts/vault/src/events.rs b/contracts/vault/src/events.rs index 1b5f23f..0a43cd8 100644 --- a/contracts/vault/src/events.rs +++ b/contracts/vault/src/events.rs @@ -1,14 +1,31 @@ //! NEP-297 events, one per state change. Roadmap: K1.6 -//! 🔍 Confirm the near-sdk 5 event macro syntax when implementing. +use near_sdk::json_types::U128; +use near_sdk::{near, AccountId}; -#[allow(dead_code)] -pub(crate) enum VaultEvent { - Deposited, - PlanUpdated, +#[near(event_json(standard = "stack_vault"))] +pub enum VaultEvent { + #[event_version("1.0.0")] + Deposited { token: AccountId, sender_id: AccountId, amount: U128 }, + #[event_version("1.0.0")] + PlanUpdated { + target_asset: String, + destination_address: String, + amount_per_run: U128, + interval_s: u64, + max_cost_bps: u16, + }, + #[event_version("1.0.0")] Paused, + #[event_version("1.0.0")] Resumed, - BuySent, - BuyFailed, - Withdrawn, - TokenRecovered, + #[event_version("1.0.0")] + BuySent { quote_ref: String, deposit_address: AccountId, amount: U128 }, + #[event_version("1.0.0")] + BuyFailed { quote_ref: String, deposit_address: AccountId, amount: U128 }, + #[event_version("1.0.0")] + Withdrawn { amount: U128 }, + #[event_version("1.0.0")] + WithdrawFailed { amount: U128 }, + #[event_version("1.0.0")] + TokenRecovered { token: AccountId, amount: U128 }, } diff --git a/contracts/vault/src/ft.rs b/contracts/vault/src/ft.rs index a8cdd4d..2597a1d 100644 --- a/contracts/vault/src/ft.rs +++ b/contracts/vault/src/ft.rs @@ -1,4 +1,4 @@ -//! NEP-141 cross-contract interface used by the vault. +//! NEP-141 / NEP-145 cross-contract interface used by the vault. use near_sdk::json_types::U128; use near_sdk::{ext_contract, AccountId}; diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index c879748..f8faf46 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1,9 +1,12 @@ //! Stack vault: holds one owner's USDC and makes bounded scheduled buys. //! Spec: ARCHITECTURE.md §3.3–3.6 · Roadmap: K1.1–K1.6 -//! Status: scaffold — state is final-draft, methods are stubs. +//! +//! Funds can leave only through `withdraw` (to the owner), `recover_token` (to the owner) +//! or `execute_buy` (keeper, at most `amount_per_run` once per `interval_s`). There is no +//! admin and no upgrade path (KD-5). use near_sdk::json_types::U128; -use near_sdk::{env, near, require, AccountId, PanicOnDefault}; +use near_sdk::{env, near, require, AccountId, Gas, NearToken, PanicOnDefault}; mod buy; mod events; @@ -12,9 +15,27 @@ mod owner; mod plan; mod receive; +#[cfg(test)] +mod tests; + +pub use events::VaultEvent; + /// Basis points (1/100 of a percent). pub type Bps = u16; +/// Gas for a NEP-141 `ft_transfer` (the 1Click SDK documents 15 TGas as the minimum). +pub(crate) const GAS_FT_TRANSFER: Gas = Gas::from_tgas(15); +/// Gas for a NEP-145 `storage_deposit`. +pub(crate) const GAS_STORAGE_DEPOSIT: Gas = Gas::from_tgas(10); +/// Gas reserved for our own callbacks. +pub(crate) const GAS_CALLBACK: Gas = Gas::from_tgas(10); +pub(crate) const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1); + +/// Plan limits enforced on-chain. +pub(crate) const MIN_INTERVAL_S: u64 = 60; +pub(crate) const MAX_COST_BPS: Bps = 5_000; +pub(crate) const MAX_FIELD_LEN: usize = 128; + #[near(contract_state)] #[derive(PanicOnDefault)] pub struct Vault { @@ -36,6 +57,7 @@ pub struct Vault { /// What `get_state` returns to the app and worker. #[near(serializers = [json])] +#[derive(Clone, Debug, PartialEq)] pub struct VaultView { pub owner: AccountId, pub keeper: AccountId, @@ -71,12 +93,28 @@ impl Vault { } pub fn get_state(&self) -> VaultView { - todo!("K1.1: build VaultView incl. next_run_at_s") + let next_run_at_s = if self.paused || !self.has_plan() { + None + } else { + Some(self.last_run_at_s.saturating_add(self.interval_s)) + }; + VaultView { + owner: self.owner.clone(), + keeper: self.keeper.clone(), + usdc: self.usdc.clone(), + target_asset: self.target_asset.clone(), + destination_address: self.destination_address.clone(), + amount_per_run: U128(self.amount_per_run), + interval_s: self.interval_s, + max_cost_bps: self.max_cost_bps, + paused: self.paused, + last_run_at_s: self.last_run_at_s, + next_run_at_s, + } } } impl Vault { - #[allow(dead_code)] // used by get_state / execute_buy (K1.1, K1.4) pub(crate) fn now_s() -> u64 { env::block_timestamp() / 1_000_000_000 } diff --git a/contracts/vault/src/owner.rs b/contracts/vault/src/owner.rs index 05c7ffe..4993222 100644 --- a/contracts/vault/src/owner.rs +++ b/contracts/vault/src/owner.rs @@ -1,24 +1,67 @@ //! Owner-only fund movements. Roadmap: K1.5 · Invariants: I1, I4 +use crate::ft::ext_ft; use crate::*; -use near_sdk::Promise; +use near_sdk::{Promise, PromiseError}; #[near] impl Vault { - /// Owner + 1 yocto. Sends USDC to the owner. + /// Owner + 1 yocto. Sends USDC to the owner. Always available, paused or not. #[payable] pub fn withdraw(&mut self, amount: U128) -> Promise { near_sdk::assert_one_yocto(); self.assert_owner(); - let _ = amount; - todo!("K1.5") + require!(amount.0 > 0, "amount must be positive"); + ext_ft::ext(self.usdc.clone()) + .with_attached_deposit(ONE_YOCTO) + .with_static_gas(GAS_FT_TRANSFER) + .ft_transfer(self.owner.clone(), amount, Some("stack withdraw".to_string())) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_CALLBACK) + .on_withdraw(amount), + ) } /// Owner + 1 yocto. Sends any other NEP-141 token held by the vault to the owner. + /// USDC goes through `withdraw` instead. #[payable] pub fn recover_token(&mut self, token: AccountId, amount: U128) -> Promise { near_sdk::assert_one_yocto(); self.assert_owner(); - let _ = (token, amount); - todo!("K1.5") + require!(token != self.usdc, "Use withdraw for USDC"); + require!(amount.0 > 0, "amount must be positive"); + ext_ft::ext(token.clone()) + .with_attached_deposit(ONE_YOCTO) + .with_static_gas(GAS_FT_TRANSFER) + .ft_transfer(self.owner.clone(), amount, Some("stack recover".to_string())) + .then( + Self::ext(env::current_account_id()) + .with_static_gas(GAS_CALLBACK) + .on_recover(token, amount), + ) + } + + #[private] + pub fn on_withdraw(&mut self, amount: U128, #[callback_result] result: Result<(), PromiseError>) -> bool { + if result.is_ok() { + VaultEvent::Withdrawn { amount }.emit(); + true + } else { + VaultEvent::WithdrawFailed { amount }.emit(); + false + } + } + + #[private] + pub fn on_recover( + &mut self, + token: AccountId, + amount: U128, + #[callback_result] result: Result<(), PromiseError>, + ) -> bool { + if result.is_ok() { + VaultEvent::TokenRecovered { token, amount }.emit(); + } + result.is_ok() } } diff --git a/contracts/vault/src/plan.rs b/contracts/vault/src/plan.rs index 6635b1b..a70ced8 100644 --- a/contracts/vault/src/plan.rs +++ b/contracts/vault/src/plan.rs @@ -1,9 +1,9 @@ -//! Owner plan controls. Roadmap: K1.2 +//! Owner plan controls. Roadmap: K1.2 · Invariants: I3, I4 use crate::*; #[near] impl Vault { - /// Owner + 1 yocto (I4). Validates ranges, stores the plan, emits `plan_updated`. + /// Owner + 1 yocto (I4). Validates ranges and stores the plan. Does not unpause. #[payable] pub fn set_plan( &mut self, @@ -15,8 +15,35 @@ impl Vault { ) { near_sdk::assert_one_yocto(); self.assert_owner(); - let _ = (target_asset, destination_address, amount_per_run, interval_s, max_cost_bps); - todo!("K1.2") + require!( + !target_asset.is_empty() && target_asset.len() <= MAX_FIELD_LEN, + "Invalid target_asset" + ); + require!( + !destination_address.is_empty() && destination_address.len() <= MAX_FIELD_LEN, + "Invalid destination_address" + ); + require!(amount_per_run.0 > 0, "amount_per_run must be positive"); + require!(interval_s >= MIN_INTERVAL_S, "interval_s too short"); + require!( + max_cost_bps > 0 && max_cost_bps <= MAX_COST_BPS, + "max_cost_bps out of range" + ); + + self.target_asset = Some(target_asset.clone()); + self.destination_address = Some(destination_address.clone()); + self.amount_per_run = amount_per_run.0; + self.interval_s = interval_s; + self.max_cost_bps = max_cost_bps; + + VaultEvent::PlanUpdated { + target_asset, + destination_address, + amount_per_run, + interval_s, + max_cost_bps, + } + .emit(); } /// Owner + 1 yocto (I4). Requires a complete plan. @@ -25,13 +52,14 @@ impl Vault { near_sdk::assert_one_yocto(); self.assert_owner(); require!(self.has_plan(), "Set a plan first"); - todo!("K1.2: unpause + emit `resumed`") + self.paused = false; + VaultEvent::Resumed.emit(); } /// Owner only, no deposit: pausing must always be the easiest call (I4). pub fn pause(&mut self) { self.assert_owner(); self.paused = true; - // TODO(K1.6): emit `paused` + VaultEvent::Paused.emit(); } } diff --git a/contracts/vault/src/receive.rs b/contracts/vault/src/receive.rs index 0415e5b..e0bc937 100644 --- a/contracts/vault/src/receive.rs +++ b/contracts/vault/src/receive.rs @@ -4,10 +4,12 @@ use near_sdk::PromiseOrValue; #[near] impl Vault { - /// Accept every token (return 0 = keep all) and emit `deposited`. - /// Non-USDC tokens stay recoverable via `recover_token`. + /// Keeps every token sent with `ft_transfer_call` (returns 0 unused) and emits `deposited`. + /// Non-USDC tokens stay recoverable via `recover_token`. Plain `ft_transfer` deposits + /// arrive without calling this; the app reads the balance with `ft_balance_of`. pub fn ft_on_transfer(&mut self, sender_id: AccountId, amount: U128, msg: String) -> PromiseOrValue { - let _ = (sender_id, amount, msg); - todo!("K1.3") + let _ = msg; + VaultEvent::Deposited { token: env::predecessor_account_id(), sender_id, amount }.emit(); + PromiseOrValue::Value(U128(0)) } } diff --git a/contracts/vault/src/tests.rs b/contracts/vault/src/tests.rs new file mode 100644 index 0000000..c7c6902 --- /dev/null +++ b/contracts/vault/src/tests.rs @@ -0,0 +1,267 @@ +//! Unit tests for vault rules. Cross-contract flows live in contracts/tests (near-workspaces). +use super::*; +use near_sdk::test_utils::{get_logs, VMContextBuilder}; +use near_sdk::{testing_env, PromiseError}; + +const SEC: u64 = 1_000_000_000; + +fn owner() -> AccountId { + "alice.near".parse().unwrap() +} +fn keeper() -> AccountId { + "keeper.stack.near".parse().unwrap() +} +fn usdc() -> AccountId { + "usdc.near".parse().unwrap() +} +fn vault_id() -> AccountId { + "v0123456789abcdef.factory.stack.near".parse().unwrap() +} +fn deposit_addr() -> AccountId { + "0000000000000000000000000000000000000000000000000000000000000abc".parse().unwrap() +} + +fn ctx(predecessor: AccountId, deposit: u128, now_s: u64) { + let mut b = VMContextBuilder::new(); + b.current_account_id(vault_id()) + .predecessor_account_id(predecessor) + .attached_deposit(NearToken::from_yoctonear(deposit)) + .block_timestamp(now_s * SEC); + testing_env!(b.build()); +} + +fn new_vault() -> Vault { + ctx("factory.stack.near".parse().unwrap(), 0, 1_000); + Vault::new(owner(), keeper(), usdc()) +} + +fn planned_vault() -> Vault { + let mut v = new_vault(); + ctx(owner(), 1, 1_000); + v.set_plan("nep141:btc.omft.near".into(), "bc1qexample".into(), U128(100_000_000), 3_600, 300); + ctx(owner(), 1, 1_000); + v.resume(); + v +} + +// ---- I7: new vault is paused, without a plan ---- +#[test] +fn i7_new_vault_is_paused_without_plan() { + let v = new_vault(); + let s = v.get_state(); + assert!(s.paused); + assert_eq!(s.target_asset, None); + assert_eq!(s.destination_address, None); + assert_eq!(s.next_run_at_s, None); +} + +// ---- I4: owner + 1 yocto, pause without deposit ---- +#[test] +#[should_panic(expected = "Requires attached deposit of exactly 1 yoctoNEAR")] +fn set_plan_requires_one_yocto() { + let mut v = new_vault(); + ctx(owner(), 0, 1_000); + v.set_plan("a".into(), "b".into(), U128(1), 3_600, 100); +} + +#[test] +#[should_panic(expected = "Only the owner")] +fn set_plan_rejects_non_owner() { + let mut v = new_vault(); + ctx(keeper(), 1, 1_000); + v.set_plan("a".into(), "b".into(), U128(1), 3_600, 100); +} + +#[test] +#[should_panic(expected = "Only the owner")] +fn resume_rejects_keeper() { + let mut v = new_vault(); + ctx(keeper(), 1, 1_000); + v.resume(); +} + +#[test] +#[should_panic(expected = "Set a plan first")] +fn resume_requires_plan() { + let mut v = new_vault(); + ctx(owner(), 1, 1_000); + v.resume(); +} + +#[test] +fn pause_needs_no_deposit_and_emits_event() { + let mut v = planned_vault(); + ctx(owner(), 0, 1_000); + v.pause(); + assert!(v.get_state().paused); + assert!(get_logs().iter().any(|l| l.contains("\"event\":\"paused\""))); +} + +#[test] +#[should_panic(expected = "Only the owner")] +fn keeper_cannot_pause() { + let mut v = planned_vault(); + ctx(keeper(), 0, 1_000); + v.pause(); +} + +#[test] +#[should_panic(expected = "Requires attached deposit of exactly 1 yoctoNEAR")] +fn withdraw_requires_one_yocto() { + let mut v = planned_vault(); + ctx(owner(), 0, 1_000); + let _ = v.withdraw(U128(1)); +} + +#[test] +#[should_panic(expected = "Only the owner")] +fn keeper_cannot_withdraw() { + let mut v = planned_vault(); + ctx(keeper(), 1, 1_000); + let _ = v.withdraw(U128(1)); +} + +#[test] +#[should_panic(expected = "Use withdraw for USDC")] +fn recover_token_refuses_usdc() { + let mut v = planned_vault(); + ctx(owner(), 1, 1_000); + let _ = v.recover_token(usdc(), U128(1)); +} + +// ---- plan validation ---- +#[test] +#[should_panic(expected = "interval_s too short")] +fn plan_rejects_short_interval() { + let mut v = new_vault(); + ctx(owner(), 1, 1_000); + v.set_plan("a".into(), "b".into(), U128(1), MIN_INTERVAL_S - 1, 100); +} + +#[test] +#[should_panic(expected = "max_cost_bps out of range")] +fn plan_rejects_cost_above_cap() { + let mut v = new_vault(); + ctx(owner(), 1, 1_000); + v.set_plan("a".into(), "b".into(), U128(1), 3_600, MAX_COST_BPS + 1); +} + +#[test] +#[should_panic(expected = "amount_per_run must be positive")] +fn plan_rejects_zero_amount() { + let mut v = new_vault(); + ctx(owner(), 1, 1_000); + v.set_plan("a".into(), "b".into(), U128(0), 3_600, 100); +} + +#[test] +fn set_plan_keeps_vault_paused_and_emits_event() { + let mut v = new_vault(); + ctx(owner(), 1, 1_000); + v.set_plan("nep141:eth.omft.near".into(), "0xabc".into(), U128(5), 3_600, 100); + let s = v.get_state(); + assert!(s.paused, "set_plan must not unpause"); + assert_eq!(s.amount_per_run, U128(5)); + assert!(get_logs().iter().any(|l| l.contains("\"event\":\"plan_updated\""))); +} + +// ---- I2 / I3: bounded buys ---- +#[test] +#[should_panic(expected = "Only the keeper")] +fn owner_cannot_execute_buy() { + let mut v = planned_vault(); + ctx(owner(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(1)); +} + +#[test] +#[should_panic(expected = "Vault is paused")] +fn i3_no_buy_when_paused() { + let mut v = planned_vault(); + ctx(owner(), 0, 1_000); + v.pause(); + ctx(keeper(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(1)); +} + +#[test] +#[should_panic(expected = "No plan")] +fn i3_no_buy_without_plan() { + let mut v = new_vault(); + v.paused = false; // force: a plan-less vault must still refuse + ctx(keeper(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(1)); +} + +#[test] +#[should_panic(expected = "Amount above amount_per_run")] +fn i2_amount_capped() { + let mut v = planned_vault(); + ctx(keeper(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(100_000_001)); +} + +#[test] +#[should_panic(expected = "Too early for the next buy")] +fn i2_one_buy_per_interval() { + let mut v = planned_vault(); + ctx(keeper(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(1)); + ctx(keeper(), 0, 10_000 + 3_599); + let _ = v.execute_buy("q2".into(), deposit_addr(), U128(1)); +} + +#[test] +fn buy_allowed_again_after_interval() { + let mut v = planned_vault(); + ctx(keeper(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(1)); + ctx(keeper(), 0, 10_000 + 3_600); + let _ = v.execute_buy("q2".into(), deposit_addr(), U128(1)); + assert_eq!(v.get_state().last_run_at_s, 13_600); +} + +// ---- I6: failed transfer restores the schedule ---- +#[test] +fn i6_failed_transfer_restores_last_run() { + let mut v = planned_vault(); + ctx(keeper(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(1)); + assert_eq!(v.last_run_at_s, 10_000); + ctx(vault_id(), 0, 10_001); + let ok = v.on_buy_transfer("q1".into(), deposit_addr(), U128(1), 0, Err(PromiseError::Failed)); + assert!(!ok); + assert_eq!(v.last_run_at_s, 0); + assert!(get_logs().iter().any(|l| l.contains("\"event\":\"buy_failed\""))); +} + +#[test] +fn successful_transfer_keeps_last_run_and_emits_buy_sent() { + let mut v = planned_vault(); + ctx(keeper(), 0, 10_000); + let _ = v.execute_buy("q1".into(), deposit_addr(), U128(1)); + ctx(vault_id(), 0, 10_001); + assert!(v.on_buy_transfer("q1".into(), deposit_addr(), U128(1), 0, Ok(()))); + assert_eq!(v.last_run_at_s, 10_000); + assert!(get_logs().iter().any(|l| l.contains("\"event\":\"buy_sent\""))); +} + +// `#[private]` is enforced in the wasm entry point, not on direct Rust calls; covered by the +// near-workspaces test `callbacks_are_private` instead. + +#[test] +fn next_run_reported_when_active() { + let v = planned_vault(); + assert_eq!(v.get_state().next_run_at_s, Some(3_600)); +} + +#[test] +fn ft_on_transfer_keeps_everything() { + let mut v = planned_vault(); + ctx(usdc(), 0, 1_000); + match v.ft_on_transfer(owner(), U128(50), String::new()) { + near_sdk::PromiseOrValue::Value(U128(0)) => {} + _ => panic!("must keep all tokens"), + } + assert!(get_logs().iter().any(|l| l.contains("\"event\":\"deposited\""))); +} diff --git a/project_structure.md b/project_structure.md index 070f3ee..538f9e4 100644 --- a/project_structure.md +++ b/project_structure.md @@ -33,12 +33,13 @@ app/, worker/ ──► lib/server ──► lib/db, lib/oneclick, lib/near ─ ## 2. `contracts/` ``` contracts/ -├─ Cargo.toml # workspace: factory, vault, tests +├─ Cargo.toml # workspace: factory, vault, mock-ft, tests ├─ rust-toolchain.toml ├─ factory/src/ -│ ├─ lib.rs # state, create_vault_for, get_vault, daily cap -│ ├─ deploy.rs # sub-account + vault code deploy -│ └─ storage.rs # USDC storage_deposit for the vault +│ ├─ lib.rs # state, set_vault_code, create_vault_for, views, daily cap, events +│ ├─ deploy.rs # sub-account + vault code deploy, on_vault_deployed +│ ├─ storage.rs # USDC storage_deposit, on_vault_registered, retry_registration +│ └─ tests.rs # unit tests ├─ vault/src/ │ ├─ lib.rs # state, new, get_state │ ├─ plan.rs # set_plan, resume, pause @@ -46,12 +47,13 @@ contracts/ │ ├─ owner.rs # withdraw, recover_token │ ├─ receive.rs # ft_on_transfer │ ├─ events.rs # NEP-297 -│ └─ ft.rs # NEP-141 ext interface +│ ├─ ft.rs # NEP-141 / NEP-145 ext interface +│ └─ tests.rs # unit tests (rules, guards, callbacks) +├─ mock-ft/src/lib.rs # test-only NEP-141 + NEP-145 token standing in for USDC ├─ tests/src/ # near-workspaces -│ ├─ setup.rs # mock USDC + factory + vault -│ ├─ invariants.rs # I1–I7 -│ ├─ buy.rs · owner.rs · factory.rs · recover.rs -│ └─ ../res/mock_ft.wasm +│ ├─ setup.rs # builds wasm (compile_project), mock USDC + factory + alice's vault +│ ├─ invariants.rs # I1–I7 + private callbacks +│ └─ buy.rs · owner.rs · factory.rs · recover.rs └─ README.md # build, test, method list ``` Unit tests next to code (`#[cfg(test)]`); cross-contract tests in `contracts/tests/`. Built wasm in `target/`, not committed.