Skip to content

tokens/stop-loss-vault: permissionless Switchboard-triggered stop-loss vault#37

Open
mikemaccana wants to merge 1 commit into
mainfrom
claude/solana-skill-review-P4Oph
Open

tokens/stop-loss-vault: permissionless Switchboard-triggered stop-loss vault#37
mikemaccana wants to merge 1 commit into
mainfrom
claude/solana-skill-review-P4Oph

Conversation

@mikemaccana

@mikemaccana mikemaccana commented May 29, 2026

Copy link
Copy Markdown
Collaborator

What this adds

A new tokens/stop-loss-vault/ Anchor example: a per-owner vault that holds a single volatile SPL token (e.g. wSOL) and permissionlessly converts it to a single stable token (e.g. USDC) when a Switchboard On-Demand price feed reports a price at or below an owner-set threshold. The conversion runs from an offchain cranker — typically a TukTuk task — that calls convert_if_triggered on a schedule. The instruction reverts cheaply while the price is above the threshold and only swaps once it has actually dropped.

Architecture

  • One PDA per owner at seeds [b"vault", owner.key().as_ref()]. The vault owns two associated token accounts (volatile + stable) 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 false → true once a conversion fires, locking the pre-trigger actions (deposit, update_threshold, withdraw_volatile) so the post-trigger vault is simply a stable-token wallet drained via withdraw_stables.
  • Permissionless conversion. convert_if_triggered reads the latest price, and only if it is at or below the stored threshold CPIs the swap aggregator's shared_accounts_route with the vault's entire volatile balance; the vault PDA signs the CPI for itself. In production the 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 in. Refused once triggered.
  • update_threshold(new_threshold_price?, new_crank_interval_seconds?) — owner trails the threshold and/or crank cadence. Both optional; refused once triggered.
  • convert_if_triggered(switchboard_price_update_data) — permissionless. Swaps only when the price is at or below the threshold and fresh; otherwise reverts with PriceAboveThreshold or StalePrice.
  • withdraw_stables(amount) — owner pulls stables out after a trigger.
  • withdraw_volatile(amount) — owner pulls volatile tokens back out before a trigger. The escape hatch so a vault whose threshold is never reached doesn't lock the deposit forever.

Token handling & safety

  • transfer_checked for every token move, so each CPI carries the mint + decimals and a wrong-mint/decimals account fails instead of silently miscalculating.
  • anchor_spl::token_interface (InterfaceAccount, Interface<TokenInterface>) throughout, so the vault works against both the Classic Token Program and the Token Extensions Program.
  • Oracle freshness is enforced: convert_if_triggered reads last_update_slot and rejects any price older than MAX_PRICE_STALENESS_SLOTS, failing closed if the feed stops updating.
  • Owner-only mutations via has_one = owner + PDA seeds; the crank path is the only permissionless entry point and it cannot move funds anywhere except through the swap.

Why Switchboard On-Demand

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 verification. That fits a permissionless crank: the cranker pays for the update it wants the program to act on, and the program never trusts the cranker's identity. The teaching example uses a mock-switchboard program exposing the minimum fields the vault reads (price, scale, last-update slot) so tests can drive deterministic price scenarios; production swaps it for the switchboard-on-demand crate and PullFeedAccountData::parse_and_verify.

Why TukTuk

TukTuk is the maintained replacement for the dead Clockwork for scheduling onchain instruction handlers. The vault doesn't enforce the cadence onchain — it records crank_interval_seconds as a hint and stores the TukTuk task pubkey for discoverability. Anyone can crank; in normal operation TukTuk runs the schedule and pays for the price update.

Tests

8 Rust + LiteSVM scenarios under programs/stop-loss-vault/tests/:

  • Alice initialises a $100-threshold vault and deposits 10 SOL.
  • Three cranks ($180 → $150 → $80); the third fires and Alice withdraws $800 USDC.
  • Carol cannot withdraw stables or volatile from a vault she doesn't own.
  • Alice trails the threshold up to $200 after SOL rallies; the next crank fires at $180.
  • A crank above threshold reverts cheaply and leaves the vault un-triggered.
  • A flash crash between cranks is missed (documents the discrete-time limitation).
  • A price that drops below threshold but goes stale is rejected (StalePrice).
  • A never-triggered vault isn't a trap: the owner reclaims the deposit via withdraw_volatile.

Run:

cd tokens/stop-loss-vault/anchor
anchor build
anchor test
running 8 tests ... test result: ok. 8 passed; 0 failed

Scope

  • programs/stop-loss-vault/ — the vault program and its Rust + LiteSVM tests.
  • programs/mock-jupiter/ — minimal mock with Jupiter v6's shared_accounts_route shape (deterministic price-multiply, checked math), tests only.
  • programs/mock-switchboard/ — minimal mock feed (price / scale / last-update slot), tests only.
  • tokens/stop-loss-vault/README.md — architecture, lifecycle, and limitations.

Limitations (documented in the README and exercised by tests)

  • Flash-crash gap between cranks — a discrete-time stop-loss only sees the price at crank time; a crash-and-recover between cranks is missed. Tune crank_interval_seconds accordingly.
  • MEV — the crank is permissionless and swaps at whatever route it supplies, so the swap is exposed to adversarial transaction ordering; production must pass a real quote + realistic slippage or route privately (e.g. a Jito bundle). The mock route uses slippage_bps = 0 / quoted_out_amount = 0 for simplicity.
  • No partial-fill protection — the whole volatile balance is swapped in one instruction.
  • mock-jupiter / mock-switchboard are test stand-ins, marked not-for-production, and the tuktuk_task registration is an honestly-labelled TODO (the owner pre-creates the task; the vault just records it).

https://claude.ai/code/session_01UXGGFcK3UWRrcv9UoW1gkJ

@mikemaccana mikemaccana changed the title tokens/stop-loss-vault: permissionless Switchboard-triggered stop-loss vault (skill-reviewed) tokens/stop-loss-vault: permissionless Switchboard-triggered stop-loss vault May 29, 2026
@mikemaccana mikemaccana force-pushed the claude/solana-skill-review-P4Oph branch 4 times, most recently from 24f1f6a to 201dc08 Compare June 1, 2026 20:33
…s vault

A per-owner Anchor vault that holds one volatile token and permissionlessly
converts it to a stable token (USDC) once a Switchboard On-Demand price feed
reports a price at or below an owner-set threshold. An offchain TukTuk crank
calls convert_if_triggered on a schedule; the swap routes through Jupiter v6's
shared_accounts_route, with a mock-jupiter program standing in for tests.

Instructions: initialize_vault, deposit, update_threshold,
convert_if_triggered, withdraw_stables, withdraw_volatile.

- token_interface + transfer_checked for every token move, so the vault works
  against the Classic and Token Extensions programs.
- Oracle freshness enforced via MAX_PRICE_STALENESS_SLOTS; owner-only mutations
  via has_one + PDA seeds.
- withdraw_volatile escape hatch so a never-triggered vault never locks funds.
- mock-jupiter and mock-switchboard test programs with the real external shapes.
- 8 Rust + LiteSVM scenarios; README with a finance primer and a program-flow
  walkthrough (Alice/Bob/Carol/Dave) naming each handler and the accounts it
  changes.

https://claude.ai/code/session_01UXGGFcK3UWRrcv9UoW1gkJ
@mikemaccana mikemaccana force-pushed the claude/solana-skill-review-P4Oph branch from 201dc08 to c07aadd Compare June 1, 2026 20:54
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