From c07aadddabe0ee4f1387be091db9ff395fd9d36c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 14:24:04 +0000 Subject: [PATCH] tokens/stop-loss-vault: permissionless Switchboard-triggered stop-loss vault A per-owner Anchor vault that holds one volatile token and permissionlessly converts it to a stable token (USDC) once a Switchboard On-Demand price feed reports a price at or below an owner-set threshold. An offchain TukTuk crank calls convert_if_triggered on a schedule; the swap routes through Jupiter v6's shared_accounts_route, with a mock-jupiter program standing in for tests. Instructions: initialize_vault, deposit, update_threshold, convert_if_triggered, withdraw_stables, withdraw_volatile. - token_interface + transfer_checked for every token move, so the vault works against the Classic and Token Extensions programs. - Oracle freshness enforced via MAX_PRICE_STALENESS_SLOTS; owner-only mutations via has_one + PDA seeds. - withdraw_volatile escape hatch so a never-triggered vault never locks funds. - mock-jupiter and mock-switchboard test programs with the real external shapes. - 8 Rust + LiteSVM scenarios; README with a finance primer and a program-flow walkthrough (Alice/Bob/Carol/Dave) naming each handler and the accounts it changes. https://claude.ai/code/session_01UXGGFcK3UWRrcv9UoW1gkJ --- tokens/stop-loss-vault/README.md | 112 ++++ tokens/stop-loss-vault/anchor/.gitignore | 8 + tokens/stop-loss-vault/anchor/.prettierignore | 7 + tokens/stop-loss-vault/anchor/Anchor.toml | 20 + tokens/stop-loss-vault/anchor/Cargo.toml | 14 + .../anchor/migrations/deploy.ts | 12 + .../anchor/programs/mock-jupiter/Cargo.toml | 24 + .../anchor/programs/mock-jupiter/src/lib.rs | 236 ++++++++ .../programs/mock-switchboard/Cargo.toml | 23 + .../programs/mock-switchboard/src/lib.rs | 89 +++ .../programs/stop-loss-vault/Cargo.toml | 44 ++ .../programs/stop-loss-vault/src/constants.rs | 27 + .../programs/stop-loss-vault/src/error.rs | 31 + .../stop-loss-vault/src/instructions.rs | 25 + .../src/instructions/convert_if_triggered.rs | 241 ++++++++ .../src/instructions/deposit.rs | 59 ++ .../src/instructions/initialize_vault.rs | 88 +++ .../src/instructions/update_threshold.rs | 41 ++ .../src/instructions/withdraw_stables.rs | 66 +++ .../src/instructions/withdraw_volatile.rs | 68 +++ .../programs/stop-loss-vault/src/lib.rs | 88 +++ .../programs/stop-loss-vault/src/state.rs | 55 ++ .../stop-loss-vault/tests/common/mod.rs | 540 ++++++++++++++++++ .../tests/stop_loss_vault_scenarios.rs | 346 +++++++++++ 24 files changed, 2264 insertions(+) create mode 100644 tokens/stop-loss-vault/README.md create mode 100644 tokens/stop-loss-vault/anchor/.gitignore create mode 100644 tokens/stop-loss-vault/anchor/.prettierignore create mode 100644 tokens/stop-loss-vault/anchor/Anchor.toml create mode 100644 tokens/stop-loss-vault/anchor/Cargo.toml create mode 100644 tokens/stop-loss-vault/anchor/migrations/deploy.ts create mode 100644 tokens/stop-loss-vault/anchor/programs/mock-jupiter/Cargo.toml create mode 100644 tokens/stop-loss-vault/anchor/programs/mock-jupiter/src/lib.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/mock-switchboard/Cargo.toml create mode 100644 tokens/stop-loss-vault/anchor/programs/mock-switchboard/src/lib.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/Cargo.toml create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/constants.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/error.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/convert_if_triggered.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/deposit.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/initialize_vault.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/update_threshold.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_stables.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_volatile.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/lib.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/state.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/common/mod.rs create mode 100644 tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs diff --git a/tokens/stop-loss-vault/README.md b/tokens/stop-loss-vault/README.md new file mode 100644 index 00000000..828daaf4 --- /dev/null +++ b/tokens/stop-loss-vault/README.md @@ -0,0 +1,112 @@ +# Stop-Loss Vault + +A vault that holds one volatile token for its owner and automatically sells it for a stable token once the price falls to a level the owner picked in advance. It is the onchain version of a [stop-loss order](https://www.investopedia.com/terms/s/stop-lossorder.asp) — a standing instruction to "sell if the price drops to X" so a holder caps their downside without having to watch the market. + +The running example in this README uses a [tokenized stock](https://www.investopedia.com/terms/t/tokenization.asp) as the volatile token — **NVDAx** (a token that tracks the Nvidia share price) or **tslax** (Tesla) — and **USDC**, a [stablecoin](https://www.investopedia.com/terms/s/stablecoin.asp) worth ~$1, as the stable token. The program itself is asset-agnostic: any volatile-token → stable-token pair works (NVDAx → USDC, tslax → USDC, …). + +## A 60-second finance primer + +If you come from software rather than trading, here is everything you need: + +- **Volatile token** — one whose price swings a lot (a [tokenized stock](https://www.investopedia.com/terms/t/tokenization.asp) like NVDAx or tslax). "[Volatility](https://www.investopedia.com/terms/v/volatility.asp)" just means the price moves around. +- **Stable token** — a [stablecoin](https://www.investopedia.com/terms/s/stablecoin.asp) such as USDC, engineered to stay near $1. Converting into it "locks in" a dollar value. +- **Stop-loss** — a [stop-loss order](https://www.investopedia.com/terms/s/stop-lossorder.asp): "if the price drops to my threshold, sell." This program automates exactly that. +- **Threshold price** — the floor the owner sets. At or below it, the vault converts to USDC. +- **Price oracle** — a service that reports a real-world price onchain. This vault reads a [Switchboard](https://docs.switchboard.xyz/) feed; the price travels as signed data the program can trust. +- **Swap / DEX aggregator** — software that trades one token for another at the best available price. Here that is [Jupiter](https://jup.ag); the swap consumes [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) supplied by other users. +- **Cranker / keeper** — a bot that calls a program on a schedule (the chain does not run timers itself). This vault is cranked by a [TukTuk](https://github.com/helium/tuktuk) task. + +Two more terms show up under [Limitations](#limitations): [slippage](https://www.investopedia.com/terms/s/slippage.asp) (getting a worse fill than quoted) and [front-running](https://www.investopedia.com/terms/f/frontrunning.asp) (someone trading ahead of your transaction). + +## Major concepts + +- **One vault per owner**, a [program-derived address](https://solana.com/docs/references/terminology#program-derived-account) (PDA) at seeds `[b"vault", owner.key().as_ref()]`. The PDA owns two associated token accounts — one for the volatile token, one for the stable token — and stores the oracle feed pubkey, the threshold price (in the feed's fixed-point scale), the suggested crank cadence, and the registered TukTuk task pubkey. +- **One-shot lifecycle.** A `triggered` flag flips `false → true` the moment a conversion fires. After that the pre-trigger actions (`deposit`, `update_threshold`, `withdraw_volatile`) are locked and the vault is simply a USDC wallet the owner drains with `withdraw_stables`. +- **Permissionless conversion.** `convert_if_triggered` reads the latest price and, only if it is at or below the threshold *and* fresh, performs a [cross-program invocation](https://solana.com/docs/references/terminology#cross-program-invocation-cpi) into the swap aggregator's `shared_accounts_route`, selling the vault's entire volatile balance. The vault PDA signs the swap for itself. +- **Custody.** Funds sit in program-owned token accounts. There is no admin key and no escape hatch for anyone but the owner: the owner alone can deposit, withdraw, or change the threshold, and the permissionless cranker can do nothing except execute the one swap the rules already permit. The custody rule is the deployed bytecode. + +## Instructions + +- `initialize_vault(threshold_price, crank_interval_seconds, tuktuk_task)` — owner creates the vault, its two associated token accounts, and records the threshold + scheduling hint. +- `deposit(amount)` — owner moves volatile tokens into the vault. Refused once triggered. +- `update_threshold(new_threshold_price?, new_crank_interval_seconds?)` — owner moves the threshold up or down (a [trailing stop](https://www.investopedia.com/terms/t/trailingstop.asp) when raised as the price rises) and/or changes the suggested cadence. Both arguments optional; refused once triggered. +- `convert_if_triggered(switchboard_price_update_data)` — permissionless. Swaps only when the latest price is at or below the threshold and fresh; otherwise reverts with `PriceAboveThreshold` (price strictly above) or `StalePrice` (price too old). +- `withdraw_stables(amount)` — owner pulls USDC out after a trigger. +- `withdraw_volatile(amount)` — owner pulls the volatile token back out *before* a trigger. The escape hatch so a vault whose threshold is never reached never locks the deposit. Refused once triggered. + +## Program flow + +Four users, each with a real reason to be here: + +- **Alice — the vault owner (investor).** She holds 100 NVDAx because she's bullish on Nvidia long-term (her [investment thesis](https://www.investopedia.com/terms/i/investment-thesis.asp)), but she wants to cap her downside and doesn't want to watch the chart all day. Her motivation: automatic protection. +- **Bob — the cranker (keeper).** He runs a TukTuk worker that calls the vault on schedule. His motivation: keepers are paid (out of the task's funding) for reliably executing scheduled work. He cannot move funds anywhere except through the swap the rules allow. +- **Carol — an outsider.** She'd profit by draining someone else's vault. Her motivation is theft; the program's job is to refuse her. +- **Dave — a liquidity provider.** He deposits NVDAx and USDC into the swap pool the conversion routes through, acting as a [market maker](https://www.investopedia.com/terms/m/marketmaker.asp). His motivation: earn the swap fee every time a trade — including Alice's conversion — flows through his liquidity. + +The lifecycle, naming the handler called and the accounts each one changes: + +1. **Alice opens her vault.** Calls `initialize_vault($200 threshold, 600s cadence, tuktuk_task)`. + *Creates:* the `vault` PDA, the vault's NVDAx associated token account, the vault's USDC associated token account. +2. **Alice funds it.** Calls `deposit(100 NVDAx)`. + *Changes:* Alice's NVDAx account (−100), the vault's NVDAx account (+100). The move uses `transfer_checked`, which carries the mint and decimals so a wrong-token account can't slip through. +3. **Bob cranks on schedule.** Calls `convert_if_triggered(price_update)` every 10 minutes. + - While NVDAx is above $200: reverts with `PriceAboveThreshold` — a cheap no-op that changes nothing. + - When NVDAx is at or below $200 and the price is fresh: swaps all 100 NVDAx for USDC through Dave's pool via Jupiter's `shared_accounts_route`. *Changes:* the vault's NVDAx account (→ 0), the vault's USDC account (+ 100 × price), Dave's pool accounts, and `vault.triggered` (→ `true`). +4. **Alice trails her floor up (optional).** NVDAx rallies to $300, so she raises her protection. Calls `update_threshold($250)`. + *Changes:* `vault.threshold_price`. (Rejected if the vault has already triggered.) +5. **Alice cashes out after a conversion.** Calls `withdraw_stables(amount)`. + *Changes:* the vault's USDC account (−amount), Alice's USDC account (+amount). Callable only after a trigger. +6. **…or Alice backs out if it never fires.** NVDAx stays above her floor and she changes her mind. Calls `withdraw_volatile(amount)` before any trigger. + *Changes:* the vault's NVDAx account (−amount), Alice's NVDAx account (+amount). Refused after a trigger (the balance is in USDC by then — she'd use `withdraw_stables`). +7. **Carol tries to steal.** Calls `withdraw_stables` or `withdraw_volatile` pointed at Alice's vault. The `has_one = owner` constraint and the PDA seeds reject her: the transaction reverts and **no accounts change**. + +| Handler | Who calls it | Accounts it changes | +| --- | --- | --- | +| `initialize_vault` | Owner (Alice) | Creates `vault` PDA + vault's two token accounts | +| `deposit` | Owner | Owner's volatile account → vault's volatile account | +| `update_threshold` | Owner | `vault.threshold_price` / `vault.crank_interval_seconds` | +| `convert_if_triggered` | Anyone (cranker Bob) | Vault's volatile account → vault's stable account (via swap pool); sets `vault.triggered` | +| `withdraw_stables` | Owner | Vault's stable account → owner's stable account | +| `withdraw_volatile` | Owner | Vault's volatile account → owner's volatile account | + +## Why Switchboard On-Demand + +Switchboard On-Demand prices are pulled (not pushed) and verified onchain via Ed25519 signatures, so the price-update bytes travel as an instruction argument and the program trusts them only after verification. That fits a permissionless crank: the cranker pays for the price update it wants the program to act on, and the program never trusts the cranker's identity. Pyth is the obvious alternative but pushes prices on a continuous publisher schedule, which costs more in account rent and update fees for the same behaviour. + +The teaching example uses a `mock-switchboard` program exposing the minimum fields the vault reads (price, scale, last-update slot) so the tests can drive deterministic price scenarios. Production swaps it for the `switchboard-on-demand` crate and verifies updates via `PullFeedAccountData::parse_and_verify`. + +## Why TukTuk + +[TukTuk](https://github.com/helium/tuktuk) is the maintained replacement for Clockwork (dead) for scheduling onchain instruction handlers. The vault doesn't enforce the cadence onchain — it records `crank_interval_seconds` as a hint and stores the TukTuk task pubkey for discoverability. Anyone can crank, but in normal operation TukTuk runs the schedule and pays for the price update. + +## Setup + +Prerequisites: the [Solana CLI](https://docs.anza.xyz/cli/install), [Anchor](https://www.anchor-lang.com/docs/installation) 1.0+, and a Rust toolchain. From `tokens/stop-loss-vault/anchor`, `anchor build` compiles all three programs (the vault plus the `mock-jupiter` and `mock-switchboard` test stand-ins) to `target/deploy/`, which the integration tests load via `include_bytes!`. + +## Testing + +```sh +anchor build +anchor test +``` + +`anchor test` runs the Rust + LiteSVM integration tests under `programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs`. The fixtures use a 9-decimal NVDAx stand-in priced in USD against 6-decimal USDC: + +- Alice opens a vault with a $100 threshold and deposits 10 NVDAx. +- Bob cranks across three checks ($180 → $150 → $80); the third fires the conversion and Alice withdraws $800 USDC (10 × $80). +- Carol cannot withdraw from a vault she doesn't own. +- Alice trails the threshold up to $200 after NVDAx rallies to $250; the next crank fires at $180 (10 × $180 = $1800 USDC). +- A crank when the price is above the threshold reverts cheaply and leaves the vault un-triggered. +- A [flash crash](https://www.investopedia.com/terms/f/flash-crash.asp) *between* cranks is missed — the vault is not converted (see Limitations). +- A price that drops below the threshold but goes stale before the next crank is rejected — the vault is not converted. +- A vault that never triggers isn't a trap: Alice pulls her deposit back out with `withdraw_volatile`, and Carol can't use it against Alice's vault. + +## Limitations + +- **Flash-crash gap between cranks.** This is a discrete-time stop-loss: the vault only sees the price at crank time. If the price crashes through the threshold and recovers between two consecutive cranks, the vault never sees the crash and the conversion does not fire. The fix is either a tighter `crank_interval_seconds` (more crank and price-update fees) or a continuous-watch offchain liquidator with stronger trust assumptions. `test_flash_crash_between_cranks_misses_trigger` demonstrates the gap. +- **Oracle staleness.** `convert_if_triggered` rejects a price whose `last_update_slot` is more than `MAX_PRICE_STALENESS_SLOTS` behind the current slot, so a crank can't act on a stale print from a feed that has stopped updating. Switchboard On-Demand prices are pulled at crank time, so in normal operation the price is always fresh; the check exists to fail closed when it isn't. +- **MEV / [front-running](https://www.investopedia.com/terms/f/frontrunning.asp).** `convert_if_triggered` is permissionless and swaps at whatever route the cranker supplies, so the swap is exposed to adversarial transaction ordering — a searcher, or the slot leader building the block, can place a transaction around the crank to fill it at a worse price. The Jupiter route built here passes `slippage_bps = 0` and `quoted_out_amount = 0` for simplicity; production must compute a real quote and pass realistic [slippage](https://www.investopedia.com/terms/s/slippage.asp), or route privately (for example through a Jito bundle), to avoid being filled below the oracle's last print. +- **No partial-fill protection.** The vault swaps its *entire* volatile balance in one instruction. If [liquidity](https://www.investopedia.com/terms/l/liquidity.asp) for the full size is thin, the owner pays the route's price impact in full. Real systems split into chunks or refuse to convert above a price-impact ceiling. +- **`mock-jupiter` is a test stand-in.** It performs a deterministic price-multiply rather than a real route. Do not deploy with it. To use real Jupiter v6, pass its program ID as the `swap_program` account at call time — the handler derives the instruction discriminator from the `shared_accounts_route` name, so it already matches the real program. +- **`mock-switchboard` is a test stand-in.** It exposes a writable price the test harness drives directly. Real Switchboard On-Demand verifies signed updates onchain via `PullFeedAccountData::parse_and_verify`; the production handler must do the same and reject unsigned data. +- **TukTuk task registration is stubbed.** `initialize_vault` accepts a `tuktuk_task` pubkey as input rather than CPI-creating the task atomically. See the `TODO` in `initialize_vault.rs` for the integration point. diff --git a/tokens/stop-loss-vault/anchor/.gitignore b/tokens/stop-loss-vault/anchor/.gitignore new file mode 100644 index 00000000..58466dce --- /dev/null +++ b/tokens/stop-loss-vault/anchor/.gitignore @@ -0,0 +1,8 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn +.surfpool diff --git a/tokens/stop-loss-vault/anchor/.prettierignore b/tokens/stop-loss-vault/anchor/.prettierignore new file mode 100644 index 00000000..41425834 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/.prettierignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +node_modules +dist +build +test-ledger diff --git a/tokens/stop-loss-vault/anchor/Anchor.toml b/tokens/stop-loss-vault/anchor/Anchor.toml new file mode 100644 index 00000000..40268970 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/Anchor.toml @@ -0,0 +1,20 @@ +[toolchain] +package_manager = "npm" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +mock_jupiter = "DSMyed6WZ2US8nfwLQtF7en9jcd9exn7c4qQd52Nffx1" +mock_switchboard = "GAbm8tcMimkhYsQZm24N3Ev1kuWbTKXkTQ1gQEpfJ9Gg" +stop_loss_vault = "BSzhyK5soR2T3T1LCjwYVybff2D9NowwfFHdVsAwnkmG" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test" + +[hooks] diff --git a/tokens/stop-loss-vault/anchor/Cargo.toml b/tokens/stop-loss-vault/anchor/Cargo.toml new file mode 100644 index 00000000..f3977048 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tokens/stop-loss-vault/anchor/migrations/deploy.ts b/tokens/stop-loss-vault/anchor/migrations/deploy.ts new file mode 100644 index 00000000..81b3ef43 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/migrations/deploy.ts @@ -0,0 +1,12 @@ +// Migrations are an early feature. Currently, they're nothing more than this +// single deploy script that's invoked from the CLI, injecting a provider +// configured from the workspace's Anchor.toml. + +const anchor = require("@anchor-lang/core"); + +module.exports = async (provider) => { + // Configure client to use the provider. + anchor.setProvider(provider); + + // Add your deploy script here. +}; diff --git a/tokens/stop-loss-vault/anchor/programs/mock-jupiter/Cargo.toml b/tokens/stop-loss-vault/anchor/programs/mock-jupiter/Cargo.toml new file mode 100644 index 00000000..c177afdf --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/mock-jupiter/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mock-jupiter" +version = "0.1.0" +description = "Teaching mock of Jupiter v6's swap aggregator. Implements a SINGLE instruction with the same discriminator and account layout as Jupiter v6's `shared_accounts_route`, but performs a deterministic price-multiply instead of a real swap. NOT FOR PRODUCTION." +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "mock_jupiter" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + +[dependencies] +anchor-lang = "1.0.0" +anchor-spl = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tokens/stop-loss-vault/anchor/programs/mock-jupiter/src/lib.rs b/tokens/stop-loss-vault/anchor/programs/mock-jupiter/src/lib.rs new file mode 100644 index 00000000..7a33f835 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/mock-jupiter/src/lib.rs @@ -0,0 +1,236 @@ +//! Mock Jupiter v6 swap aggregator for testing the stop-loss vault. +//! +//! Real Jupiter aggregates across many AMMs and routes through possibly +//! multiple pools. The instruction that the vault uses against real Jupiter is +//! `shared_accounts_route` — a single permissioned route through Jupiter's +//! shared program-owned accounts. +//! +//! This mock implements ONE instruction with the same external shape (an +//! 8-byte Anchor-style discriminator + a borsh argument struct + a fixed +//! account list head). Instead of actually routing through DEXes, the mock: +//! +//! 1. Reads the current price from a mock Switchboard feed passed in +//! remaining accounts. +//! 2. Transfers `in_amount` of the input mint from the user's source ATA to +//! the mock liquidity pool's input ATA. +//! 3. Transfers `in_amount * price / 10^scale` adjusted for decimal +//! differences of the output mint from the mock pool's output ATA back +//! to the user's destination ATA. +//! +//! This is enough to exercise the vault's swap path in tests. NOT FOR +//! PRODUCTION — real Jupiter swaps go through real liquidity, real price +//! impact, real slippage, and real route accounts. +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Token, TokenAccount, TransferChecked}; + +declare_id!("DSMyed6WZ2US8nfwLQtF7en9jcd9exn7c4qQd52Nffx1"); + +#[program] +pub mod mock_jupiter { + use super::*; + + /// Mock of Jupiter v6's `shared_accounts_route`. Same argument layout, same + /// account order at the head, but executes a deterministic price multiply + /// instead of a real route. The `_route_plan_len`, `_quoted_out_amount`, + /// `_slippage_bps` and `_platform_fee_bps` arguments are accepted but + /// ignored — the mock's "route" is the single Switchboard price. + pub fn shared_accounts_route( + ctx: Context, + _id: u8, + _route_plan_len: u8, + in_amount: u64, + _quoted_out_amount: u64, + _slippage_bps: u16, + _platform_fee_bps: u8, + ) -> Result<()> { + // Decode the mock Switchboard feed from the dedicated account slot. + // Anchor account layout is `[8-byte discriminator | borsh struct]`. + // The vault passes the same feed account here as it does for its own + // pre-flight price check, so prices are consistent. + let feed_account = &ctx.accounts.price_feed; + let feed_data = feed_account.try_borrow_data()?; + require!( + feed_data.len() + >= MOCK_FEED_DISCRIMINATOR_LENGTH + MOCK_FEED_PAYLOAD_LENGTH, + MockJupiterError::FeedDataTooShort + ); + // Skip the 8-byte Anchor discriminator and decode the fixed-layout + // payload: 32 (authority) + 16 (price i128) + 4 (scale u32) + + // 8 (last_update_slot u64). + let payload = + &feed_data[MOCK_FEED_DISCRIMINATOR_LENGTH..MOCK_FEED_DISCRIMINATOR_LENGTH + + MOCK_FEED_PAYLOAD_LENGTH]; + let price_bytes: [u8; 16] = payload[32..48] + .try_into() + .map_err(|_| MockJupiterError::FeedDataTooShort)?; + let price = i128::from_le_bytes(price_bytes); + let scale_bytes: [u8; 4] = payload[48..52] + .try_into() + .map_err(|_| MockJupiterError::FeedDataTooShort)?; + let scale = u32::from_le_bytes(scale_bytes); + drop(feed_data); + + require!(price > 0, MockJupiterError::NonPositivePrice); + + // Pull the user's volatile tokens into the mock pool. + let cpi_in = CpiContext::new( + ctx.accounts.token_program.key(), + TransferChecked { + from: ctx.accounts.source_token_account.to_account_info(), + mint: ctx.accounts.input_mint_decimals.to_account_info(), + to: ctx.accounts.program_source_token_account.to_account_info(), + authority: ctx.accounts.user_transfer_authority.to_account_info(), + }, + ); + token::transfer_checked(cpi_in, in_amount, ctx.accounts.input_mint_decimals.decimals)?; + + // Compute the stable amount the user receives. + // + // `price` has `scale` decimal places (e.g. scale=8, price=200_00000000 + // means $200). `in_amount` is in the input mint's smallest units + // (e.g. lamports for SOL). The output token has its own decimals on + // its mint; the caller passes them explicitly so this mock can scale + // correctly without doing a CPI to the mint. + // + // out_amount = in_amount * price * 10^output_decimals + // / (10^scale * 10^input_decimals) + let in_decimals = ctx.accounts.input_mint_decimals.decimals as u32; + let out_decimals = ctx.accounts.output_mint_decimals.decimals as u32; + + let in_amount_u128 = in_amount as u128; + let price_u128 = u128::try_from(price) + .map_err(|_| MockJupiterError::NonPositivePrice)?; + let numerator = in_amount_u128 + .checked_mul(price_u128) + .ok_or(MockJupiterError::MathOverflow)? + .checked_mul(ten_pow(out_decimals)?) + .ok_or(MockJupiterError::MathOverflow)?; + let denominator = ten_pow(scale)? + .checked_mul(ten_pow(in_decimals)?) + .ok_or(MockJupiterError::MathOverflow)?; + let out_amount_u128 = numerator + .checked_div(denominator) + .ok_or(MockJupiterError::MathOverflow)?; + let out_amount: u64 = out_amount_u128 + .try_into() + .map_err(|_| MockJupiterError::MathOverflow)?; + + // Push the stable tokens back to the user from the mock pool. + // The pool ATA is owned by a PDA so we sign for it. + let pool_authority_bump = ctx.bumps.pool_authority; + let signer_seeds: &[&[&[u8]]] = + &[&[POOL_AUTHORITY_SEED, &[pool_authority_bump]]]; + let cpi_out = CpiContext::new_with_signer( + ctx.accounts.token_program.key(), + TransferChecked { + from: ctx + .accounts + .program_destination_token_account + .to_account_info(), + mint: ctx.accounts.output_mint_decimals.to_account_info(), + to: ctx.accounts.destination_token_account.to_account_info(), + authority: ctx.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ); + token::transfer_checked(cpi_out, out_amount, ctx.accounts.output_mint_decimals.decimals)?; + Ok(()) + } + + /// Convenience instruction so tests can derive a stable PDA-owned pool + /// authority without rolling their own keypair scheme. Not part of the + /// Jupiter API surface. + pub fn initialize_pool_authority(_ctx: Context) -> Result<()> { + Ok(()) + } +} + +/// 8-byte Anchor discriminator length. Anchor accounts and Anchor instructions +/// both prefix their serialised data with an 8-byte discriminator, so this +/// constant is shared. +pub const MOCK_FEED_DISCRIMINATOR_LENGTH: usize = 8; +/// Fixed payload length of `mock_switchboard::MockFeed`: +/// 32 (authority Pubkey) + 16 (price i128) + 4 (scale u32) + 8 (last_update_slot u64). +pub const MOCK_FEED_PAYLOAD_LENGTH: usize = 32 + 16 + 4 + 8; + +/// PDA seed for the mock pool authority. Tests fund the mock pool ATAs owned +/// by this PDA so the pool has stables to disburse. +pub const POOL_AUTHORITY_SEED: &[u8] = b"mock-jupiter-pool"; + +fn ten_pow(power: u32) -> Result { + 10u128 + .checked_pow(power) + .ok_or_else(|| error!(MockJupiterError::MathOverflow)) +} + +/// Stub PDA the mock pool ATAs are owned by. Holds no state; existence makes +/// it a valid signer authority for token-transfer CPIs out of pool ATAs. +#[account] +pub struct PoolAuthority {} + +#[derive(Accounts)] +pub struct InitializePoolAuthority<'info> { + /// CHECK: PDA derived from POOL_AUTHORITY_SEED; never read or written. + /// Existence as an account is incidental — Anchor still requires us to + /// declare it, but it doesn't need any data. + #[account( + seeds = [POOL_AUTHORITY_SEED], + bump, + )] + pub pool_authority: UncheckedAccount<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct SharedAccountsRoute<'info> { + pub token_program: Program<'info, Token>, + + /// User signing for the swap. In Jupiter this is `userTransferAuthority`; + /// for the vault path this will be the vault PDA signing for itself. + pub user_transfer_authority: Signer<'info>, + + /// User's source token account (vault's volatile ATA for our use). + #[account(mut)] + pub source_token_account: Box>, + + /// Mock pool's input token account (receives `in_amount`). + #[account(mut)] + pub program_source_token_account: Box>, + + /// Mock pool's output token account (pays out the stable). + #[account(mut)] + pub program_destination_token_account: Box>, + + /// User's destination token account (vault's stable ATA for our use). + #[account(mut)] + pub destination_token_account: Box>, + + /// CHECK: read-only price feed; payload layout is validated when read. + pub price_feed: UncheckedAccount<'info>, + + /// Decimal-only view of input mint. We just need `decimals`. + pub input_mint_decimals: Box>, + /// Decimal-only view of output mint. + pub output_mint_decimals: Box>, + + /// CHECK: PDA that owns the pool ATAs. + #[account( + seeds = [POOL_AUTHORITY_SEED], + bump, + )] + pub pool_authority: UncheckedAccount<'info>, +} + +#[error_code] +pub enum MockJupiterError { + #[msg("Mock Switchboard feed account data is shorter than expected.")] + FeedDataTooShort, + #[msg("Mock Switchboard feed reported a non-positive price.")] + NonPositivePrice, + #[msg("Math overflow while computing swap output.")] + MathOverflow, +} diff --git a/tokens/stop-loss-vault/anchor/programs/mock-switchboard/Cargo.toml b/tokens/stop-loss-vault/anchor/programs/mock-switchboard/Cargo.toml new file mode 100644 index 00000000..5f904834 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/mock-switchboard/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "mock-switchboard" +version = "0.1.0" +description = "Teaching mock of a Switchboard On-Demand feed account. Stores a single i128 price + scale + slot. Real Switchboard On-Demand verifies an Ed25519 signed update; the mock just lets tests set the price directly. NOT FOR PRODUCTION." +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "mock_switchboard" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tokens/stop-loss-vault/anchor/programs/mock-switchboard/src/lib.rs b/tokens/stop-loss-vault/anchor/programs/mock-switchboard/src/lib.rs new file mode 100644 index 00000000..8d90af9c --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/mock-switchboard/src/lib.rs @@ -0,0 +1,89 @@ +//! Mock Switchboard On-Demand feed for testing the stop-loss vault. +//! +//! Real Switchboard On-Demand feeds are program-owned accounts whose data is +//! produced by an offchain oracle network and verified onchain via Ed25519 +//! signatures over the latest price update. That verification path is +//! out-of-scope for this teaching example, so this mock stores a single price +//! the test harness writes directly, plus the slot the update happened in. +//! +//! The onchain reader (`stop-loss-vault::convert_if_triggered`) reads the +//! mock feed the same way it would read a real feed: load the account, decode +//! the layout, read `price` and `last_update_slot`. Swap this program ID for +//! `SBondMDrcV3K4kxZR1HNVT7osZxAHVHgYXL5Ze1oMUv` (Switchboard On-Demand) and +//! adapt the layout to consume real feeds in production. +//! +//! NOT FOR PRODUCTION. +use anchor_lang::prelude::*; + +declare_id!("GAbm8tcMimkhYsQZm24N3Ev1kuWbTKXkTQ1gQEpfJ9Gg"); + +#[program] +pub mod mock_switchboard { + use super::*; + + /// Initialise the mock feed with an initial price. The signer becomes the + /// authority allowed to push later price updates. + pub fn initialize_feed( + ctx: Context, + price: i128, + scale: u32, + ) -> Result<()> { + let feed = &mut ctx.accounts.feed; + feed.authority = ctx.accounts.authority.key(); + feed.price = price; + feed.scale = scale; + feed.last_update_slot = Clock::get()?.slot; + Ok(()) + } + + /// Push a new price to the mock feed. In real Switchboard this would be a + /// signed update from the oracle network; here it's just an authority-gated + /// write, because the goal is to drive deterministic test scenarios. + pub fn set_price(ctx: Context, price: i128) -> Result<()> { + let feed = &mut ctx.accounts.feed; + feed.price = price; + feed.last_update_slot = Clock::get()?.slot; + Ok(()) + } +} + +#[derive(Accounts)] +pub struct InitializeFeed<'info> { + #[account( + init, + payer = authority, + space = MockFeed::DISCRIMINATOR.len() + MockFeed::INIT_SPACE, + )] + pub feed: Account<'info, MockFeed>, + + #[account(mut)] + pub authority: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct SetPrice<'info> { + #[account( + mut, + has_one = authority, + )] + pub feed: Account<'info, MockFeed>, + + pub authority: Signer<'info>, +} + +/// Mock of a Switchboard On-Demand feed. Real feeds carry many more fields +/// (median, range, sample window, signatures) — this is the bare minimum the +/// vault needs to do a price comparison. +#[derive(InitSpace)] +#[account] +pub struct MockFeed { + pub authority: Pubkey, + /// Signed 128-bit fixed-point price. Real Switchboard prices are also i128. + pub price: i128, + /// Number of decimal places implied by `price`. E.g. `scale = 8` means + /// `price = 200 * 10^8` represents $200.00000000. + pub scale: u32, + pub last_update_slot: u64, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/Cargo.toml b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/Cargo.toml new file mode 100644 index 00000000..7babe4b0 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "stop-loss-vault" +version = "0.1.0" +description = "Onchain stop-loss vault: permissionlessly converts a volatile SPL token into a stable SPL token when a Switchboard On-Demand price feed drops below a user-set threshold." +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "stop_loss_vault" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +# anchor-spl's `idl-build` is required so anchor-spl account types resolve in the IDL. +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + + +[dependencies] +anchor-lang = "1.0.0" +anchor-spl = "1.0.0" + +[dev-dependencies] +litesvm = "0.10.0" +solana-message = "3.0.1" +solana-transaction = "3.0.2" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +borsh = "1.5.1" +# Test-only stand-ins for the Switchboard feed and the Jupiter swap aggregator. +# These MUST stay out of [dependencies]: a normal dependency on mock-jupiter +# with `no-entrypoint` makes Cargo unify that feature across the workspace and +# strip mock-jupiter's own entrypoint, leaving a stub `.so` that can't execute. +# The program builds its swap CPI by hand and needs neither crate at runtime. +mock-switchboard = { path = "../mock-switchboard", features = ["no-entrypoint"] } +mock-jupiter = { path = "../mock-jupiter", features = ["no-entrypoint"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/constants.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/constants.rs new file mode 100644 index 00000000..e82e857e --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/constants.rs @@ -0,0 +1,27 @@ +use anchor_lang::prelude::*; + +/// Default crank cadence baked into `initialize_vault` when the caller passes +/// `0` for `crank_interval_seconds`. Ten minutes is short enough to make the +/// flash-crash window small in normal markets but long enough to keep TukTuk +/// task costs low. +#[constant] +pub const DEFAULT_CRANK_INTERVAL_SECONDS: u32 = 600; + +/// Maximum age, in slots, of a price update `convert_if_triggered` will act on. +/// 150 slots is roughly one minute at ~400ms/slot. A Switchboard On-Demand +/// price is pulled fresh at crank time, so in normal operation the update is +/// only a few slots old; this bound makes the handler fail closed if the feed +/// has stopped updating rather than swap on a stale price. +pub const MAX_PRICE_STALENESS_SLOTS: u64 = 150; + +/// 8-byte Anchor discriminator length. Anchor accounts (and Anchor +/// instructions) both prefix their serialised data with an 8-byte +/// discriminator, so this constant is shared. +pub const ANCHOR_DISCRIMINATOR_LENGTH: usize = 8; + +/// Bytes the mock Switchboard feed lays out after the discriminator: +/// 32 (authority Pubkey) + 16 (price i128) + 4 (scale u32) + 8 (last_update_slot u64). +/// +/// In production this is replaced by Switchboard On-Demand's `PullFeedAccountData` +/// layout — see `switchboard-on-demand` crate. +pub const MOCK_FEED_PAYLOAD_LENGTH: usize = 32 + 16 + 4 + 8; diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/error.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/error.rs new file mode 100644 index 00000000..ef88f76c --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/error.rs @@ -0,0 +1,31 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum StopLossError { + #[msg("Oracle reported a price above the stop-loss threshold; no conversion needed.")] + PriceAboveThreshold, + + #[msg("Oracle feed account is shorter than expected; refusing to read.")] + FeedDataTooShort, + + #[msg("Oracle reported a non-positive price.")] + NonPositivePrice, + + #[msg("Oracle price update is older than the maximum accepted staleness.")] + StalePrice, + + #[msg("Vault has not been triggered yet; stables are not available to withdraw.")] + VaultNotTriggered, + + #[msg("Vault has already triggered; cannot deposit, re-arm, or change threshold.")] + VaultAlreadyTriggered, + + #[msg("Vault holds no volatile balance to convert.")] + EmptyVault, + + #[msg("Math overflow.")] + MathOverflow, + + #[msg("Caller is not the vault owner.")] + Unauthorized, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions.rs new file mode 100644 index 00000000..6f267c8e --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions.rs @@ -0,0 +1,25 @@ +pub mod convert_if_triggered; +pub mod deposit; +pub mod initialize_vault; +pub mod update_threshold; +pub mod withdraw_stables; +pub mod withdraw_volatile; + +// Glob-export each instruction module so the `#[program]` macro can resolve +// the `__client_accounts_*` helper modules it generates per instruction. +// We accept the `ambiguous_glob_reexports` warning over `handler` because +// every handler is referenced from `lib.rs` by its full module path +// (`instructions::deposit::handler`, etc.), so the glob ambiguity is never +// actually resolved against a `handler` symbol at the crate root. +#[allow(ambiguous_glob_reexports)] +pub use convert_if_triggered::*; +#[allow(ambiguous_glob_reexports)] +pub use deposit::*; +#[allow(ambiguous_glob_reexports)] +pub use initialize_vault::*; +#[allow(ambiguous_glob_reexports)] +pub use update_threshold::*; +#[allow(ambiguous_glob_reexports)] +pub use withdraw_stables::*; +#[allow(ambiguous_glob_reexports)] +pub use withdraw_volatile::*; diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/convert_if_triggered.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/convert_if_triggered.rs new file mode 100644 index 00000000..4c605f5e --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/convert_if_triggered.rs @@ -0,0 +1,241 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program::invoke_signed; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::{ + ANCHOR_DISCRIMINATOR_LENGTH, MAX_PRICE_STALENESS_SLOTS, MOCK_FEED_PAYLOAD_LENGTH, +}; +use crate::error::StopLossError; +use crate::state::Vault; + +/// First 8 bytes of `sha256("global:shared_accounts_route")` — the Anchor +/// instruction-discriminator scheme. This is Jupiter v6's published +/// `shared_accounts_route` discriminator, and the `mock-jupiter` stand-in (an +/// Anchor program with the same instruction name) derives the identical value, +/// so this handler targets both without depending on either crate. Reproduce: +/// python3 -c "import hashlib; print(list(hashlib.sha256(b'global:shared_accounts_route').digest()[:8]))" +const SHARED_ACCOUNTS_ROUTE_DISCRIMINATOR: [u8; ANCHOR_DISCRIMINATOR_LENGTH] = + [193, 32, 155, 51, 65, 214, 156, 129]; + +/// Permissionless: anyone can crank this — typically a TukTuk worker. Reads +/// the Switchboard On-Demand feed, compares to `vault.threshold_price`, and +/// if (and only if) the latest price has fallen at or below the threshold, +/// CPIs Jupiter (mock or real) with the vault's entire volatile balance. +/// +/// # Known limitation: flash-crash gap between cranks +/// +/// This is a discrete-time stop-loss. The crank runs every +/// `crank_interval_seconds` (default 600s, set by TukTuk). If the price +/// crashes through the threshold AND recovers above it between two +/// consecutive cranks, this instruction will never see the crash and will +/// not convert. That is the cost of doing stop-loss permissionlessly without +/// continuous orderbook monitoring; the README's "Limitations" section walks +/// through the tradeoff and `test_flash_crash_between_cranks_misses_trigger` +/// demonstrates it explicitly. Pick `crank_interval_seconds` accordingly. +/// +/// Real Switchboard On-Demand price updates are passed in the +/// `switchboard_price_update_data` argument and verified onchain via Ed25519. +/// In this teaching example the mock feed is read directly from the account +/// data, so the argument is accepted but not yet wired to verification. +pub fn handler( + ctx: Context, + _switchboard_price_update_data: Vec, +) -> Result<()> { + require!( + !ctx.accounts.vault.triggered, + StopLossError::VaultAlreadyTriggered + ); + + // Read the oracle price out of the feed account. + // + // TODO: replace this direct-read with Switchboard On-Demand's verified- + // update path. The production handler should call + // `switchboard_on_demand::PullFeedAccountData::parse_and_verify(...)` over + // `_switchboard_price_update_data` so the onchain logic only trusts + // signed price updates. For tests we read the mock layout directly: + let feed_account = &ctx.accounts.oracle_feed; + require_keys_eq!( + feed_account.key(), + ctx.accounts.vault.oracle_feed, + StopLossError::Unauthorized + ); + let feed_data = feed_account.try_borrow_data()?; + require!( + feed_data.len() >= ANCHOR_DISCRIMINATOR_LENGTH + MOCK_FEED_PAYLOAD_LENGTH, + StopLossError::FeedDataTooShort + ); + let payload = &feed_data[ANCHOR_DISCRIMINATOR_LENGTH + ..ANCHOR_DISCRIMINATOR_LENGTH + MOCK_FEED_PAYLOAD_LENGTH]; + // 32 (authority) + price (16) + scale (4) + last_update_slot (8). + let price_bytes: [u8; 16] = payload[32..48] + .try_into() + .map_err(|_| StopLossError::FeedDataTooShort)?; + let price = i128::from_le_bytes(price_bytes); + let last_update_slot_bytes: [u8; 8] = payload[52..60] + .try_into() + .map_err(|_| StopLossError::FeedDataTooShort)?; + let last_update_slot = u64::from_le_bytes(last_update_slot_bytes); + drop(feed_data); + + require!(price > 0, StopLossError::NonPositivePrice); + + // Freshness: refuse to act on a price the feed hasn't refreshed recently. + // `saturating_sub` floors the age at 0, so a feed slot ahead of the local + // clock reads as fresh rather than wrapping into a huge age. + let current_slot = Clock::get()?.slot; + require!( + current_slot.saturating_sub(last_update_slot) <= MAX_PRICE_STALENESS_SLOTS, + StopLossError::StalePrice + ); + + // Fire condition: price at or below the threshold. A price strictly above + // the threshold leaves the vault armed and reverts with PriceAboveThreshold. + require!( + price <= ctx.accounts.vault.threshold_price, + StopLossError::PriceAboveThreshold + ); + + // CPI into the swap aggregator with the vault's full volatile balance. + let in_amount = ctx.accounts.vault_volatile_account.amount; + require!(in_amount > 0, StopLossError::EmptyVault); + + // Build the swap aggregator's `shared_accounts_route` instruction by hand, + // so the same code targets the real Jupiter v6 program in production and + // the `mock-jupiter` stand-in under test — only the `swap_program` account + // passed at call time changes. + let swap_program_id = ctx.accounts.swap_program.key(); + + let discriminator: &[u8] = &SHARED_ACCOUNTS_ROUTE_DISCRIMINATOR; + + // Argument layout: id (u8), route_plan_len (u8), in_amount (u64), + // quoted_out_amount (u64), slippage_bps (u16), platform_fee_bps (u8). + // The mock ignores everything except `in_amount`; real Jupiter requires + // accurate values for the others. + let mut instruction_data = Vec::with_capacity(8 + 1 + 1 + 8 + 8 + 2 + 1); + instruction_data.extend_from_slice(discriminator); + instruction_data.push(0u8); // id + instruction_data.push(1u8); // route_plan_len — one hop in the mock + instruction_data.extend_from_slice(&in_amount.to_le_bytes()); + // quoted_out_amount: 0 means "no quote expectation" for the mock; real + // Jupiter would reject this. + instruction_data.extend_from_slice(&0u64.to_le_bytes()); + instruction_data.extend_from_slice(&0u16.to_le_bytes()); // slippage_bps + instruction_data.push(0u8); // platform_fee_bps + + let metas = vec![ + AccountMeta::new_readonly(ctx.accounts.token_program.key(), false), + // user_transfer_authority — the vault PDA signs for itself. + AccountMeta::new_readonly(ctx.accounts.vault.key(), true), + AccountMeta::new(ctx.accounts.vault_volatile_account.key(), false), + AccountMeta::new(ctx.accounts.pool_volatile_account.key(), false), + AccountMeta::new(ctx.accounts.pool_stable_account.key(), false), + AccountMeta::new(ctx.accounts.vault_stable_account.key(), false), + AccountMeta::new_readonly(ctx.accounts.oracle_feed.key(), false), + AccountMeta::new_readonly(ctx.accounts.volatile_mint.key(), false), + AccountMeta::new_readonly(ctx.accounts.stable_mint.key(), false), + AccountMeta::new_readonly(ctx.accounts.pool_authority.key(), false), + ]; + + let instruction = Instruction { + program_id: swap_program_id, + accounts: metas, + data: instruction_data, + }; + + let owner_key = ctx.accounts.vault.owner; + let bump = ctx.accounts.vault.bump; + let signer_seeds: &[&[&[u8]]] = + &[&[Vault::SEED_PREFIX, owner_key.as_ref(), &[bump]]]; + + invoke_signed( + &instruction, + &[ + ctx.accounts.token_program.to_account_info(), + ctx.accounts.vault.to_account_info(), + ctx.accounts.vault_volatile_account.to_account_info(), + ctx.accounts.pool_volatile_account.to_account_info(), + ctx.accounts.pool_stable_account.to_account_info(), + ctx.accounts.vault_stable_account.to_account_info(), + ctx.accounts.oracle_feed.to_account_info(), + ctx.accounts.volatile_mint.to_account_info(), + ctx.accounts.stable_mint.to_account_info(), + ctx.accounts.pool_authority.to_account_info(), + ctx.accounts.swap_program.to_account_info(), + ], + signer_seeds, + )?; + + ctx.accounts.vault.triggered = true; + Ok(()) +} + +#[derive(Accounts)] +pub struct ConvertIfTriggeredAccountConstraints<'info> { + /// PDA holding the volatile stash. Note: NO `has_one = owner` here — the + /// crank is permissionless. The owner field is read for the signer seeds + /// only. + #[account( + mut, + seeds = [Vault::SEED_PREFIX, vault.owner.as_ref()], + bump = vault.bump, + has_one = volatile_mint, + has_one = stable_mint, + has_one = oracle_feed, + )] + pub vault: Box>, + + // Heavy account wrappers are boxed to keep this constraints struct off the + // BPF stack. Without these boxes the generated `try_accounts` function + // exceeds the 4096-byte SBF stack frame. + pub volatile_mint: Box>, + + pub stable_mint: Box>, + + /// CHECK: matched against `vault.oracle_feed` via `has_one`; layout is + /// validated when the data is read. + pub oracle_feed: UncheckedAccount<'info>, + + #[account( + mut, + associated_token::mint = volatile_mint, + associated_token::authority = vault, + )] + pub vault_volatile_account: Box>, + + #[account( + mut, + associated_token::mint = stable_mint, + associated_token::authority = vault, + )] + pub vault_stable_account: Box>, + + /// Mock pool's input-mint ATA (volatile token). Owned by the swap + /// program's pool authority. + #[account( + mut, + token::mint = volatile_mint, + )] + pub pool_volatile_account: Box>, + + /// Mock pool's output-mint ATA (stable token). + #[account( + mut, + token::mint = stable_mint, + )] + pub pool_stable_account: Box>, + + /// CHECK: pool authority PDA; validated by the swap program. + pub pool_authority: UncheckedAccount<'info>, + + /// CHECK: swap program. In tests this is `mock_jupiter::ID`; in + /// production replace it with Jupiter v6's program ID. + pub swap_program: UncheckedAccount<'info>, + + /// Anyone signs and pays — typically a TukTuk worker. This is the + /// permissionless entry point. + #[account(mut)] + pub cranker: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/deposit.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/deposit.rs new file mode 100644 index 00000000..dd0d1108 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/deposit.rs @@ -0,0 +1,59 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::error::StopLossError; +use crate::state::Vault; + +/// Move `amount` of the volatile token from the owner's ATA into the vault's +/// volatile ATA. The vault must not already have triggered. +pub fn handler(ctx: Context, amount: u64) -> Result<()> { + require!( + !ctx.accounts.vault.triggered, + StopLossError::VaultAlreadyTriggered + ); + + let cpi_context = CpiContext::new( + ctx.accounts.token_program.key(), + TransferChecked { + from: ctx.accounts.owner_volatile_account.to_account_info(), + mint: ctx.accounts.volatile_mint.to_account_info(), + to: ctx.accounts.vault_volatile_account.to_account_info(), + authority: ctx.accounts.owner.to_account_info(), + }, + ); + token_interface::transfer_checked(cpi_context, amount, ctx.accounts.volatile_mint.decimals)?; + Ok(()) +} + +#[derive(Accounts)] +pub struct DepositAccountConstraints<'info> { + #[account( + mut, + seeds = [Vault::SEED_PREFIX, owner.key().as_ref()], + bump = vault.bump, + has_one = owner @ StopLossError::Unauthorized, + has_one = volatile_mint, + )] + pub vault: Account<'info, Vault>, + + pub volatile_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = volatile_mint, + associated_token::authority = vault, + )] + pub vault_volatile_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = volatile_mint, + associated_token::authority = owner, + )] + pub owner_volatile_account: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/initialize_vault.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/initialize_vault.rs new file mode 100644 index 00000000..d2e780d5 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/initialize_vault.rs @@ -0,0 +1,88 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::constants::DEFAULT_CRANK_INTERVAL_SECONDS; +use crate::state::Vault; + +/// Create a new vault for `owner` and the (volatile, stable) pair. +/// +/// The vault account is a PDA at `[b"vault", owner.key().as_ref()]`. Its +/// associated token accounts for the volatile and stable mints are created +/// here so deposit and conversion can run with one fewer instruction. +/// +/// TukTuk task registration is intentionally stubbed — see the inline TODO. +/// The owner can do it offchain too; the vault just records the resulting +/// task pubkey for discoverability. +pub fn handler( + ctx: Context, + threshold_price: i128, + crank_interval_seconds: u32, + tuktuk_task: Pubkey, +) -> Result<()> { + let vault = &mut ctx.accounts.vault; + vault.owner = ctx.accounts.owner.key(); + vault.volatile_mint = ctx.accounts.volatile_mint.key(); + vault.stable_mint = ctx.accounts.stable_mint.key(); + vault.oracle_feed = ctx.accounts.oracle_feed.key(); + vault.threshold_price = threshold_price; + vault.crank_interval_seconds = if crank_interval_seconds == 0 { + DEFAULT_CRANK_INTERVAL_SECONDS + } else { + crank_interval_seconds + }; + vault.tuktuk_task = tuktuk_task; + vault.triggered = false; + vault.bump = ctx.bumps.vault; + + // TODO: TukTuk task registration — see github.com/helium/tuktuk for the + // real CPI. The production version of this handler should CPI into + // TukTuk's `task_init` here so the task is created atomically with the + // vault. For this teaching example, we accept the `tuktuk_task` pubkey as + // an input the owner has pre-created (or zeroed out for tests that don't + // exercise the scheduler). + Ok(()) +} + +#[derive(Accounts)] +pub struct InitializeVaultAccountConstraints<'info> { + #[account( + init, + payer = owner, + space = Vault::DISCRIMINATOR.len() + Vault::INIT_SPACE, + seeds = [Vault::SEED_PREFIX, owner.key().as_ref()], + bump, + )] + pub vault: Account<'info, Vault>, + + pub volatile_mint: InterfaceAccount<'info, Mint>, + + pub stable_mint: InterfaceAccount<'info, Mint>, + + /// CHECK: arbitrary Switchboard feed pubkey; layout is verified at read + /// time inside `convert_if_triggered`. + pub oracle_feed: UncheckedAccount<'info>, + + #[account( + init, + payer = owner, + associated_token::mint = volatile_mint, + associated_token::authority = vault, + )] + pub vault_volatile_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + init, + payer = owner, + associated_token::mint = stable_mint, + associated_token::authority = vault, + )] + pub vault_stable_account: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/update_threshold.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/update_threshold.rs new file mode 100644 index 00000000..32dfc4aa --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/update_threshold.rs @@ -0,0 +1,41 @@ +use anchor_lang::prelude::*; + +use crate::error::StopLossError; +use crate::state::Vault; + +/// Trail the stop-loss threshold up (or down) and/or change the suggested +/// crank cadence. Both arguments are optional so the owner can change one +/// without resending the other; a single combined ix keeps the onchain +/// surface small. +/// +/// Refuses to mutate once the vault has triggered: at that point the vault is +/// effectively a stable-token wallet and there's nothing left to protect. +pub fn handler( + ctx: Context, + new_threshold_price: Option, + new_crank_interval_seconds: Option, +) -> Result<()> { + let vault = &mut ctx.accounts.vault; + require!(!vault.triggered, StopLossError::VaultAlreadyTriggered); + + if let Some(threshold) = new_threshold_price { + vault.threshold_price = threshold; + } + if let Some(interval) = new_crank_interval_seconds { + vault.crank_interval_seconds = interval; + } + Ok(()) +} + +#[derive(Accounts)] +pub struct UpdateThresholdAccountConstraints<'info> { + #[account( + mut, + seeds = [Vault::SEED_PREFIX, owner.key().as_ref()], + bump = vault.bump, + has_one = owner @ StopLossError::Unauthorized, + )] + pub vault: Account<'info, Vault>, + + pub owner: Signer<'info>, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_stables.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_stables.rs new file mode 100644 index 00000000..40a33bff --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_stables.rs @@ -0,0 +1,66 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::error::StopLossError; +use crate::state::Vault; + +/// Owner pulls `amount` of the stable token out of the vault. Only callable +/// after `convert_if_triggered` has fired — until then there are no stables +/// to take. +pub fn handler(ctx: Context, amount: u64) -> Result<()> { + require!( + ctx.accounts.vault.triggered, + StopLossError::VaultNotTriggered + ); + + let owner_key = ctx.accounts.owner.key(); + let bump = ctx.accounts.vault.bump; + let signer_seeds: &[&[&[u8]]] = + &[&[Vault::SEED_PREFIX, owner_key.as_ref(), &[bump]]]; + + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.key(), + TransferChecked { + from: ctx.accounts.vault_stable_account.to_account_info(), + mint: ctx.accounts.stable_mint.to_account_info(), + to: ctx.accounts.owner_stable_account.to_account_info(), + authority: ctx.accounts.vault.to_account_info(), + }, + signer_seeds, + ); + token_interface::transfer_checked(cpi_context, amount, ctx.accounts.stable_mint.decimals)?; + Ok(()) +} + +#[derive(Accounts)] +pub struct WithdrawStablesAccountConstraints<'info> { + #[account( + mut, + seeds = [Vault::SEED_PREFIX, owner.key().as_ref()], + bump = vault.bump, + has_one = owner @ StopLossError::Unauthorized, + has_one = stable_mint, + )] + pub vault: Account<'info, Vault>, + + pub stable_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = stable_mint, + associated_token::authority = vault, + )] + pub vault_stable_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = stable_mint, + associated_token::authority = owner, + )] + pub owner_stable_account: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_volatile.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_volatile.rs new file mode 100644 index 00000000..26a5d8c6 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_volatile.rs @@ -0,0 +1,68 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::error::StopLossError; +use crate::state::Vault; + +/// Owner pulls `amount` of the volatile token back out of the vault. This is +/// the escape hatch for a vault that never triggered: while the price stays +/// above the threshold the deposit would otherwise be locked with no way out. +/// Refused once the vault has triggered — at that point the volatile balance +/// is zero and the position is held in stables (use `withdraw_stables`). +pub fn handler(ctx: Context, amount: u64) -> Result<()> { + require!( + !ctx.accounts.vault.triggered, + StopLossError::VaultAlreadyTriggered + ); + + let owner_key = ctx.accounts.owner.key(); + let bump = ctx.accounts.vault.bump; + let signer_seeds: &[&[&[u8]]] = + &[&[Vault::SEED_PREFIX, owner_key.as_ref(), &[bump]]]; + + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.key(), + TransferChecked { + from: ctx.accounts.vault_volatile_account.to_account_info(), + mint: ctx.accounts.volatile_mint.to_account_info(), + to: ctx.accounts.owner_volatile_account.to_account_info(), + authority: ctx.accounts.vault.to_account_info(), + }, + signer_seeds, + ); + token_interface::transfer_checked(cpi_context, amount, ctx.accounts.volatile_mint.decimals)?; + Ok(()) +} + +#[derive(Accounts)] +pub struct WithdrawVolatileAccountConstraints<'info> { + #[account( + mut, + seeds = [Vault::SEED_PREFIX, owner.key().as_ref()], + bump = vault.bump, + has_one = owner @ StopLossError::Unauthorized, + has_one = volatile_mint, + )] + pub vault: Account<'info, Vault>, + + pub volatile_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = volatile_mint, + associated_token::authority = vault, + )] + pub vault_volatile_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = volatile_mint, + associated_token::authority = owner, + )] + pub owner_volatile_account: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/lib.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/lib.rs new file mode 100644 index 00000000..1b3268d0 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/lib.rs @@ -0,0 +1,88 @@ +//! Stop-loss vault. +//! +//! Holds a single volatile SPL token for one owner and permissionlessly +//! converts it to a single stable SPL token when a Switchboard On-Demand +//! price feed drops below a user-set threshold. TukTuk schedules the +//! permissionless crank. +//! +//! See `README.md` (one directory up) for the architecture overview, +//! limitations, and integration notes. +pub mod constants; +pub mod error; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use constants::*; +pub use instructions::*; +pub use state::*; + +declare_id!("BSzhyK5soR2T3T1LCjwYVybff2D9NowwfFHdVsAwnkmG"); + +#[program] +pub mod stop_loss_vault { + use super::*; + + /// Create a vault for the caller. See + /// `instructions::initialize_vault::handler`. + pub fn initialize_vault( + ctx: Context, + threshold_price: i128, + crank_interval_seconds: u32, + tuktuk_task: Pubkey, + ) -> Result<()> { + instructions::initialize_vault::handler( + ctx, + threshold_price, + crank_interval_seconds, + tuktuk_task, + ) + } + + /// Owner deposits volatile tokens. + pub fn deposit(ctx: Context, amount: u64) -> Result<()> { + instructions::deposit::handler(ctx, amount) + } + + /// Owner adjusts the stop-loss threshold and/or suggested crank cadence. + /// Both fields are optional; pass `None` to leave a field unchanged. + pub fn update_threshold( + ctx: Context, + new_threshold_price: Option, + new_crank_interval_seconds: Option, + ) -> Result<()> { + instructions::update_threshold::handler( + ctx, + new_threshold_price, + new_crank_interval_seconds, + ) + } + + /// Permissionless conversion — see + /// `instructions::convert_if_triggered::handler` for the flash-crash + /// limitation documented at the API level. + pub fn convert_if_triggered( + ctx: Context, + switchboard_price_update_data: Vec, + ) -> Result<()> { + instructions::convert_if_triggered::handler(ctx, switchboard_price_update_data) + } + + /// Owner withdraws stables after a trigger. + pub fn withdraw_stables( + ctx: Context, + amount: u64, + ) -> Result<()> { + instructions::withdraw_stables::handler(ctx, amount) + } + + /// Owner withdraws volatile tokens before a trigger — the escape hatch for + /// a vault that never fires. See `instructions::withdraw_volatile::handler`. + pub fn withdraw_volatile( + ctx: Context, + amount: u64, + ) -> Result<()> { + instructions::withdraw_volatile::handler(ctx, amount) + } +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/state.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/state.rs new file mode 100644 index 00000000..9a0128f5 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/state.rs @@ -0,0 +1,55 @@ +use anchor_lang::prelude::*; + +/// Per-owner stop-loss vault. +/// +/// One vault holds ONE volatile asset and converts it into ONE stable asset +/// when a Switchboard On-Demand price feed reports a price at or below +/// `threshold_price`. The vault is permissionlessly cranked: anyone can call +/// `convert_if_triggered`, but the call only succeeds when the price has +/// crossed the threshold. TukTuk is used in production to schedule that crank +/// every `crank_interval_seconds`. +#[derive(InitSpace)] +#[account] +pub struct Vault { + /// Wallet that initialised the vault. Only this key can deposit, + /// withdraw, update the threshold, or close the vault. + pub owner: Pubkey, + + /// Mint of the volatile asset (e.g. wSOL). + pub volatile_mint: Pubkey, + + /// Mint of the stable asset that the vault converts into (e.g. USDC). + pub stable_mint: Pubkey, + + /// Switchboard On-Demand feed reporting `volatile_mint / USD`. Layout is + /// validated at read time. + pub oracle_feed: Pubkey, + + /// Threshold price expressed in the feed's native scale (e.g. for an + /// 8-decimal feed, `threshold_price = 100_00000000` means $100). When the + /// feed reports `<= threshold_price` the cranker can fire the swap. + /// Stored as `i128` to match Switchboard's price type. + pub threshold_price: i128, + + /// Suggested crank cadence in seconds. The onchain program does NOT + /// enforce this — it's a hint to the offchain cranker (TukTuk) on how + /// often to attempt the conversion. Default in `initialize_vault` is 600. + pub crank_interval_seconds: u32, + + /// Public key of the TukTuk task registered against this vault. Stored so + /// the owner can find / cancel / reconfigure the task offchain. + pub tuktuk_task: Pubkey, + + /// Set to `true` after `convert_if_triggered` fires. Locks the pre-trigger + /// actions (`deposit`, `update_threshold`, `withdraw_volatile`) so the + /// post-trigger vault is simply a stable-token wallet drained via + /// `withdraw_stables`. + pub triggered: bool, + + /// PDA bump for `[b"vault", owner.key().as_ref()]`. + pub bump: u8, +} + +impl Vault { + pub const SEED_PREFIX: &'static [u8] = b"vault"; +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/common/mod.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/common/mod.rs new file mode 100644 index 00000000..fe0a12fa --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/common/mod.rs @@ -0,0 +1,540 @@ +//! Shared test harness for the stop-loss-vault scenarios. +//! +//! The scenarios in `stop_loss_vault_scenarios.rs` read like stories: "Alice +//! deposits 10 SOL, Bob cranks, price drops, Alice pulls out stables". This +//! module hides the LiteSVM plumbing so the scenarios stay readable. +//! +//! The test harness deliberately uses `anchor_lang::solana_program` types +//! everywhere so there's no version skew with Anchor 1.0.2's pubkey/ +//! instruction types. SPL instructions are built by hand (the discriminators +//! and serialisation are trivial) rather than via the older `spl-token` / +//! `spl-associated-token-account` crates, which pull in mismatching versions +//! of `solana-program`. + +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::pubkey::Pubkey; +use anchor_lang::solana_program::rent; +use anchor_lang::solana_program::system_instruction; +use anchor_lang::system_program; +use anchor_lang::{Discriminator, InstructionData, ToAccountMetas}; +use anchor_spl::associated_token::get_associated_token_address; +use borsh::BorshDeserialize; +use litesvm::LiteSVM; +use solana_keypair::Keypair; +use solana_message::{Message, VersionedMessage}; +use solana_signer::Signer; +use solana_transaction::versioned::VersionedTransaction; + +/// Native SOL has 9 decimals (lamports per SOL). Used only to fund accounts +/// with rent and transaction fees. +pub const SOL_DECIMALS: u8 = 9; +/// The volatile token (read it as NVDAx, a tokenized Nvidia share). A +/// 9-decimal stand-in for whatever volatile asset a real vault would hold. +pub const NVDAX_DECIMALS: u8 = 9; +/// USDC has 6 decimals. +pub const USDC_DECIMALS: u8 = 6; +/// Switchboard On-Demand prices come in with 8 decimal places by convention. +/// Real Switchboard feeds expose `scale` directly; the mock uses the same +/// value so the price-comparison logic is identical to production. +pub const ORACLE_SCALE: u32 = 8; + +/// Length of a packed SPL token Mint account, copied from +/// `spl_token::state::Mint::LEN`. Used to compute rent for new mint accounts. +pub const SPL_TOKEN_MINT_LEN: usize = 82; + +/// Convert a USD price (e.g. $200) into the fixed-point i128 the oracle uses. +pub fn dollars_to_oracle_price(dollars: u128) -> i128 { + (dollars * 10u128.pow(ORACLE_SCALE)) as i128 +} + +pub fn sol(amount: u64) -> u64 { + amount * 10u64.pow(SOL_DECIMALS as u32) +} +pub fn nvdax(amount: u64) -> u64 { + amount * 10u64.pow(NVDAX_DECIMALS as u32) +} +pub fn usdc(amount: u64) -> u64 { + amount * 10u64.pow(USDC_DECIMALS as u32) +} + +pub struct TestWorld { + pub svm: LiteSVM, + pub mint_authority: Keypair, + pub volatile_mint: Pubkey, + pub stable_mint: Pubkey, + pub pool_authority: Pubkey, + pub pool_volatile_account: Pubkey, + pub pool_stable_account: Pubkey, + pub feed: Keypair, + pub feed_authority: Keypair, +} + +pub fn new_world() -> TestWorld { + let mut svm = LiteSVM::new(); + svm.add_program( + stop_loss_vault::id(), + include_bytes!("../../../../target/deploy/stop_loss_vault.so"), + ) + .unwrap(); + svm.add_program( + mock_jupiter::id(), + include_bytes!("../../../../target/deploy/mock_jupiter.so"), + ) + .unwrap(); + svm.add_program( + mock_switchboard::id(), + include_bytes!("../../../../target/deploy/mock_switchboard.so"), + ) + .unwrap(); + + let mint_authority = Keypair::new(); + svm.airdrop(&mint_authority.pubkey(), sol(10)).unwrap(); + + let volatile_mint = create_mint(&mut svm, &mint_authority, NVDAX_DECIMALS); + let stable_mint = create_mint(&mut svm, &mint_authority, USDC_DECIMALS); + + let (pool_authority, _bump) = Pubkey::find_program_address( + &[mock_jupiter::POOL_AUTHORITY_SEED], + &mock_jupiter::id(), + ); + + let pool_volatile_account = + create_ata(&mut svm, &mint_authority, &pool_authority, &volatile_mint); + let pool_stable_account = + create_ata(&mut svm, &mint_authority, &pool_authority, &stable_mint); + + // Pre-fund the mock pool with stables so it can pay every test swap. + mint_to( + &mut svm, + &mint_authority, + &stable_mint, + &pool_stable_account, + usdc(1_000_000_000), + ); + + let feed_authority = Keypair::new(); + svm.airdrop(&feed_authority.pubkey(), sol(10)).unwrap(); + let feed = Keypair::new(); + + TestWorld { + svm, + mint_authority, + volatile_mint, + stable_mint, + pool_authority, + pool_volatile_account, + pool_stable_account, + feed, + feed_authority, + } +} + +// ---- minimal SPL Token instruction builders (avoid version-skewed crates) ---- + +/// Token program ID (classic SPL Token, not Token-2022). +pub const TOKEN_PROGRAM_ID: Pubkey = anchor_spl::token::ID; +/// Associated Token Account program ID. +pub const ATA_PROGRAM_ID: Pubkey = anchor_spl::associated_token::ID; + +fn token_initialize_mint_ix( + mint: &Pubkey, + mint_authority: &Pubkey, + decimals: u8, +) -> Instruction { + // SPL Token instruction layout for `InitializeMint`: + // tag (u8 = 0) + // decimals (u8) + // mint_authority (32 bytes) + // freeze_authority option: tag (u8 = 0 / 1) + 32 bytes if Some + let mut data = Vec::with_capacity(1 + 1 + 32 + 1); + data.push(0); + data.push(decimals); + data.extend_from_slice(mint_authority.as_ref()); + data.push(0); // freeze_authority = None + Instruction { + program_id: TOKEN_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(*mint, false), + AccountMeta::new_readonly(rent::ID, false), + ], + data, + } +} + +fn token_mint_to_ix( + mint: &Pubkey, + destination: &Pubkey, + mint_authority: &Pubkey, + amount: u64, +) -> Instruction { + // SPL Token instruction `MintTo` (tag = 7) + u64 amount. + let mut data = Vec::with_capacity(1 + 8); + data.push(7); + data.extend_from_slice(&amount.to_le_bytes()); + Instruction { + program_id: TOKEN_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(*mint, false), + AccountMeta::new(*destination, false), + AccountMeta::new_readonly(*mint_authority, true), + ], + data, + } +} + +fn ata_create_idempotent_ix( + payer: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, +) -> Instruction { + let ata = get_associated_token_address(owner, mint); + // `CreateIdempotent` is discriminator 1; `Create` is 0. Either works here + // but idempotent is safer if a test ever calls twice. + Instruction { + program_id: ATA_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(*owner, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new_readonly(system_program::ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + ], + data: vec![1u8], + } +} + +pub fn create_mint(svm: &mut LiteSVM, mint_authority: &Keypair, decimals: u8) -> Pubkey { + let mint = Keypair::new(); + let rent = svm.minimum_balance_for_rent_exemption(SPL_TOKEN_MINT_LEN); + let create_account_ix = system_instruction::create_account( + &mint_authority.pubkey(), + &mint.pubkey(), + rent, + SPL_TOKEN_MINT_LEN as u64, + &TOKEN_PROGRAM_ID, + ); + let init_mint_ix = token_initialize_mint_ix(&mint.pubkey(), &mint_authority.pubkey(), decimals); + send_tx( + svm, + &[create_account_ix, init_mint_ix], + mint_authority, + &[mint_authority, &mint], + ); + mint.pubkey() +} + +pub fn create_ata( + svm: &mut LiteSVM, + payer: &Keypair, + owner: &Pubkey, + mint: &Pubkey, +) -> Pubkey { + let ata = get_associated_token_address(owner, mint); + let ix = ata_create_idempotent_ix(&payer.pubkey(), owner, mint); + send_tx(svm, &[ix], payer, &[payer]); + ata +} + +pub fn mint_to( + svm: &mut LiteSVM, + mint_authority: &Keypair, + mint: &Pubkey, + destination: &Pubkey, + amount: u64, +) { + let ix = token_mint_to_ix(mint, destination, &mint_authority.pubkey(), amount); + send_tx(svm, &[ix], mint_authority, &[mint_authority]); +} + +/// SPL Token account layout: `mint(32) + owner(32) + amount(u64) + ...`. +/// We only need the amount, at offset 64. +pub fn token_balance(svm: &LiteSVM, ata: &Pubkey) -> u64 { + let account = svm.get_account(ata).expect("ATA missing"); + let amount_bytes: [u8; 8] = account.data[64..72].try_into().unwrap(); + u64::from_le_bytes(amount_bytes) +} + +pub fn vault_address(owner: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[stop_loss_vault::Vault::SEED_PREFIX, owner.as_ref()], + &stop_loss_vault::id(), + ) +} + +pub fn send_tx( + svm: &mut LiteSVM, + instructions: &[Instruction], + payer: &Keypair, + signers: &[&Keypair], +) { + let blockhash = svm.latest_blockhash(); + let msg = Message::new_with_blockhash(instructions, Some(&payer.pubkey()), &blockhash); + let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), signers).unwrap(); + svm.send_transaction(tx).unwrap(); +} + +pub fn try_send_tx( + svm: &mut LiteSVM, + instructions: &[Instruction], + payer: &Keypair, + signers: &[&Keypair], +) -> Result<(), litesvm::types::FailedTransactionMetadata> { + let blockhash = svm.latest_blockhash(); + let msg = Message::new_with_blockhash(instructions, Some(&payer.pubkey()), &blockhash); + let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), signers).unwrap(); + svm.send_transaction(tx).map(|_| ()) +} + +pub fn initialize_feed(world: &mut TestWorld, price: i128) { + let ix_data = mock_switchboard::instruction::InitializeFeed { + price, + scale: ORACLE_SCALE, + } + .data(); + let accounts = mock_switchboard::accounts::InitializeFeed { + feed: world.feed.pubkey(), + authority: world.feed_authority.pubkey(), + system_program: system_program::ID, + } + .to_account_metas(None); + let ix = Instruction { + program_id: mock_switchboard::id(), + accounts, + data: ix_data, + }; + let payer = world.feed_authority.insecure_clone(); + let feed_kp = world.feed.insecure_clone(); + send_tx(&mut world.svm, &[ix], &payer, &[&payer, &feed_kp]); +} + +pub fn set_feed_price(world: &mut TestWorld, price: i128) { + let ix_data = mock_switchboard::instruction::SetPrice { price }.data(); + let accounts = mock_switchboard::accounts::SetPrice { + feed: world.feed.pubkey(), + authority: world.feed_authority.pubkey(), + } + .to_account_metas(None); + let ix = Instruction { + program_id: mock_switchboard::id(), + accounts, + data: ix_data, + }; + let payer = world.feed_authority.insecure_clone(); + send_tx(&mut world.svm, &[ix], &payer, &[&payer]); + // LiteSVM (and real validators) reject byte-identical resent transactions. + // Tests that call `set_feed_price` repeatedly with the same price would + // produce identical bytes; expire the blockhash so the next tx is fresh. + world.svm.expire_blockhash(); +} + +pub fn initialize_vault( + world: &mut TestWorld, + owner: &Keypair, + threshold_price: i128, + crank_interval_seconds: u32, +) -> Pubkey { + let (vault, _bump) = vault_address(&owner.pubkey()); + let vault_volatile = get_associated_token_address(&vault, &world.volatile_mint); + let vault_stable = get_associated_token_address(&vault, &world.stable_mint); + + let ix_data = stop_loss_vault::instruction::InitializeVault { + threshold_price, + crank_interval_seconds, + tuktuk_task: Pubkey::default(), + } + .data(); + let accounts = stop_loss_vault::accounts::InitializeVaultAccountConstraints { + vault, + volatile_mint: world.volatile_mint, + stable_mint: world.stable_mint, + oracle_feed: world.feed.pubkey(), + vault_volatile_account: vault_volatile, + vault_stable_account: vault_stable, + owner: owner.pubkey(), + token_program: TOKEN_PROGRAM_ID, + associated_token_program: ATA_PROGRAM_ID, + system_program: system_program::ID, + } + .to_account_metas(None); + let ix = Instruction { + program_id: stop_loss_vault::id(), + accounts, + data: ix_data, + }; + send_tx(&mut world.svm, &[ix], owner, &[owner]); + vault +} + +pub fn deposit(world: &mut TestWorld, owner: &Keypair, amount: u64) { + let (vault, _bump) = vault_address(&owner.pubkey()); + let vault_volatile = get_associated_token_address(&vault, &world.volatile_mint); + let owner_volatile = get_associated_token_address(&owner.pubkey(), &world.volatile_mint); + let ix_data = stop_loss_vault::instruction::Deposit { amount }.data(); + let accounts = stop_loss_vault::accounts::DepositAccountConstraints { + vault, + volatile_mint: world.volatile_mint, + vault_volatile_account: vault_volatile, + owner_volatile_account: owner_volatile, + owner: owner.pubkey(), + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None); + let ix = Instruction { + program_id: stop_loss_vault::id(), + accounts, + data: ix_data, + }; + send_tx(&mut world.svm, &[ix], owner, &[owner]); +} + +pub fn try_convert_if_triggered( + world: &mut TestWorld, + cranker: &Keypair, + vault_owner: &Pubkey, +) -> Result<(), litesvm::types::FailedTransactionMetadata> { + let (vault, _bump) = vault_address(vault_owner); + let vault_volatile = get_associated_token_address(&vault, &world.volatile_mint); + let vault_stable = get_associated_token_address(&vault, &world.stable_mint); + + let ix_data = stop_loss_vault::instruction::ConvertIfTriggered { + switchboard_price_update_data: Vec::new(), + } + .data(); + let accounts = stop_loss_vault::accounts::ConvertIfTriggeredAccountConstraints { + vault, + volatile_mint: world.volatile_mint, + stable_mint: world.stable_mint, + oracle_feed: world.feed.pubkey(), + vault_volatile_account: vault_volatile, + vault_stable_account: vault_stable, + pool_volatile_account: world.pool_volatile_account, + pool_stable_account: world.pool_stable_account, + pool_authority: world.pool_authority, + swap_program: mock_jupiter::id(), + cranker: cranker.pubkey(), + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None); + let ix = Instruction { + program_id: stop_loss_vault::id(), + accounts, + data: ix_data, + }; + try_send_tx(&mut world.svm, &[ix], cranker, &[cranker]) +} + +pub fn try_update_threshold( + world: &mut TestWorld, + caller: &Keypair, + new_threshold_price: Option, + new_crank_interval_seconds: Option, +) -> Result<(), litesvm::types::FailedTransactionMetadata> { + let (vault, _bump) = vault_address(&caller.pubkey()); + let ix_data = stop_loss_vault::instruction::UpdateThreshold { + new_threshold_price, + new_crank_interval_seconds, + } + .data(); + let accounts = stop_loss_vault::accounts::UpdateThresholdAccountConstraints { + vault, + owner: caller.pubkey(), + } + .to_account_metas(None); + let ix = Instruction { + program_id: stop_loss_vault::id(), + accounts, + data: ix_data, + }; + try_send_tx(&mut world.svm, &[ix], caller, &[caller]) +} + +pub fn try_withdraw_volatile( + world: &mut TestWorld, + caller: &Keypair, + vault_owner_for_pda: &Pubkey, + amount: u64, +) -> Result<(), litesvm::types::FailedTransactionMetadata> { + let (vault, _bump) = vault_address(vault_owner_for_pda); + let vault_volatile = get_associated_token_address(&vault, &world.volatile_mint); + let owner_volatile = get_associated_token_address(&caller.pubkey(), &world.volatile_mint); + let ix_data = stop_loss_vault::instruction::WithdrawVolatile { amount }.data(); + let accounts = stop_loss_vault::accounts::WithdrawVolatileAccountConstraints { + vault, + volatile_mint: world.volatile_mint, + vault_volatile_account: vault_volatile, + owner_volatile_account: owner_volatile, + owner: caller.pubkey(), + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None); + let ix = Instruction { + program_id: stop_loss_vault::id(), + accounts, + data: ix_data, + }; + try_send_tx(&mut world.svm, &[ix], caller, &[caller]) +} + +pub fn try_withdraw_stables( + world: &mut TestWorld, + caller: &Keypair, + vault_owner_for_pda: &Pubkey, + amount: u64, +) -> Result<(), litesvm::types::FailedTransactionMetadata> { + let (vault, _bump) = vault_address(vault_owner_for_pda); + let vault_stable = get_associated_token_address(&vault, &world.stable_mint); + let owner_stable = get_associated_token_address(&caller.pubkey(), &world.stable_mint); + let ix_data = stop_loss_vault::instruction::WithdrawStables { amount }.data(); + let accounts = stop_loss_vault::accounts::WithdrawStablesAccountConstraints { + vault, + stable_mint: world.stable_mint, + vault_stable_account: vault_stable, + owner_stable_account: owner_stable, + owner: caller.pubkey(), + token_program: TOKEN_PROGRAM_ID, + } + .to_account_metas(None); + let ix = Instruction { + program_id: stop_loss_vault::id(), + accounts, + data: ix_data, + }; + try_send_tx(&mut world.svm, &[ix], caller, &[caller]) +} + +pub fn fund_with_volatile(world: &mut TestWorld, actor: &Keypair, amount: u64) -> Pubkey { + let ata = create_ata(&mut world.svm, actor, &actor.pubkey(), &world.volatile_mint); + let mint_authority = world.mint_authority.insecure_clone(); + mint_to(&mut world.svm, &mint_authority, &world.volatile_mint, &ata, amount); + ata +} + +pub fn create_stable_ata(world: &mut TestWorld, actor: &Keypair) -> Pubkey { + create_ata(&mut world.svm, actor, &actor.pubkey(), &world.stable_mint) +} + +pub fn vault_state(svm: &LiteSVM, vault: &Pubkey) -> stop_loss_vault::Vault { + let account = svm.get_account(vault).expect("vault missing"); + stop_loss_vault::Vault::try_from_slice( + &account.data[stop_loss_vault::Vault::DISCRIMINATOR.len()..], + ) + .unwrap() +} + +pub fn new_funded_keypair(svm: &mut LiteSVM, lamports: u64) -> Keypair { + let kp = Keypair::new(); + svm.airdrop(&kp.pubkey(), lamports).unwrap(); + kp +} + +/// Advance the SVM clock past the vault's price-staleness bound so a price set +/// before this call reads as stale to `convert_if_triggered`. +pub fn warp_past_price_staleness(world: &mut TestWorld) { + let clock = world + .svm + .get_sysvar::(); + world + .svm + .warp_to_slot(clock.slot + stop_loss_vault::MAX_PRICE_STALENESS_SLOTS + 1); +} diff --git a/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs new file mode 100644 index 00000000..a8b91dd5 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs @@ -0,0 +1,346 @@ +//! Stop-loss vault scenarios. +//! +//! Each test is a story with named actors (Alice, Bob, Carol, Dave), real +//! numbers (USDC 6 decimals, NVDAx 9 decimals, oracle 8 decimals), and a clear +//! beat-by-beat progression a non-engineer can follow. + +// `anchor build` generates the IDL by compiling the crate with the `idl-build` +// feature, which also compiles this integration test. The test `include_bytes!`s +// the three program `.so` files, which don't exist yet during IDL generation — +// so exclude the whole harness from `idl-build`. It still compiles and runs +// under `cargo test` (where `idl-build` is off and the `.so` files exist). +#![cfg(not(feature = "idl-build"))] + +mod common; + +use common::*; +use solana_signer::Signer; + +/// Alice opens a stop-loss vault. NVDAx is at $200, threshold is $100, crank +/// cadence is every 10 minutes. Alice deposits 10 NVDAx into the vault. +#[test] +fn test_alice_initialises_vault_with_100_usd_threshold() { + let mut world = new_world(); + + // NVDAx @ $200 expressed in the oracle's 8-decimal scale. + let starting_price = dollars_to_oracle_price(200); + initialize_feed(&mut world, starting_price); + + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let alice_volatile_ata = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _alice_stable_ata = create_stable_ata(&mut world, &alice); + + // Threshold $100 in oracle scale. Crank every 10 minutes. + let threshold = dollars_to_oracle_price(100); + let crank_interval_seconds = 600; + let vault = initialize_vault(&mut world, &alice, threshold, crank_interval_seconds); + + deposit(&mut world, &alice, nvdax(10)); + + // Vault now holds Alice's 10 NVDAx. + let vault_volatile_ata = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.volatile_mint); + assert_eq!(token_balance(&world.svm, &vault_volatile_ata), nvdax(10)); + // Alice's personal volatile ATA is empty. + assert_eq!(token_balance(&world.svm, &alice_volatile_ata), 0); + + let state = vault_state(&world.svm, &vault); + assert_eq!(state.owner, alice.pubkey()); + assert_eq!(state.threshold_price, threshold); + assert_eq!(state.crank_interval_seconds, crank_interval_seconds); + assert!(!state.triggered); +} + +/// Alice's vault is armed at $100. Bob is the cranker. Hour 1 NVDAx is at $180: +/// Bob's crank reverts. Hour 2 at $150: reverts. Hour 3 at $80: conversion +/// fires. Vault volatile balance \u2192 0, vault stable balance \u2192 10 * $80 = $800 +/// USDC (six decimals). +#[test] +fn test_price_drops_below_threshold_on_third_check_converts_to_stables() { + let mut world = new_world(); + + initialize_feed(&mut world, dollars_to_oracle_price(200)); + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let _ = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _ = create_stable_ata(&mut world, &alice); + let vault = initialize_vault(&mut world, &alice, dollars_to_oracle_price(100), 600); + deposit(&mut world, &alice, nvdax(10)); + + let bob = new_funded_keypair(&mut world.svm, sol(1)); + let vault_volatile = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.volatile_mint); + let vault_stable = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.stable_mint); + + // Hour 1: price $180. Bob cranks, reverts because price > threshold. + set_feed_price(&mut world, dollars_to_oracle_price(180)); + let result = try_convert_if_triggered(&mut world, &bob, &alice.pubkey()); + assert!(result.is_err(), "expected revert at $180 > $100 threshold"); + assert_eq!(token_balance(&world.svm, &vault_volatile), nvdax(10)); + assert_eq!(token_balance(&world.svm, &vault_stable), 0); + + // Hour 2: price $150. Same story. + set_feed_price(&mut world, dollars_to_oracle_price(150)); + let result = try_convert_if_triggered(&mut world, &bob, &alice.pubkey()); + assert!(result.is_err(), "expected revert at $150 > $100 threshold"); + assert_eq!(token_balance(&world.svm, &vault_volatile), nvdax(10)); + + // Hour 3: price $80. NOW the crank fires. + set_feed_price(&mut world, dollars_to_oracle_price(80)); + try_convert_if_triggered(&mut world, &bob, &alice.pubkey()) + .expect("crank should succeed when price drops below threshold"); + + // Vault has been drained of volatile and now holds stables: 10 NVDAx * $80 = $800. + assert_eq!(token_balance(&world.svm, &vault_volatile), 0); + assert_eq!(token_balance(&world.svm, &vault_stable), usdc(800)); + + let state = vault_state(&world.svm, &vault); + assert!(state.triggered); + + // Alice can now withdraw her stables. + try_withdraw_stables(&mut world, &alice, &alice.pubkey(), usdc(800)) + .expect("Alice should be able to withdraw stables after the trigger"); + let alice_stable = + anchor_spl::associated_token::get_associated_token_address(&alice.pubkey(), &world.stable_mint); + assert_eq!(token_balance(&world.svm, &alice_stable), usdc(800)); + assert_eq!(token_balance(&world.svm, &vault_stable), 0); +} + +/// Carol does not own any vault. She tries to withdraw stables from a vault +/// that doesn't exist for her, then from Alice's vault by passing Alice's +/// owner key. Both should fail \u2014 Anchor's signer + has_one + PDA seeds keep +/// the funds safe. +#[test] +fn test_carol_cannot_withdraw_someone_elses_vault() { + let mut world = new_world(); + + initialize_feed(&mut world, dollars_to_oracle_price(200)); + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let _ = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _ = create_stable_ata(&mut world, &alice); + let _vault = initialize_vault(&mut world, &alice, dollars_to_oracle_price(100), 600); + deposit(&mut world, &alice, nvdax(10)); + + // Trigger the vault so it actually has stables to steal. + set_feed_price(&mut world, dollars_to_oracle_price(80)); + let bob = new_funded_keypair(&mut world.svm, sol(1)); + try_convert_if_triggered(&mut world, &bob, &alice.pubkey()).unwrap(); + + // Carol enters. + let carol = new_funded_keypair(&mut world.svm, sol(1)); + let _ = create_stable_ata(&mut world, &carol); + + // Path 1: Carol asks the vault PDA derived from HER OWN owner key. There + // is no vault at that PDA \u2014 deserialise fails. + let result = try_withdraw_stables(&mut world, &carol, &carol.pubkey(), usdc(100)); + assert!( + result.is_err(), + "Carol with her own owner key shouldn't find a vault" + ); + + // Path 2: Carol points at Alice's vault PDA but signs as herself. The + // `has_one = owner` check on the vault fails because Carol's signer is + // not the vault's owner. + let result = try_withdraw_stables(&mut world, &carol, &alice.pubkey(), usdc(100)); + assert!( + result.is_err(), + "Carol shouldn't be able to drain Alice's vault" + ); +} + +/// Alice's vault was opened at $100 threshold when NVDAx was $200. NVDAx rallies +/// to $250. Alice trails the threshold UP to $200 \u2014 she now wants +/// protection at a higher floor. Bob's crank at $220 still reverts. NVDAx +/// finally falls to $180, Bob cranks, conversion fires at the new threshold. +#[test] +fn test_alice_trails_threshold_up_as_price_rises() { + let mut world = new_world(); + + initialize_feed(&mut world, dollars_to_oracle_price(200)); + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let _ = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _ = create_stable_ata(&mut world, &alice); + let vault = initialize_vault(&mut world, &alice, dollars_to_oracle_price(100), 600); + deposit(&mut world, &alice, nvdax(10)); + + let vault_stable = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.stable_mint); + + // Price rallies to $250. Alice trails the threshold up to $200. + set_feed_price(&mut world, dollars_to_oracle_price(250)); + try_update_threshold(&mut world, &alice, Some(dollars_to_oracle_price(200)), None) + .expect("Alice should be able to update her own threshold"); + let state = vault_state(&world.svm, &vault); + assert_eq!(state.threshold_price, dollars_to_oracle_price(200)); + + let bob = new_funded_keypair(&mut world.svm, sol(1)); + + // Bob cranks at $220 \u2014 still above $200 threshold. + set_feed_price(&mut world, dollars_to_oracle_price(220)); + let result = try_convert_if_triggered(&mut world, &bob, &alice.pubkey()); + assert!(result.is_err(), "crank at $220 vs $200 threshold must revert"); + + // Price falls to $180. Now below the trailed threshold. + set_feed_price(&mut world, dollars_to_oracle_price(180)); + try_convert_if_triggered(&mut world, &bob, &alice.pubkey()) + .expect("crank at $180 vs $200 threshold should fire"); + + // 10 NVDAx * $180 = $1800. + assert_eq!(token_balance(&world.svm, &vault_stable), usdc(1800)); +} + +/// Bob runs the cranker when NVDAx is well above the threshold. The +/// instruction reverts with `PriceAboveThreshold` and leaves vault state +/// untouched \u2014 importantly the vault is NOT marked triggered. +#[test] +fn test_price_above_threshold_crank_reverts_cheaply() { + let mut world = new_world(); + + initialize_feed(&mut world, dollars_to_oracle_price(300)); + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let _ = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _ = create_stable_ata(&mut world, &alice); + let vault = initialize_vault(&mut world, &alice, dollars_to_oracle_price(100), 600); + deposit(&mut world, &alice, nvdax(10)); + + let vault_volatile = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.volatile_mint); + let vault_stable = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.stable_mint); + + let bob = new_funded_keypair(&mut world.svm, sol(1)); + set_feed_price(&mut world, dollars_to_oracle_price(300)); + + let result = try_convert_if_triggered(&mut world, &bob, &alice.pubkey()); + assert!(result.is_err(), "crank at $300 vs $100 threshold must revert"); + + // State after the failed crank: vault untouched, not triggered. + let state = vault_state(&world.svm, &vault); + assert!(!state.triggered, "vault must NOT be marked triggered on a no-op crank"); + assert_eq!(token_balance(&world.svm, &vault_volatile), nvdax(10)); + assert_eq!(token_balance(&world.svm, &vault_stable), 0); +} + +/// Documents the flash-crash limitation. +/// +/// Alice's vault is armed at $100. Hour 1 the price is $200, Bob cranks and +/// reverts. BETWEEN the hour-1 and hour-2 cranks the price flash-crashes to +/// $50 and recovers to $180 before Bob's next chance to look. Hour 2 Bob +/// cranks at $180 \u2014 still above threshold \u2014 reverts. The vault was NOT +/// converted, even though the price went below the threshold mid-window. +/// +/// This is a known limitation of any discrete-time onchain stop-loss: the +/// program only sees the price at crank time. The fix in real systems is +/// either a tighter crank cadence (more expensive) or a continuous-watch +/// offchain liquidator (worse trust assumptions). We document the gap +/// instead of pretending it doesn't exist. +#[test] +fn test_flash_crash_between_cranks_misses_trigger() { + let mut world = new_world(); + + initialize_feed(&mut world, dollars_to_oracle_price(200)); + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let _ = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _ = create_stable_ata(&mut world, &alice); + let vault = initialize_vault(&mut world, &alice, dollars_to_oracle_price(100), 600); + deposit(&mut world, &alice, nvdax(10)); + + let vault_volatile = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.volatile_mint); + let vault_stable = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.stable_mint); + + let bob = new_funded_keypair(&mut world.svm, sol(1)); + + // Hour 1: $200, well above threshold. Bob cranks, reverts. + set_feed_price(&mut world, dollars_to_oracle_price(200)); + let result = try_convert_if_triggered(&mut world, &bob, &alice.pubkey()); + assert!(result.is_err()); + + // Between cranks: $50 (would have triggered), back to $180. The vault + // never sees the $50 print because no crank fires while it's there. + set_feed_price(&mut world, dollars_to_oracle_price(50)); + set_feed_price(&mut world, dollars_to_oracle_price(180)); + + // Hour 2: Bob cranks at $180. Still above threshold \u2014 reverts. + let result = try_convert_if_triggered(&mut world, &bob, &alice.pubkey()); + assert!(result.is_err()); + + // Despite the price having been below threshold during the window, the + // vault is NOT converted. This is the limitation. + let state = vault_state(&world.svm, &vault); + assert!(!state.triggered, "flash-crash between cranks does NOT trigger - known limitation"); + assert_eq!(token_balance(&world.svm, &vault_volatile), nvdax(10)); + assert_eq!(token_balance(&world.svm, &vault_stable), 0); +} + +/// NVDAx never falls to Alice's threshold, so the vault never triggers. Alice +/// changes her mind and pulls her 10 NVDAx back out with `withdraw_volatile` — +/// the escape hatch that stops a never-triggered vault from locking the +/// deposit forever. Carol cannot use it against Alice's vault. +#[test] +fn test_owner_withdraws_volatile_before_trigger() { + let mut world = new_world(); + + initialize_feed(&mut world, dollars_to_oracle_price(200)); + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let alice_volatile = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _ = create_stable_ata(&mut world, &alice); + let vault = initialize_vault(&mut world, &alice, dollars_to_oracle_price(100), 600); + deposit(&mut world, &alice, nvdax(10)); + + let vault_volatile = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.volatile_mint); + assert_eq!(token_balance(&world.svm, &vault_volatile), nvdax(10)); + + // Carol can't drain Alice's volatile balance. + let carol = new_funded_keypair(&mut world.svm, sol(1)); + let _ = create_stable_ata(&mut world, &carol); + let result = try_withdraw_volatile(&mut world, &carol, &alice.pubkey(), nvdax(10)); + assert!(result.is_err(), "Carol must not withdraw Alice's volatile balance"); + + // Alice pulls her full deposit back out. + try_withdraw_volatile(&mut world, &alice, &alice.pubkey(), nvdax(10)) + .expect("owner should be able to withdraw volatile before a trigger"); + assert_eq!(token_balance(&world.svm, &vault_volatile), 0); + assert_eq!(token_balance(&world.svm, &alice_volatile), nvdax(10)); + + let state = vault_state(&world.svm, &vault); + assert!(!state.triggered); +} + +/// The price drops below the threshold, but no crank runs for a long time and +/// the feed goes stale. When Bob finally cranks, `convert_if_triggered` rejects +/// the stale price instead of swapping on it: a feed that has stopped updating +/// must not move the vault's funds. +#[test] +fn test_stale_price_crank_reverts() { + let mut world = new_world(); + + initialize_feed(&mut world, dollars_to_oracle_price(200)); + let alice = new_funded_keypair(&mut world.svm, sol(10)); + let _ = fund_with_volatile(&mut world, &alice, nvdax(10)); + let _ = create_stable_ata(&mut world, &alice); + let vault = initialize_vault(&mut world, &alice, dollars_to_oracle_price(100), 600); + deposit(&mut world, &alice, nvdax(10)); + + let vault_volatile = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.volatile_mint); + let vault_stable = + anchor_spl::associated_token::get_associated_token_address(&vault, &world.stable_mint); + + // Price drops to $80 — below the $100 threshold, so a fresh crank would + // fire. But the feed then stops updating for longer than the staleness + // bound. + set_feed_price(&mut world, dollars_to_oracle_price(80)); + warp_past_price_staleness(&mut world); + + let bob = new_funded_keypair(&mut world.svm, sol(1)); + let result = try_convert_if_triggered(&mut world, &bob, &alice.pubkey()); + assert!(result.is_err(), "a stale price must not trigger a conversion"); + + let state = vault_state(&world.svm, &vault); + assert!(!state.triggered, "stale price leaves the vault un-triggered"); + assert_eq!(token_balance(&world.svm, &vault_volatile), nvdax(10)); + assert_eq!(token_balance(&world.svm, &vault_stable), 0); +}