Autonomous on-chain liquidation protection for Solana perpetuals.
A leveraged perp position gets liquidated because nobody was watching at the moment it mattered. Wick is a Solana program that watches continuously, decides with deterministic fixed-point arithmetic, and acts before the liquidator does — on venues whose authority model actually permits it.
The guard is written in Pinocchio
(no_std, no Anchor) because the critical path is a latency budget, and every
byte of deserialization on it is spend. See
wick-architecture.md for the full specification;
section numbers throughout this README refer to it.
Most perp protocols require the position owner's signature on every state change, which makes autonomous protection architecturally impossible there. Wick does not paper over that. It runs two tiers and tells you which one you are in:
- Autonomous (Drift/Velocity perps) — the guard PDA is the position
delegate. On breach it signs a hard reduce-only
place_perp_orderitself and the action lands without the owner present. This is the fast path. - Co-signed (Jupiter) — the guard builds the owner-signed instruction and holds it as pending. The owner's signature is what lands it. The guard never claims to be faster than the human in this tier.
Both tiers share one guard account, one health engine, one action selector, and one nonce/replay model. The difference is exactly one dispatch branch (§8.4).
The guard's dispatch path, benchmarked in LiteSVM over 300 recorded dispatches
(program/tests/latency_bench.rs, dataset at
frontend/public/latency-samples.json):
| p50 | 187 µs |
| p99 | 266 µs |
| min / max | 178 µs / 1396 µs |
| samples | 300 |
For scale: a Solana L1 slot is ~400 ms, and the sub-50 ms lane Wick targets is 50,000 µs — roughly 267× headroom at p50. This is a VM-measured dispatch cost, not an end-to-end on-chain claim: it excludes network propagation, leader scheduling, and confirmation. The dashboard plots the recorded distribution rather than a marketing number, and the target line is drawn off scale on purpose.
.
├── wick-architecture.md # Technical specification
├── brand.md # Ember Circuit design tokens
├── .github/workflows/ci.yml # fmt + clippy(-D warnings) + build-sbf + tests
├── program/ # On-chain guard (Pinocchio, no_std, BPF)
│ ├── src/
│ │ ├── lib.rs # entrypoint, module wiring
│ │ ├── instruction.rs # instruction discriminators
│ │ ├── processor.rs # handlers + §7.2 critical path (on_price_tick)
│ │ ├── state.rs # health engine, selector, partial-close solver,
│ │ │ # dispatch regimes (§8.1–8.4)
│ │ ├── account.rs # deterministic byte-map serialization
│ │ ├── pyth.rs # verified PriceUpdateV2 accessor (§7.1)
│ │ ├── drift.rs # hard reduce-only place_perp_order CPI (§8.7)
│ │ ├── jupiter.rs # co-signed instant_create_tpsl safety net
│ │ ├── delegation.rs # MagicBlock ER delegate/commit/undelegate (§8.6)
│ │ └── error.rs # WickError
│ ├── tests/ # LiteSVM e2e + real-fixture proofs
│ └── mocks/drift/ # mock Drift program for e2e CPI testing
├── cranker/ # Off-chain tick driver (Node, ESM)
│ └── src/ # Hermes VAA fetch → post PriceUpdateV2 → OnPriceTick
├── frontend/ # Next.js 16 console + landing page
│ └── src/
│ ├── app/ # / (landing) and /console
│ ├── components/wick/ # design-system components
│ ├── hooks/ # guard polling, derived events, wallet, actions
│ └── lib/ # account decoder, health math, instruction builders
└── deploy/deploy-devnet.sh # build + deploy + print the guard PDA params
OnPriceTick runs the §7.2 ordering. The order is the design:
- Price — read from the Pyth
PriceUpdateV2account at index[3], gated on feed ID, full verification, ≤60 s age and ≤150 bps confidence, scaled to 6dp. It is never taken from the tick payload, so a cranker cannot feed the guard a fabricated price. - Staleness — ticks older than
MAX_TICK_AGE_SLOTSare rejected; 3 consecutive stale ticks flip the guard todegraded. A fresh tick clears both the streak and the flag (§8.1.3). - Health — cross-multiplied equity-vs-maintenance comparison. Fixed-point
throughout (
SCALE = 1_000_000,BPS_DENOM = 10_000): no division, no floats, no rounding surprises between the program and the UI. - Nonce — monotonic tick nonce; replayed or stale nonces hard-reject.
- Caps — per-action and daily USD policy caps.
- Select — take-profit, then top-up, then partial-close (bounded
binary-search fraction solver in
[0, BPS_DENOM]). If nothing inside the caps restores the buffer it escalates to manual review — never a silent no-op. - Dispatch — Autonomous: build and CPI immediately; the nonce commits on the landed venue action. Co-signed: persist the built instruction as pending; the nonce commits only when the owner confirms on L1.
The guard's margin wallet is 2-of-2 (owner + co_authority) — see §8.5. A
singleton RouteConfig PDA holds a kill-switch checked at the top of every
state-mutating instruction.
| # | Instruction | Signer | Purpose |
|---|---|---|---|
| 0 | InitGuard |
owner + payer | Create the guard PDA (b"guard" || owner) and pin its policy |
| 1 | DepositMargin |
owner | Add collateral |
| 2 | WithdrawMargin |
owner + co-authority | 2-of-2 withdrawal (§8.5) |
| 3 | SetPaused |
route authority | Kill-switch |
| 4 | Delegate |
owner | Delegate the guard to the Ephemeral Rollup (§8.6) |
| 5 | CommitAndUndelegate |
owner | Commit state to L1 and exit the rollup |
| 6 | Commit |
owner | Commit state, stay delegated |
| 7 | OnPriceTick |
cranker | The §7.2 critical path |
| 8 | UpdatePosition |
owner | Enroll/refresh the watched position snapshot |
| 9 | ConfirmYes |
owner | Record that the co-signed instruction landed; commit the nonce |
| 10 | InitRouteConfig |
route authority | Create the singleton config |
- Solana CLI
≥ 4.0.3(providescargo-build-sbf) - Rust
1.89+ (CI pins1.97.1), Linux or macOS - Node
20+for the cranker and frontend
cd program
cargo build-sbf # -> target/deploy/wick_guard.so
cargo test --features no-entrypoint --all-targets
cd mocks/drift && cargo build-sbf # only for the e2e tick testsWhat CI enforces:
cargo fmt --check
cargo clippy --features no-entrypoint --all-targets -- -D warnings./deploy/deploy-devnet.sh # add --smoke to print program metadataIt prints the PROGRAM_ID to put in frontend/.env.local.
A fresh deployment owns no accounts, and check_not_paused rejects every
state-mutating instruction while RouteConfig is missing — so this step is
required before the cranker or the console can do anything.
cd cranker
cp .env.example .env # RPC, keypair path, program id
npm install
npm run init # RouteConfig + guard PDA + starting positioninit is idempotent: it skips whatever already exists, so it is safe to re-run.
Flags: --venue drift|jupiter|none, --collateral, --size, --entry,
--market-index, --subaccount-id. It ends by printing the guard PDA and the
two frontend/.env.local values.
The devnet bring-up sets the co-authority to the owner's own key so a single keypair can complete the flow. That defeats the point of 2-of-2 withdrawal authority — a real deployment must pass a second, separately-held key.
cd frontend
cp .env.example .env.local # set NEXT_PUBLIC_GUARD_PROGRAM_ID
npm install && npm run dev/ is the landing page; /console attaches to a live guard. With a wallet
connected it resolves your guard by PDA and enables the co-sign confirm; with
no wallet it stays read-only. It renders explicit unconfigured / no-guard /
error states rather than falling back to fake numbers, and the activity feed
records only transitions it actually observes — the guard account holds current
state, not history, so nothing is backfilled.
cd cranker
npm start # DRY_RUN=1 in .env simulates without sendingIt pulls a VAA from Hermes, posts a fully-verified PriceUpdateV2 through the
Pyth receiver, and drives OnPriceTick. Set DRY_RUN=0 to send real
transactions.
Secrets stay out of git:
.env,*.pem,*.key, and any*keypair*.jsonare gitignored. Only.env.exampletemplates are tracked.
61 unit tests plus LiteSVM integration tests covering:
- the fixed-point health engine, breach detection, and the partial-close solver
- action-selection precedence and cap enforcement
- the 2-of-2 withdraw matrix and nonce semantics
- serialization round-trips for every account layout
- the verified Pyth accessor — feed, staleness, confidence, 6dp scaling
- the Drift adapter — program ID, discriminator, reduce-only wire layout, direction mapping, missing-account rejection
The end-to-end proofs are the interesting ones:
- Autonomous (
tick.rs) — an underwater Drift position on a breach tick drives the guard PDA to CPI a hard reduce-onlyplace_perp_orderinto mock Drift signed by its own delegate seeds, stamping the position and committing the nonce. The mock enforces the delegate invariant: the CPI authority must be the account stored asUser.delegateand a signer — the test fails if either is violated. - Co-signed (
tick.rs) — the same breach never reaches the venue. The action is held pending, the nonce does not advance, and on Jupiter the guard additionally builds and persists the owner-signedinstant_create_tpsldata beside the expected nonce.Confirmthen commits it. - Real protocol (
real_drift.rs) — the autonomous reduce runs against the real Velocity (vELoC1…) program in LiteSVM using mainnet account fixtures, not a mock.
- The guard PDA is a delegate, never an owner. Delegates cannot withdraw.
reduce_only = trueis written by the serializer itself (d[30] = 1), so a position-increasing order is not merely unused — it is unconstructible.- Re-initializing a funded guard account is refused, closing the path where an
attacker passes a victim's guard with their own key as
ownerto reset nonce and collateral. - Account layouts are explicit byte maps with pinned offsets rather than
repr(C)casts, so BPF and the TypeScript decoder cannot disagree. frontend/src/lib/guard-layout.tsandguard-health.tsmirroraccount.rsandstate.rsbyte for byte and in bigint respectively — the UI never disagrees with the program about whether a position is liquidatable.
Stated plainly, because a risk tool that oversells itself is worse than none:
- Jupiter defensive closes are not yet built as signed instructions. The take-profit safety net is built, persisted, and confirmable. Breach closes on Jupiter are held as pending state only; that build path is still open.
- The sub-50 ms claim is VM-measured, not on-chain. 187 µs p50 is real and reproducible, but it is dispatch cost in LiteSVM — it excludes propagation and confirmation.
- ER delegation is written but not round-tripped. The guard is live on
devnet (
FRtyvM3xcFhL5FbukUdzaMV7t4pePiqxPvp2ZHwptBE) and the delegate/commit/undelegate hooks exist, but the live MagicBlock round trip is unverified. - The console is read + confirm. It resolves the guard, decodes it, and can
land
ConfirmYes. Init, deposit, and position enrollment are built as instruction builders but not yet surfaced as forms. - Drift market and sub-account are pinned at init and cannot be changed without re-initialization.
npm auditreports 3 moderate advisories, all transitive through@solana/web3.js → jayson → uuid. The only offered fix downgrades web3.js to0.0.3, so it is deliberately not applied.
- Guard program — health engine, selector, solver, authority dispatch, serialization
- CI — fmt, clippy, build-sbf, tests
- Jupiter co-signed safety net + owner
Confirm - Verified Pyth
PriceUpdateV2accessor - Drift reduce-only adapter + delegate-PDA e2e (autonomous tier)
- Live-protocol proof against real Velocity with mainnet fixtures
- Measured latency benchmark (187 µs p50) + honest dashboard chart
- Devnet deployment + cranker driving real price ticks
- Frontend — Ember Circuit brand, landing page, live console, wallet co-sign
- Jupiter defensive-close instruction build
- ER delegation round-trip on live MagicBlock
- On-chain end-to-end latency measurement
Apache-2.0