Skip to content

Commit b45d77a

Browse files
author
Edward
committed
tokens/stop-loss-vault: add Anchor program, mock-jupiter, mock-switchboard, tests, README
Onchain stop-loss vault: holds one volatile SPL token for one owner and permissionlessly converts it into one stable SPL token when a Switchboard On-Demand feed reports a price below an owner-set threshold. TukTuk is the intended cranker in production. - Five instructions: initialize_vault, deposit, update_threshold, convert_if_triggered (permissionless), withdraw_stables. - mock-jupiter: teaching mock of Jupiter v6's shared_accounts_route — same external instruction shape, deterministic price-multiply instead of a real route. NOT FOR PRODUCTION. - mock-switchboard: minimal feed (price, scale, last_update_slot) with a test-driven set_price. Production swaps it for switchboard-on-demand. - Six Rust + LiteSVM scenarios with named actors (Alice, Bob, Carol) and real-money numbers (10 SOL, $100 threshold, USDC 6 decimals, oracle scale 8). - README documents architecture and limitations: flash-crash gap between cranks, oracle staleness, MEV behaviour, no partial-fill protection, mocks-are-mocks, TukTuk task registration stubbed.
1 parent 83e0967 commit b45d77a

21 files changed

Lines changed: 1866 additions & 54 deletions

File tree

tokens/stop-loss-vault/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Stop-Loss Vault
2+
3+
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.
4+
5+
## Architecture
6+
7+
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.
8+
9+
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.
10+
11+
## Instructions
12+
13+
- `initialize_vault(threshold_price, crank_interval_seconds, tuktuk_task)` — owner creates the vault, its two ATAs, and records the threshold + scheduling hint.
14+
- `deposit(amount)` — owner moves volatile tokens into the vault. Refuses once the vault has triggered.
15+
- `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.
16+
- `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`.
17+
- `withdraw_stables(amount)` — owner pulls stables out after the vault has triggered.
18+
19+
## Why Switchboard On-Demand
20+
21+
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.
22+
23+
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`.
24+
25+
## Why TukTuk
26+
27+
[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.
28+
29+
## Testing
30+
31+
```sh
32+
anchor build
33+
anchor test
34+
```
35+
36+
`anchor test` runs the Rust + LiteSVM integration tests under `programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs`. Scenarios:
37+
38+
- Alice initialises a vault with a $100 threshold, deposits 10 SOL.
39+
- Bob cranks across three checks ($180 → $150 → $80); the third fires the conversion and Alice withdraws $800 USDC.
40+
- Carol cannot withdraw from a vault she doesn't own.
41+
- Alice trails the threshold up to $200 after SOL rallies to $250; the next crank fires at $180.
42+
- A crank when the price is above threshold reverts cheaply and leaves the vault un-triggered.
43+
- A flash crash *between* cranks is missed — the vault is not converted (see Limitations).
44+
45+
## Limitations
46+
47+
- **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.
48+
- **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.
49+
- **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.
50+
- **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.
51+
- **`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.
52+
- **`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.
53+
- **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.

tokens/stop-loss-vault/anchor/Anchor.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ resolution = true
66
skip-lint = false
77

88
[programs.localnet]
9-
stop_loss_vault = "Hc3STUtyD3fQJgZ2qek8GGddY3TRTdtaapwXFFP1gsYj"
9+
mock_jupiter = "DSMyed6WZ2US8nfwLQtF7en9jcd9exn7c4qQd52Nffx1"
10+
mock_switchboard = "GAbm8tcMimkhYsQZm24N3Ev1kuWbTKXkTQ1gQEpfJ9Gg"
11+
stop_loss_vault = "BSzhyK5soR2T3T1LCjwYVybff2D9NowwfFHdVsAwnkmG"
1012

1113
[provider]
1214
cluster = "localnet"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "mock-jupiter"
3+
version = "0.1.0"
4+
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."
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "mock_jupiter"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
no-log-ix-name = []
17+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
18+
19+
[dependencies]
20+
anchor-lang = "1.0.0-rc.5"
21+
anchor-spl = "1.0.0-rc.5"
22+
23+
[lints.rust]
24+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
//! Mock Jupiter v6 swap aggregator for testing the stop-loss vault.
2+
//!
3+
//! Real Jupiter aggregates across many AMMs and routes through possibly
4+
//! multiple pools. The instruction that the vault uses against real Jupiter is
5+
//! `shared_accounts_route` — a single permissioned route through Jupiter's
6+
//! shared program-owned accounts.
7+
//!
8+
//! This mock implements ONE instruction with the same external shape (an
9+
//! 8-byte Anchor-style discriminator + a borsh argument struct + a fixed
10+
//! account list head). Instead of actually routing through DEXes, the mock:
11+
//!
12+
//! 1. Reads the current price from a mock Switchboard feed passed in
13+
//! remaining accounts.
14+
//! 2. Transfers `in_amount` of the input mint from the user's source ATA to
15+
//! the mock liquidity pool's input ATA.
16+
//! 3. Transfers `in_amount * price / 10^scale` adjusted for decimal
17+
//! differences of the output mint from the mock pool's output ATA back
18+
//! to the user's destination ATA.
19+
//!
20+
//! This is enough to exercise the vault's swap path in tests. NOT FOR
21+
//! PRODUCTION — real Jupiter swaps go through real liquidity, real price
22+
//! impact, real slippage, and real route accounts.
23+
use anchor_lang::prelude::*;
24+
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
25+
26+
declare_id!("DSMyed6WZ2US8nfwLQtF7en9jcd9exn7c4qQd52Nffx1");
27+
28+
#[program]
29+
pub mod mock_jupiter {
30+
use super::*;
31+
32+
/// Mock of Jupiter v6's `shared_accounts_route`. Same argument layout, same
33+
/// account order at the head, but executes a deterministic price multiply
34+
/// instead of a real route. The `_route_plan_len`, `_quoted_out_amount`,
35+
/// `_slippage_bps` and `_platform_fee_bps` arguments are accepted but
36+
/// ignored — the mock's "route" is the single Switchboard price.
37+
pub fn shared_accounts_route(
38+
ctx: Context<SharedAccountsRoute>,
39+
_id: u8,
40+
_route_plan_len: u8,
41+
in_amount: u64,
42+
_quoted_out_amount: u64,
43+
_slippage_bps: u16,
44+
_platform_fee_bps: u8,
45+
) -> Result<()> {
46+
// Decode the mock Switchboard feed from the dedicated account slot.
47+
// Anchor account layout is `[8-byte discriminator | borsh struct]`.
48+
// The vault passes the same feed account here as it does for its own
49+
// pre-flight price check, so prices are consistent.
50+
let feed_account = &ctx.accounts.price_feed;
51+
let feed_data = feed_account.try_borrow_data()?;
52+
require!(
53+
feed_data.len()
54+
>= MOCK_FEED_DISCRIMINATOR_LENGTH + MOCK_FEED_PAYLOAD_LENGTH,
55+
MockJupiterError::FeedDataTooShort
56+
);
57+
// Skip the 8-byte Anchor discriminator and decode the fixed-layout
58+
// payload: 32 (authority) + 16 (price i128) + 4 (scale u32) +
59+
// 8 (last_update_slot u64).
60+
let payload =
61+
&feed_data[MOCK_FEED_DISCRIMINATOR_LENGTH..MOCK_FEED_DISCRIMINATOR_LENGTH
62+
+ MOCK_FEED_PAYLOAD_LENGTH];
63+
let price_bytes: [u8; 16] = payload[32..48]
64+
.try_into()
65+
.map_err(|_| MockJupiterError::FeedDataTooShort)?;
66+
let price = i128::from_le_bytes(price_bytes);
67+
let scale_bytes: [u8; 4] = payload[48..52]
68+
.try_into()
69+
.map_err(|_| MockJupiterError::FeedDataTooShort)?;
70+
let scale = u32::from_le_bytes(scale_bytes);
71+
drop(feed_data);
72+
73+
require!(price > 0, MockJupiterError::NonPositivePrice);
74+
75+
// Pull the user's volatile tokens into the mock pool.
76+
let cpi_in = CpiContext::new(
77+
ctx.accounts.token_program.key(),
78+
Transfer {
79+
from: ctx.accounts.source_token_account.to_account_info(),
80+
to: ctx.accounts.program_source_token_account.to_account_info(),
81+
authority: ctx.accounts.user_transfer_authority.to_account_info(),
82+
},
83+
);
84+
token::transfer(cpi_in, in_amount)?;
85+
86+
// Compute the stable amount the user receives.
87+
//
88+
// `price` has `scale` decimal places (e.g. scale=8, price=200_00000000
89+
// means $200). `in_amount` is in the input mint's smallest units
90+
// (e.g. lamports for SOL). The output token has its own decimals on
91+
// its mint; the caller passes them explicitly so this mock can scale
92+
// correctly without doing a CPI to the mint.
93+
//
94+
// out_amount = in_amount * price * 10^output_decimals
95+
// / (10^scale * 10^input_decimals)
96+
let in_decimals = ctx.accounts.input_mint_decimals.decimals as u32;
97+
let out_decimals = ctx.accounts.output_mint_decimals.decimals as u32;
98+
99+
let in_amount_u128 = in_amount as u128;
100+
let price_u128 = u128::try_from(price)
101+
.map_err(|_| MockJupiterError::NonPositivePrice)?;
102+
let numerator = in_amount_u128
103+
.checked_mul(price_u128)
104+
.ok_or(MockJupiterError::MathOverflow)?
105+
.checked_mul(ten_pow(out_decimals)?)
106+
.ok_or(MockJupiterError::MathOverflow)?;
107+
let denominator = ten_pow(scale)?
108+
.checked_mul(ten_pow(in_decimals)?)
109+
.ok_or(MockJupiterError::MathOverflow)?;
110+
let out_amount_u128 = numerator
111+
.checked_div(denominator)
112+
.ok_or(MockJupiterError::MathOverflow)?;
113+
let out_amount: u64 = out_amount_u128
114+
.try_into()
115+
.map_err(|_| MockJupiterError::MathOverflow)?;
116+
117+
// Push the stable tokens back to the user from the mock pool.
118+
// The pool ATA is owned by a PDA so we sign for it.
119+
let pool_authority_bump = ctx.bumps.pool_authority;
120+
let signer_seeds: &[&[&[u8]]] =
121+
&[&[POOL_AUTHORITY_SEED, &[pool_authority_bump]]];
122+
let cpi_out = CpiContext::new_with_signer(
123+
ctx.accounts.token_program.key(),
124+
Transfer {
125+
from: ctx
126+
.accounts
127+
.program_destination_token_account
128+
.to_account_info(),
129+
to: ctx.accounts.destination_token_account.to_account_info(),
130+
authority: ctx.accounts.pool_authority.to_account_info(),
131+
},
132+
signer_seeds,
133+
);
134+
token::transfer(cpi_out, out_amount)?;
135+
Ok(())
136+
}
137+
138+
/// Convenience instruction so tests can derive a stable PDA-owned pool
139+
/// authority without rolling their own keypair scheme. Not part of the
140+
/// Jupiter API surface.
141+
pub fn initialize_pool_authority(_ctx: Context<InitializePoolAuthority>) -> Result<()> {
142+
Ok(())
143+
}
144+
}
145+
146+
/// 8-byte Anchor discriminator length. Anchor accounts and Anchor instructions
147+
/// both prefix their serialised data with an 8-byte discriminator, so this
148+
/// constant is shared.
149+
pub const MOCK_FEED_DISCRIMINATOR_LENGTH: usize = 8;
150+
/// Fixed payload length of `mock_switchboard::MockFeed`:
151+
/// 32 (authority Pubkey) + 16 (price i128) + 4 (scale u32) + 8 (last_update_slot u64).
152+
pub const MOCK_FEED_PAYLOAD_LENGTH: usize = 32 + 16 + 4 + 8;
153+
154+
/// PDA seed for the mock pool authority. Tests fund the mock pool ATAs owned
155+
/// by this PDA so the pool has stables to disburse.
156+
pub const POOL_AUTHORITY_SEED: &[u8] = b"mock-jupiter-pool";
157+
158+
fn ten_pow(power: u32) -> Result<u128> {
159+
10u128
160+
.checked_pow(power)
161+
.ok_or_else(|| error!(MockJupiterError::MathOverflow))
162+
}
163+
164+
/// Stub PDA the mock pool ATAs are owned by. Holds no state; existence makes
165+
/// it a valid signer authority for `Transfer` CPIs out of pool ATAs.
166+
#[account]
167+
pub struct PoolAuthority {}
168+
169+
#[derive(Accounts)]
170+
pub struct InitializePoolAuthority<'info> {
171+
/// CHECK: PDA derived from POOL_AUTHORITY_SEED; never read or written.
172+
/// Existence as an account is incidental — Anchor still requires us to
173+
/// declare it, but it doesn't need any data.
174+
#[account(
175+
seeds = [POOL_AUTHORITY_SEED],
176+
bump,
177+
)]
178+
pub pool_authority: UncheckedAccount<'info>,
179+
180+
#[account(mut)]
181+
pub payer: Signer<'info>,
182+
183+
pub system_program: Program<'info, System>,
184+
}
185+
186+
#[derive(Accounts)]
187+
pub struct SharedAccountsRoute<'info> {
188+
pub token_program: Program<'info, Token>,
189+
190+
/// User signing for the swap. In Jupiter this is `userTransferAuthority`;
191+
/// for the vault path this will be the vault PDA signing for itself.
192+
pub user_transfer_authority: Signer<'info>,
193+
194+
/// User's source token account (vault's volatile ATA for our use).
195+
#[account(mut)]
196+
pub source_token_account: Box<Account<'info, TokenAccount>>,
197+
198+
/// Mock pool's input token account (receives `in_amount`).
199+
#[account(mut)]
200+
pub program_source_token_account: Box<Account<'info, TokenAccount>>,
201+
202+
/// Mock pool's output token account (pays out the stable).
203+
#[account(mut)]
204+
pub program_destination_token_account: Box<Account<'info, TokenAccount>>,
205+
206+
/// User's destination token account (vault's stable ATA for our use).
207+
#[account(mut)]
208+
pub destination_token_account: Box<Account<'info, TokenAccount>>,
209+
210+
/// CHECK: read-only price feed; payload layout is validated when read.
211+
pub price_feed: UncheckedAccount<'info>,
212+
213+
/// Decimal-only view of input mint. We just need `decimals`.
214+
pub input_mint_decimals: Box<Account<'info, anchor_spl::token::Mint>>,
215+
/// Decimal-only view of output mint.
216+
pub output_mint_decimals: Box<Account<'info, anchor_spl::token::Mint>>,
217+
218+
/// CHECK: PDA that owns the pool ATAs.
219+
#[account(
220+
seeds = [POOL_AUTHORITY_SEED],
221+
bump,
222+
)]
223+
pub pool_authority: UncheckedAccount<'info>,
224+
}
225+
226+
#[error_code]
227+
pub enum MockJupiterError {
228+
#[msg("Mock Switchboard feed account data is shorter than expected.")]
229+
FeedDataTooShort,
230+
#[msg("Mock Switchboard feed reported a non-positive price.")]
231+
NonPositivePrice,
232+
#[msg("Math overflow while computing swap output.")]
233+
MathOverflow,
234+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "mock-switchboard"
3+
version = "0.1.0"
4+
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."
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "mock_switchboard"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
no-log-ix-name = []
17+
idl-build = ["anchor-lang/idl-build"]
18+
19+
[dependencies]
20+
anchor-lang = "1.0.0-rc.5"
21+
22+
[lints.rust]
23+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }

0 commit comments

Comments
 (0)