diff --git a/tokens/stop-loss-vault/README.md b/tokens/stop-loss-vault/README.md new file mode 100644 index 00000000..cb97718c --- /dev/null +++ b/tokens/stop-loss-vault/README.md @@ -0,0 +1,53 @@ +# Stop-Loss Vault + +A per-owner vault that holds a single volatile SPL token (e.g. wSOL) and permissionlessly converts it to a single stable SPL token (e.g. USDC) when a Switchboard On-Demand price feed reports a price at or below an owner-set threshold. The conversion is triggered by an offchain cranker — typically a [TukTuk](https://github.com/helium/tuktuk) task — that calls `convert_if_triggered` on a schedule. The instruction reverts cheaply when the price is still above the threshold and only swaps when the price has actually dropped. + +## Architecture + +One PDA per owner at seeds `[b"vault", owner.key().as_ref()]`. The vault owns two associated token accounts — one for the volatile mint, one for the stable mint — and records the oracle feed pubkey, the threshold price (in the feed's native fixed-point scale), the suggested crank cadence, and the registered TukTuk task pubkey. A `triggered` flag flips from `false` to `true` once a conversion has fired, locking the vault out of further deposits or threshold updates so the post-trigger state is just a stable-token wallet. + +The conversion path reads the latest price from the Switchboard feed, compares it to the stored threshold, and if (and only if) the price is strictly below the threshold, CPIs the swap aggregator's `shared_accounts_route` instruction with the vault's entire volatile balance. The vault PDA signs the CPI for itself. In production the swap aggregator is Jupiter v6; in tests a `mock-jupiter` program with the same external instruction shape stands in. + +## Instructions + +- `initialize_vault(threshold_price, crank_interval_seconds, tuktuk_task)` — owner creates the vault, its two ATAs, and records the threshold + scheduling hint. +- `deposit(amount)` — owner moves volatile tokens into the vault. Refuses once the vault has triggered. +- `update_threshold(new_threshold_price?, new_crank_interval_seconds?)` — owner trails the threshold up (or down) and/or changes the suggested crank cadence. Both arguments optional; refuses once the vault has triggered. +- `convert_if_triggered(switchboard_price_update_data)` — permissionless. Anyone can call; the instruction only swaps when the latest price is strictly below the threshold. Otherwise it reverts with `PriceAboveThreshold`. +- `withdraw_stables(amount)` — owner pulls stables out after the vault has triggered. + +## 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 signature verification. That fits a permissionless crank model: the cranker pays for the price update they want the program to act on, and the program never has to trust 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 end behaviour. + +The teaching example uses a `mock-switchboard` program with the minimum fields the vault needs (price, scale, last-update slot) so the tests can drive deterministic price scenarios. Production swaps `mock-switchboard` for the real `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 (which is dead) for scheduling onchain instructions. The vault doesn't enforce the crank cadence onchain — it just 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. + +## 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`. Scenarios: + +- Alice initialises a vault with a $100 threshold, deposits 10 SOL. +- Bob cranks across three checks ($180 → $150 → $80); the third fires the conversion and Alice withdraws $800 USDC. +- Carol cannot withdraw from a vault she doesn't own. +- Alice trails the threshold up to $200 after SOL rallies to $250; the next crank fires at $180. +- A crank when the price is above threshold reverts cheaply and leaves the vault un-triggered. +- A flash crash *between* cranks is missed — the vault is not converted (see Limitations). + +## 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` (which costs more in crank fees and price-update fees) or a continuous-watch offchain liquidator with stronger trust assumptions. `test_flash_crash_between_cranks_misses_trigger` demonstrates the gap explicitly. +- **Oracle staleness.** The vault accepts whatever the feed currently reports. It does not enforce a maximum age on the price update. Production should reject updates older than some `max_staleness_seconds` once it's reading a real Switchboard feed. +- **MEV behaviour.** `convert_if_triggered` is permissionless, so a sandwich attacker watching the mempool can front-run the crank with adverse routes. 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, or use a private route, to avoid being filled at a worse price than the oracle's last print. +- **No partial-fill protection.** The vault swaps its *entire* volatile balance in one instruction. If liquidity for the full size is poor, the user 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. Swap to Jupiter v6 by changing the `swap_program` account passed at call time and pointing `instruction_data`'s discriminator at Jupiter v6's real `shared_accounts_route` sighash. +- **`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 an 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..ea854a52 --- /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. + +import * as anchor from "@anchor-lang/core"; + +module.exports = async function (provider: anchor.AnchorProvider) { + // Configure client to use the provider. + anchor.setProvider(provider); + + // Add your deploy script here. +}; diff --git a/tokens/stop-loss-vault/anchor/package.json b/tokens/stop-loss-vault/anchor/package.json new file mode 100644 index 00000000..3329b102 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/package.json @@ -0,0 +1,15 @@ +{ + "license": "ISC", + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" + }, + "dependencies": { + "@anchor-lang/core": "^1.0.0-rc.5" + }, + "devDependencies": { + "@types/bn.js": "^5.1.0", + "typescript": "^5.7.3", + "prettier": "^2.6.2" + } +} 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..f8e1cb9d --- /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-rc.5" +anchor-spl = "1.0.0-rc.5" + +[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..beed878b --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/mock-jupiter/src/lib.rs @@ -0,0 +1,234 @@ +//! 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, Transfer}; + +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(), + Transfer { + from: ctx.accounts.source_token_account.to_account_info(), + to: ctx.accounts.program_source_token_account.to_account_info(), + authority: ctx.accounts.user_transfer_authority.to_account_info(), + }, + ); + token::transfer(cpi_in, in_amount)?; + + // 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(), + Transfer { + from: ctx + .accounts + .program_destination_token_account + .to_account_info(), + to: ctx.accounts.destination_token_account.to_account_info(), + authority: ctx.accounts.pool_authority.to_account_info(), + }, + signer_seeds, + ); + token::transfer(cpi_out, out_amount)?; + 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 `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..cda5bc12 --- /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-rc.5" + +[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..e1c41367 --- /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 off-chain 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 on-chain 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..46607d70 --- /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-rc.5" +anchor-spl = "1.0.0-rc.5" +# The vault's `convert_if_triggered` CPIs into Jupiter v6's +# `shared_accounts_route` instruction. For tests we use a mock with the same +# external layout, and we depend on the mock's crate so the instruction +# discriminator stays in sync. In production this dependency is swapped for a +# tiny crate (or hardcoded const) that exposes Jupiter v6's real discriminator. +mock-jupiter = { path = "../mock-jupiter", features = ["no-entrypoint"] } + +[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" +mock-switchboard = { path = "../mock-switchboard", 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..eea619c0 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/constants.rs @@ -0,0 +1,20 @@ +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; + +/// 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..7611da3b --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/error.rs @@ -0,0 +1,28 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum StopLossError { + #[msg("Oracle reported a price at or 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("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..ff34a73c --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions.rs @@ -0,0 +1,22 @@ +pub mod convert_if_triggered; +pub mod deposit; +pub mod initialize_vault; +pub mod update_threshold; +pub mod withdraw_stables; + +// 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::*; 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..4ef4af05 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/convert_if_triggered.rs @@ -0,0 +1,226 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program::invoke_signed; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::Discriminator; +use anchor_spl::token::{Mint, Token, TokenAccount}; + +use crate::constants::{ANCHOR_DISCRIMINATOR_LENGTH, MOCK_FEED_PAYLOAD_LENGTH}; +use crate::error::StopLossError; +use crate::state::Vault; + +/// 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 on-chain 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); + drop(feed_data); + + require!(price > 0, StopLossError::NonPositivePrice); + + // The fire condition: price strictly below threshold. Equality keeps the + // vault armed — slightly lazy, but it means the test `price_above_threshold` + // case includes the boundary cleanly. + 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 Jupiter v6 `shared_accounts_route` instruction. We construct + // it by hand (rather than via the `mock_jupiter` cpi module) because the + // production version of this handler will target the *real* Jupiter + // program ID and the real `shared_accounts_route` discriminator — the + // only thing that has to change is the program ID in the client and the + // discriminator below. For now both come from `mock_jupiter`. + let mock_jupiter_program_id = ctx.accounts.swap_program.key(); + + // Anchor-style 8-byte discriminator for `shared_accounts_route` — pulled + // from the mock-jupiter crate so it stays in sync with the program. + // Real Jupiter v6 uses the same sighash scheme; swap this for the real + // Jupiter discriminator when targeting their mainnet program. + let discriminator: &[u8] = + mock_jupiter::instruction::SharedAccountsRoute::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: mock_jupiter_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: Program<'info, Token>, +} 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..cfb32975 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/deposit.rs @@ -0,0 +1,58 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; + +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(), + Transfer { + from: ctx.accounts.owner_volatile_account.to_account_info(), + to: ctx.accounts.vault_volatile_account.to_account_info(), + authority: ctx.accounts.owner.to_account_info(), + }, + ); + token::transfer(cpi_context, amount)?; + 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: Account<'info, Mint>, + + #[account( + mut, + associated_token::mint = volatile_mint, + associated_token::authority = vault, + )] + pub vault_volatile_account: Account<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = volatile_mint, + associated_token::authority = owner, + )] + pub owner_volatile_account: Account<'info, TokenAccount>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Program<'info, Token>, +} 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..37097503 --- /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::{Mint, Token, TokenAccount}; + +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: Account<'info, Mint>, + + pub stable_mint: Account<'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: Account<'info, TokenAccount>, + + #[account( + init, + payer = owner, + associated_token::mint = stable_mint, + associated_token::authority = vault, + )] + pub vault_stable_account: Account<'info, TokenAccount>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Program<'info, Token>, + 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..ac6605f9 --- /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 on-chain +/// 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..619f3926 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/instructions/withdraw_stables.rs @@ -0,0 +1,65 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer}; + +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(), + Transfer { + from: ctx.accounts.vault_stable_account.to_account_info(), + to: ctx.accounts.owner_stable_account.to_account_info(), + authority: ctx.accounts.vault.to_account_info(), + }, + signer_seeds, + ); + token::transfer(cpi_context, amount)?; + 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: Account<'info, Mint>, + + #[account( + mut, + associated_token::mint = stable_mint, + associated_token::authority = vault, + )] + pub vault_stable_account: Account<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = stable_mint, + associated_token::authority = owner, + )] + pub owner_stable_account: Account<'info, TokenAccount>, + + #[account(mut)] + pub owner: Signer<'info>, + + pub token_program: Program<'info, Token>, +} 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..208cda95 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/lib.rs @@ -0,0 +1,79 @@ +//! 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) + } +} 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..8119e057 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/src/state.rs @@ -0,0 +1,54 @@ +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 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 on-chain 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 `deposit` and + /// the threshold update path so the vault can't be re-armed without + /// closing — keeping the example simple. + 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..33f1c519 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/common/mod.rs @@ -0,0 +1,505 @@ +//! 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; + +/// SOL has 9 decimals (lamports per SOL). +pub const SOL_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 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, SOL_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_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 +} + +// Silence `unused_imports` complaints when individual test binaries don't +// touch every helper. +#[allow(dead_code)] +pub fn _silence_unused() -> usize { + // The `Pack` trait import is genuinely unused now \u2014 we read token + // balances by offset. Kept the bring-in for symmetry with future tests + // that want to read full account state. + 0 +} 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..0299a789 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs @@ -0,0 +1,268 @@ +//! Stop-loss vault scenarios. +//! +//! Each test is a story with named actors (Alice, Bob, Carol, Dave), real +//! numbers (USDC 6 decimals, SOL 9 decimals, oracle 8 decimals), and a clear +//! beat-by-beat progression a non-engineer can follow. + +mod common; + +use common::*; +use solana_signer::Signer; + +/// Alice opens a stop-loss vault. SOL is at $200, threshold is $100, crank +/// cadence is every 10 minutes. Alice deposits 10 SOL into the vault. +#[test] +fn test_alice_initialises_vault_with_100_usd_threshold() { + let mut world = new_world(); + + // SOL @ $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, sol(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, sol(10)); + + // Vault now holds Alice's 10 SOL. + 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), sol(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 SOL 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, sol(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, sol(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), sol(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), sol(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 SOL * $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, sol(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, sol(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 SOL was $200. SOL 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. SOL +/// 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, sol(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, sol(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 SOL * $180 = $1800. + assert_eq!(token_balance(&world.svm, &vault_stable), usdc(1800)); +} + +/// Bob runs the cranker when SOL 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, sol(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, sol(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), sol(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 +/// off-chain 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, sol(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, sol(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), sol(10)); + assert_eq!(token_balance(&world.svm, &vault_stable), 0); +} diff --git a/tokens/stop-loss-vault/anchor/rust-toolchain.toml b/tokens/stop-loss-vault/anchor/rust-toolchain.toml new file mode 100644 index 00000000..cb684c01 --- /dev/null +++ b/tokens/stop-loss-vault/anchor/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.89.0" +components = ["rustfmt","clippy"] +profile = "minimal" diff --git a/tokens/stop-loss-vault/anchor/tsconfig.json b/tokens/stop-loss-vault/anchor/tsconfig.json new file mode 100644 index 00000000..cd5d2e3d --- /dev/null +++ b/tokens/stop-loss-vault/anchor/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true + } +}