Skip to content

feat(tokens/stop-loss-vault): permissionless Switchboard-triggered stop-loss vault#36

Closed
mikemaccana-edwardbot wants to merge 2 commits into
quicknode:mainfrom
mikemaccana:edward/stop-loss-vault
Closed

feat(tokens/stop-loss-vault): permissionless Switchboard-triggered stop-loss vault#36
mikemaccana-edwardbot wants to merge 2 commits into
quicknode:mainfrom
mikemaccana:edward/stop-loss-vault

Conversation

@mikemaccana-edwardbot

Copy link
Copy Markdown
Contributor

What this PR does

Adds a new tokens/stop-loss-vault/ Anchor 1.0 example: 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 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.
  • One-shot lifecycle. 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.
  • Permissionless conversion path. convert_if_triggered 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 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.

Tests

6 Rust + LiteSVM integration tests under tokens/stop-loss-vault/anchor/programs/stop-loss-vault/tests/stop_loss_vault_scenarios.rs:

  • 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).

Run with:

cd tokens/stop-loss-vault/anchor
anchor build
anchor test

Limitations (called out in the README and in dedicated tests)

  • 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. 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.

Scope

  • tokens/stop-loss-vault/anchor/programs/stop-loss-vault/ — the vault program and its Rust + LiteSVM tests.
  • tokens/stop-loss-vault/anchor/programs/mock-jupiter/ — minimal mock with Jupiter v6's shared_accounts_route instruction shape, used only by tests.
  • tokens/stop-loss-vault/anchor/programs/mock-switchboard/ — minimal mock with the price/scale/last-update-slot fields the vault reads, used only by tests.
  • tokens/stop-loss-vault/README.md — full architecture + lifecycle walkthrough.

Status

Marked as draft: the program, tests, and README are complete, but I'm still working on follow-up CPI integration with the real TukTuk + Cron program clients (codama-generated, currently uncommitted locally) so the example shows the full end-to-end "deploy + register schedule + crank" lifecycle rather than just the onchain handlers. That can land in a later PR or as additional commits here, whichever you prefer.

Edward added 2 commits May 18, 2026 15:35
…board, 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.

Copy link
Copy Markdown
Collaborator

Reviewed this against the solana-claude-skill (SKILL.md + RUST.md), implemented the fixes, and verified with a real cargo build-sbf + cargo test run (8/8 LiteSVM scenarios pass).

Since this PR's head is on a fork, I couldn't push directly to edward/stop-loss-vault. The implemented changes are on branch claude/solana-skill-review-P4Oph (one commit on top of b45d77a) — pull/cherry-pick 4ad2350 into this PR.

Correctness / financial-safety (RUST.md "non-negotiable")

  • Raw token::transfertransfer_checked for every token move (deposit, withdraw_stables, both legs of mock-jupiter). The CPI now carries mint + decimals, so a wrong-mint/decimals account fails instead of silently miscalculating.
  • Classic Token types → token_interface (InterfaceAccount, Interface<TokenInterface>) so the vault works against the Classic and Token Extensions programs.
  • Oracle freshnessconvert_if_triggered now reads last_update_slot and rejects a price older than MAX_PRICE_STALENESS_SLOTS (StalePrice). The skill treats freshness as part of the math; it was previously only a README caveat.
  • Escrow escape hatch — added withdraw_volatile. Previously a vault whose threshold was never reached locked the owner's deposit forever with no cancel path; the skill explicitly flags this.

Truth / consistency

  • Fire-condition mismatch — the handler fired at price < threshold while the intro, state.rs, and the error message said "at or below". Made the code <= and aligned the PriceAboveThreshold message + docs.
  • Removed the dead _silence_unused test helper whose comment described a Pack import that doesn't exist.

Terminology

  • on-chain/off-chainonchain/offchain (6 spots).
  • Dropped "mempool" from the MEV note (Solana has no mempool); rewrote in terms of slot-leader/searcher ordering + Jito.

Config / docs

  • Anchor 1.0.0-rc.5 → stable 1.0.0 across all three crates (matches the rest of the repo).
  • README: documented withdraw_volatile and the staleness check, added a Setup section, fixed threshold wording, listed the two new scenarios.

Left as-is (deliberately)

The tuktuk_task stub and direct mock-feed read are honestly labelled TODOs for a teaching example with no real TukTuk/Switchboard dependency, and the mock-* programs stay marked not-for-production.

Verification

running 8 tests ... test result: ok. 8 passed; 0 failed

including the two added scenarios (test_stale_price_crank_reverts, test_owner_withdraws_volatile_before_trigger).


Generated by Claude Code

@mikemaccana

Copy link
Copy Markdown
Collaborator

Closing dupe of #37, caused by Claude API to Claude switch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants