Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions tokens/stop-loss-vault/README.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions tokens/stop-loss-vault/anchor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.anchor
.DS_Store
target
**/*.rs.bk
node_modules
test-ledger
.yarn
.surfpool
7 changes: 7 additions & 0 deletions tokens/stop-loss-vault/anchor/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.anchor
.DS_Store
target
node_modules
dist
build
test-ledger
20 changes: 20 additions & 0 deletions tokens/stop-loss-vault/anchor/Anchor.toml
Original file line number Diff line number Diff line change
@@ -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]
14 changes: 14 additions & 0 deletions tokens/stop-loss-vault/anchor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions tokens/stop-loss-vault/anchor/migrations/deploy.ts
Original file line number Diff line number Diff line change
@@ -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.
};
15 changes: 15 additions & 0 deletions tokens/stop-loss-vault/anchor/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
24 changes: 24 additions & 0 deletions tokens/stop-loss-vault/anchor/programs/mock-jupiter/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"))'] }
234 changes: 234 additions & 0 deletions tokens/stop-loss-vault/anchor/programs/mock-jupiter/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<SharedAccountsRoute>,
_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<InitializePoolAuthority>) -> 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<u128> {
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<Account<'info, TokenAccount>>,

/// Mock pool's input token account (receives `in_amount`).
#[account(mut)]
pub program_source_token_account: Box<Account<'info, TokenAccount>>,

/// Mock pool's output token account (pays out the stable).
#[account(mut)]
pub program_destination_token_account: Box<Account<'info, TokenAccount>>,

/// User's destination token account (vault's stable ATA for our use).
#[account(mut)]
pub destination_token_account: Box<Account<'info, TokenAccount>>,

/// 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<Account<'info, anchor_spl::token::Mint>>,
/// Decimal-only view of output mint.
pub output_mint_decimals: Box<Account<'info, anchor_spl::token::Mint>>,

/// 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,
}
Loading
Loading