feat(tokens/stop-loss-vault): permissionless Switchboard-triggered stop-loss vault#36
feat(tokens/stop-loss-vault): permissionless Switchboard-triggered stop-loss vault#36mikemaccana-edwardbot wants to merge 2 commits into
Conversation
…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.
|
Reviewed this against the solana-claude-skill (SKILL.md + RUST.md), implemented the fixes, and verified with a real Since this PR's head is on a fork, I couldn't push directly to Correctness / financial-safety (RUST.md "non-negotiable")
Truth / consistency
Terminology
Config / docs
Left as-is (deliberately)The Verificationincluding the two added scenarios ( Generated by Claude Code |
|
Closing dupe of #37, caused by Claude API to Claude switch. |
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 callsconvert_if_triggeredon a schedule. The instruction reverts cheaply when the price is still above the threshold and only swaps when the price has actually dropped.Architecture
[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.triggeredflag flips fromfalsetotrueonce 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.convert_if_triggeredreads 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'sshared_accounts_routeinstruction 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 amock-jupiterprogram 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 withPriceAboveThreshold.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-switchboardprogram with the minimum fields the vault needs (price, scale, last-update slot) so the tests can drive deterministic price scenarios. Production swapsmock-switchboardfor the realswitchboard-on-demandcrate and verifies updates viaPullFeedAccountData::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_secondsas 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:Run with:
Limitations (called out in the README and in dedicated tests)
test_flash_crash_between_cranks_misses_triggerdemonstrates the gap explicitly.max_staleness_secondsonce it's reading a real Switchboard feed.convert_if_triggeredis permissionless, so a sandwich attacker watching the mempool can front-run the crank with adverse routes. The Jupiter route built here passesslippage_bps = 0andquoted_out_amount = 0for 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.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'sshared_accounts_routeinstruction 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.