Command-line interface for interacting with the Percolator perpetuals protocol on Solana.
- percolator - Risk engine library
- percolator-prog - Main Percolator program (Solana smart contract)
- percolator-match - Passive LP matcher program (50bps spread)
- percolator-stake - Staking integration
- percolator-stress-test - Stress testing suite
Third-party repositories are community contributions. Do not trust — always verify. Review the code yourself before running or deploying anything.
FOR EDUCATIONAL PURPOSES ONLY
This code has NOT been audited. Do NOT use in production or with real funds. The percolator program is experimental software provided for learning and testing purposes only. Use at your own risk.
pnpm install
pnpm buildCreate a config file at ~/.config/percolator-cli.json:
{
"rpcUrl": "https://api.devnet.solana.com",
"programId": "2SSnp35m7FQ7cRLNKGdW5UzjYFF6RBUNq7d3m5mqNByp",
"walletPath": "~/.config/solana/id.json"
}Or use command-line flags:
--rpc <url>- Solana RPC endpoint--program <pubkey>- Percolator program ID--wallet <path>- Path to keypair file--json- Output in JSON format--simulate- Simulate transaction without sending
Status (2026-05-25, LIVE): v16 multi-market group on a new program, wSOL-collateralized, 3 inverted markets at 20× in HYBRID_AFTER_HOURS fee mode. SOL is the base unit; all marks are Pyth composites. Per-market isolated insurance, a $0.50-ish account-hold-per-day + market-create fee, and 20% of non-zero-market trade fees + backing yield redirected to market 0. Permissionless market append is enabled. Authorities NOT burned. (The v1 STOXX/SOL single-market bounty-5 on
4ToDRrQW…/DV4Qa…was wound down 2026-05-25 — see Deprecated below.)
On-chain addresses
Program: 4m3ipBQDYX6JQ9YSmUXDjESDHMtGWtiXforkWr9Qoxdi (new v16 program)
Market: 8oYjDr2Rt6BCuBvwaUGx7gLnzQbkuARTtrQr7DijAHn7 (market group account)
Vault PDA: GhsdCHrSYHK6nHmESKvBDDNJJxuMPQntnmJ6d5tFimts
Vault ATA: GQLuAjosXmZKoAsrfhrFjyaurvVt1C5KbHSiZT2hFDKg (wrapped SOL, PDA-signed)
Matcher: (none — third parties provision their own matcher program + context)
Insurance: 0.5 SOL per market (domains 0/2/4) = 1.5 SOL seeded at deploy
Keeper: 9WiMAQtdx8zXMovePuaZ7v472UsFgZ7vkL7rr7APuxBQ (dedicated; proximity-driven liquidator)
Markets (all inverted → priced in SOL)
| # | Market | Oracle (Pyth pull) |
|---|---|---|
| m0 | USD/SOL | SOL/USD 7UVimffx…, 1-leg, inverted |
| m1 | STOXX50/SOL | STOXX50·EUR C2Cf16… × EUR/USD Fu76Cham… ÷ SOL/USD 7UVimffx…, 3-leg DIVIDE_LEG3, inverted |
| m2 | BTC/SOL | BTC/USD 4cSM2e6… ÷ SOL/USD 7UVimffx…, 2-leg DIVIDE_LEG2, inverted |
Oracles — each market's Pyth pull legs are in the table above (m1's composite
is STOXX50·EUR × EUR/USD ÷ SOL/USD = STOXX50/SOL via DIVIDE_LEG3 = 0x02;
m2's is BTC/USD ÷ SOL/USD = BTC/SOL via DIVIDE_LEG2 = 0x01; m0 is SOL/USD
inverted). The shard-0 PriceUpdateV2 accounts have no external keeper, so the
keeper self-publishes them from hermes.pyth.network via pyth-solana-receiver
(/home/anatoly/pyth-pusher), pushing a leg only when its on-chain price nears
max_staleness_secs. The equity leg (STOXX) trades Europe/Paris 09:00–17:30; out
of hours it falls back to the EWMA mark (HYBRID_AFTER_HOURS), which the keeper's
cranks keep advancing. FX (EUR) is 24/5, crypto (SOL/BTC) 24/7.
Build provenance (upgraded 2026-05-25 to the per-asset stale fix)
BPF binary SHA-256: 473958eaa89e0c29671448871bb831eee132ab507e4642b09bbcedcf080c2516
BPF binary size: 820,280 bytes ELF
percolator-prog: 7f7cefc "Allow unrelated trades with stale assets"
(logic-only +63 lines over c929fb0; account layouts UNCHANGED,
engine pin 23de295 unchanged — existing market/portfolios stay valid)
MARKET_ACCOUNT_LEN: 116,286 bytes (capacity 64 asset slots; dynamic — realloc-growable)
Upgrade tx: EhxpYK2CwzUf2UXQx34RRpMJyVSQrVP5QJL1qQnk2rKxJ6ifBee4FJVpPD9Ywa7VGp7rPzoFstNWL4ttp9Me2H1
Prior build: f476f8fc… (819,232 B, c929fb0) — superseded
Verify locally (the toolchain dependency wincode-derive@0.4.4 requires
edition2024, so use platform-tools v1.52+ which ships rustc 1.89):
git clone https://github.com/aeyakovenko/percolator-prog.git
cd percolator-prog && git checkout 7f7cefc
cargo build-sbf --tools-version v1.52
sha256sum target/deploy/percolator_prog.so
# Expected: 473958eaa89e0c29671448871bb831eee132ab507e4642b09bbcedcf080c2516
solana program dump -u m 4m3ipBQDYX6JQ9YSmUXDjESDHMtGWtiXforkWr9Qoxdi /tmp/deployed.so
head -c 820280 /tmp/deployed.so | sha256sum # must matchConfiguration
| Param | Value | Notes |
|---|---|---|
mm (maintenance margin) |
500 bps | = im → 20× nominal leverage |
im |
500 bps | no opening buffer |
max_price_move_bps_per_slot |
24 | §1.4 envelope at mm=500 + max_accrual_dt=20; caps the mark walk to ≤480 bps/crank |
max_accrual_dt_slots |
20 | accrual catch-up step; a market past it is not dead — keeper cranks it back |
h_min / h_max |
0 / 6_480_000 | up to ~30d profit maturity |
max_trading_fee_bps |
10_000 | hybrid mode cap (100%) |
trade_fee_base_bps |
1 | hybrid base; +EWMA movement bps in off-hours |
trade_fee_mode |
HYBRID_AFTER_HOURS |
fresh-oracle → static; stale leg → EWMA mark + dynamic fee |
liquidation_fee_bps |
5 | frees envelope budget |
min_nonzero_mm_req / _im_req |
500 / 600 | exact-N proof room |
min_liquidation_abs |
0 | no per-call dust floor |
liquidation_fee_cap |
50 × 10⁹ | $50K cap per liquidation |
permissionless_resolve_stale_slots |
6_480_000 (~30 d) | survives any multi-day market closure |
force_close_delay_slots |
216_000 (~24 h) | post-resolve grace |
maintenance_fee_per_slot |
~27 lamports | ≈ $0.50/day account-hold spam deterrent (computed from live SOL/USD) |
permissionless_market_init_fee |
~5.8M lamports | ≈ $0.50 to permissionlessly append a new market |
fee_redirect_to_market_0_bps |
2000 | 20% of non-zero-market trade fees + backing yield → market 0 |
max_staleness_secs |
600 | crank accepts a leg up to 10 min old; freshen the leg (push) before cranking past this |
| Insurance seed | 0.5 SOL × 3 = 1.5 SOL | isolated per market (domains 0/2/4) |
This market is run dormant to keep operating cost near zero. With no open positions the keeper does not crank, and the Pyth pull legs are not maintained — so the market sits stale on purpose.
Staleness gating is per-asset (as of program 7f7cefc). To trade asset X you
only need X fresh — a stale other asset no longer blocks you (the engine's
market-wide loss-stale bit is ignored for a trade when neither the traded asset nor
either account's open legs touch a stale asset). InitPortfolio / Deposit aren't
staleness-gated at all. (The group-wide lock still applies to admin oracle
reconfig — ConfigureAuthMark / ConfigureHybridOracle — but not to trading.)
To trade you catch up just the asset(s) you touch (permissionless):
- Freshen that asset's Pyth pull legs — publish the shard-0 PriceUpdateV2 accounts
(e.g.
pyth-solana-receiveragainsthermes.pyth.network). - Catch-up crank the asset with
PermissionlessCrank action:0repeatedly: each crank advances accrual by ≤max_accrual_dt(20 slots), so a long-idle asset needs many cranks (a day of drift ≈ thousands) until itsslot_lastis withinmax_accrual_dtof the current slot. - Then
InitPortfolio→Deposit→TradeNoCpi(atomic-crank-then-trade in one tx keeps the asset fresh at execution).
The keeper only guarantees the market never hard-stales (~30 d, see below); it
does not keep it continuously trade-ready. Liquidation (action:1) is also not
blocked by staleness, so the keeper can always liquidate even while idle drift sits.
Operational keepalive — proximity-driven. Cron runs mainnet-bounty5-v16-tick.ts
every minute on a dedicated keeper key (9WiMAQtd…, NOT the admin/upgrade key).
A position can only become liquidatable on-chain when its stored mark moves, and the
mark only moves when cranked — so the keeper cranks only when needed:
- it reads every portfolio + each asset's mark and computes each position's health buffer off-chain (capital + pnl − fee_debt vs Σ maintenance_req) — no tx;
- no positions → the market is left to drift; the keeper cranks an asset only if
its oracle-staleness clock nears the ~30-day hard-stale (
HEARTBEAT_SLOTS), one crank resets it. Idle cost ≈ 0 (no VAA pushes); - position near its liq level → a tight crank+liquidate burst on that asset:
it walks the stored mark toward the live price in small steps (≤
max_price_move · max_accrual_dt= 480 bps/crank) and firesPermissionlessCrank action:1as the mark crosses, bounding bad debt. The engine is the source of truth — action:1 no-ops (0x15/0x16) on a healthy account, so an over-eager burst is harmless; - position present but comfortable → a single refresh crank + liq probe per tick.
timeout 58 s cron wrapper; log at ~/.cache/percolator/bounty5-v16-cron.log.
Keep the keeper key funded. Liquidation (action:1) is not gated by the
market-wide stale lock, so the keeper can always liquidate even while the idle market
is drifting. If the market ever hard-stales (> permissionless_resolve_stale_slots,
~30 d), re-run the installer's bootstrap (admin ConfigureHybridOracle re-freshen).
Install (the installer creates the keeper key + portfolio, bootstraps a stale market, and PRINTS the cron line — it does NOT modify the crontab itself):
NETWORK=mainnet npx tsx scripts/mainnet-bounty5-v16-cron-install.ts
# fund the printed keeper pubkey, then add the printed line via `crontab -e`
tail -f ~/.cache/percolator/bounty5-v16-cron.logTrading the market (any wallet, any time)
InitUser (6 accts): [user, slab, userAta, vault, tokenProgram, clock]
DepositCollateral (6): [user, slab, userAta, vault, tokenProgram, clock]
WithdrawCollateral (10): [user, slab, vault, userAta, vaultPda, tokenProgram, clock, leg1, leg2, leg3]
TradeNoCpi (7): [user, lp, slab, clock, leg1, leg2, leg3]
CloseAccount (10): [user, slab, vault, userAta, vaultPda, tokenProgram, clock, leg1, leg2, leg3]
KeeperCrank (6): [caller, slab, clock, leg1, leg2, leg3]
InitUser / DepositCollateral don't require fresh oracle. Withdraw / Close / Trade / Crank all read the 3-leg composite — during EU off-hours the equity leg is stale and the wrapper falls back to the EWMA mark + dynamic fee.
Known intentional surface (these are the bug-hunting hooks, not bugs):
- Equity leg's sponsored-shard cadence is irregular (5–60 min between
updates). With
max_staleness=600 s, the wrapper often falls into the EWMA-mark branch even during EU hours. A trader entering when the engine has stalelast_oracle_pricecarries that mark until the next composite refresh propagates. MAX_ACCRUAL_DT_SLOTS=10caps each crank's catch-up to ~4 s of wall clock. With open interest and a long oracle outage, lag accumulates.tvl_insurance_cap_mult=50— total user capital can reach 50 × the insurance fund. Bigger than typical perp DEXs (~20×).- No matcher is provisioned. TradeNoCpi works with any LP regardless of
matcher — the handler explicitly notes "no matcher check, both sides
are bilateral signers, no CPI invoked" (line 7795).
InitLPstill requiresmatcher_program != Pubkey::default()(line 6687) — any non-default pubkey suffices; the matcher fields are ignored by TradeNoCpi. Use TradeCpi only if you want spread/impact pricing via a deployed matcher program. - Smoke verified on the bounty 4 incarnation of the same wrapper (2026-05-13/14): InitUser → InitLP (dummy matcher) → TradeNoCpi off-mark drove EWMA mark drift; conservation held throughout. Bounty 5 inherits the same trading surface.
Bounty win condition: cause engine.insurance_fund.balance to drop
below its current value via any sequence of public-instruction calls.
Pyth manipulation and Solana validator attacks are out of scope; admission
bypass, K overflow, ADL math, conservation violation, fee-credits sign
flip, stale-mark arb exceeding fee mechanics, mark-EWMA exploitation,
multi-leg oracle composition flaws — all in scope.
Status (2026-05-25): the v1 bounty-5 (single STOXX50/SOL market, slab
DV4QaQapFp94FjFA8kfXTXo3oe9sGv1wwaszakHjg7hPunder the pre-v16 program4ToDRrQW5j3oeQm8uTAwV9Rp6NhYfH5E5hMKcXkqfwfz) was resolved at the fair mark1.209466and wound down — force-close the 3 accounts →WithdrawInsurance→CloseSlab→ unwrap, recovering ~17.85 SOL to admin. The4ToDRrQW…program is retired; bounty-5 now runs as the v16 multi-market group on the new program4m3ipBQDYX6JQ9YSmUXDjESDHMtGWtiXforkWr9Qoxdi(see the LIVE section above).permissionless_resolve_stale_slotson v1 was the full ~30 d, so the slab stayed crankable until wind-down.
Status (2026-05-15): Bounty 4 (slab
GSAT5fTCUgB9sMMTBsVzhvALbkSv6p9CifWmShHf92hj) auto-resolved on 2026-05-14 ~11:25 UTC after ~3.5 h of trading by hunters (3 paired positions, balanced OI ~$15, $5 SOL insurance fully intact, no socialized loss). The trigger was aKeeperCrankinvocation that satisfied the engine'sBelowProgressFloorpermissionless-recovery gate even though the market was solvent — see/tmp/bug.mdfor the forensic report.Root cause:
bounded_price_step_cap_abs(now_slot) == 0whennow_slot == engine.current_slot, which can happen for same-slot duplicate cranks in cron traffic. The fix (d76ea67 Prevent same-slot target-lag recoveryon percolator-prog) makes the wrapper withhold the recovery target whencrank_slot <= engine.last_market_slot, disarming the gate for healthy markets.Wind-down (2026-05-15): WithdrawInsurance + CloseSlab recovered ~17.3 SOL (5.12 insurance + 12.22 slab rent) to the deployer. No user SOL was lost — hunters were force-closed at the EWMA terminal price with
h_num == h_den(ratio 1.0). The program ID4ToDRrQW5j3oeQm8uTAwV9Rp6NhYfH5E5hMKcXkqfwfzwas upgraded in place to wrapperd76ea67for bounty 5 — the BPF binary and program data address are unchanged, only the program code reflects the fix.
Status (2026-05-13): Bounty 3 (
2LfCFmDKwcnHunqdsCW9uV7KNgBgnFGASs8uM7MwHgHm) went into a Resolved state on 2026-05-07 after maintenance fees consumed a tester's capital faster than the cron tick (at the time, 1 crank/min) could accrue. Conservation held — no insurance loss. The slab was force- closed, insurance withdrawn, program retired viasolana program close; ~21 SOL recovered to the deployer. The program ID is permanently retired. New participants target Bounty 5 above.
Program: 2LfCFmDKwcnHunqdsCW9uV7KNgBgnFGASs8uM7MwHgHm (RETIRED via program close)
Slab: zExGagF9FeMTYGjvkBhknmNzLAP7toX6Aj6Pu1kuvmT (closed)
BPF SHA-256: 6e2bb5aee602aed1de0b2d80f72f97b6b115e0f536438f76d31e0de06d5b7002
percolator-prog: 04b854e
engine pin: 5059332f8a6ce7e8dcff83315e90ac8e2ced7d42
Lesson: single-crank-per-minute cron can't keep up with
MAX_ACCRUAL_DT_SLOTS=10 when OI>0. Bounty 4/5 use the
4-second inner-loop pattern instead.
Status (2026-05-05): Bounty 2 superseded by Bounty 3 (then 4). All four market authorities + program upgrade authority BURNED. The market remains tradable against the deployed binary; new participants should target Bounty 5 above.
Program: 6qWZvUtfyShbxTQkwjCayk3LuGqTGJwBo2QfkePK5jdJ (upgrade authority BURNED)
Slab: CJKBStEn5VXEF9VNTChKKb5YW84MV7LycqMMziVuxJSc (all 4 market auths BURNED)
Oracle: 7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE
Vault ATA: DVTjorxLQvdtoTmDarSHGBox4VCUPb9QQkDjz8mSUxor
Vault PDA: FLnTKmFAtD3z3tTZj2Nyx52DRVwEzGz5ERwnPdK4ewR9
BPF SHA-256: 7c5b75aff1bd2a3f9ea145b63ee74a0c55d3af50922e802dac63388ef0639d1e
percolator-prog: c6e61e6ce0557163eb621a3329abc50d3952be8a
engine pin: 5940285737b514af4416cd8394773abc79e6366d
Status (2026-04-22): the v12.20 mainnet test market below is being sunset. Live state is left running for transparency; new development targets the v12.21 devnet market in the next section. The CLI on this branch (
master) parses the v12.21 wire format and is not backwards-compatible with the v12.20 slab — checkout commit74e902f1if you need to interact with the deprecated mainnet market.
Program: BCGNFw6vDinWTF9AybAbi8vr69gx5nk5w8o2vEWgpsiw (upgrade authority BURNED)
Slab: 5ZamUkAiXtvYQijNiRcuGaea66TVbbTPusHfwMX1kTqB (inverted SOL/USD, all 3 market auths BURNED)
Oracle: 7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE (Pyth SOL/USD PriceUpdateV2, sponsored shard 0)
Feed ID: ef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d
Vault ATA: AcJsfpbuUKHHdoqPuLccRsK794nHecM1XKySE6Umefvr (wrapped SOL, PDA-signed)
Matcher: (none — third parties provision their own)
On-chain provenance (the bytes anyone can dump and hash today):
On-chain SHA-256: 502088e9cf5e1b38cccd31bbab2df18d4958712fb9456d48669241aaddf4cc93
On-chain size: 395,368 bytes (output of `solana program dump`)
percolator-prog: 06f86fb125525af81c0bfd19a295095dda102c07
percolator (engine): 3f55f871a3aa29d7b582fc2641d2106cbac0c32e
percolator-cli: 74e902f165dcac98c87eb80406a2a92a40cf8dc7
MAX_ACCOUNTS: 4096
Upgrade authority: BURNED (--final at deploy time)
Verify locally: solana program dump -u m BCGNFw6vDinWTF9AybAbi8vr69gx5nk5w8o2vEWgpsiw /tmp/mainnet.so && sha256sum /tmp/mainnet.so — must output the SHA above.
To prove the deployed bytes correspond to the named commits, clone the repos at those commits and run cargo build-sbf, then compare the resulting target/deploy/percolator_prog.so against the on-chain dump (see "Reproducing the binary from source" below). The build is not a deterministic Docker reproducible build — the on-chain SHA is the authoritative artifact.
The program cannot use the standard solana-verify / OtterSec automated flow because percolator-prog/Cargo.toml uses percolator = { path = "../percolator" } — a sibling-dir path dep. Docker sandboxes that clone only one repo can't resolve it. Reproducing locally is straightforward:
# 1. Clone both repos as siblings at the deployed commits
mkdir percolator-build && cd percolator-build
git clone https://github.com/aeyakovenko/percolator.git
git clone https://github.com/aeyakovenko/percolator-prog.git
( cd percolator && git checkout 3f55f871a3aa29d7b582fc2641d2106cbac0c32e )
( cd percolator-prog && git checkout 06f86fb125525af81c0bfd19a295095dda102c07 )
# 2. Build with default features (MAX_ACCOUNTS=4096).
# Requires solana CLI 1.18+ toolchain for SBF.
cd percolator-prog
cargo build-sbf
# 3. Hash the locally built ELF.
sha256sum target/deploy/percolator_prog.so
# Record this hash.
# 4. Hash the on-chain bytes and compare.
solana program dump -u m BCGNFw6vDinWTF9AybAbi8vr69gx5nk5w8o2vEWgpsiw /tmp/deployed.so
sha256sum /tmp/deployed.so
# Expected (on-chain): 502088e9cf5e1b38cccd31bbab2df18d4958712fb9456d48669241aaddf4cc93
# The local-build hash from step 3 must equal this for byte-identical
# provenance. `solana program dump` returns the raw on-chain ELF
# (no padding stripping needed); if the local build differs in size,
# the toolchain version drifted from the original deploy.The two hashes matching proves the deployed program is byte-identical to the commits named above. If they don't match, the most common cause is a different solana-cli / cargo build-sbf toolchain version than was used at deploy time — try matching the build environment of the original deployer.
Future deploys that want the automated OtterSec badge should vendor the engine crate into
percolator-prog/engine/as a git submodule (or via a crates.io release) so the build is self-contained from one repo clone.
Configuration:
- Inverted (mark = SOL per USD), wSOL collateral, unit_scale=0 (1 lamport = 1 engine unit)
- Insurance fund: 5 SOL (≈ $435 at init, SOL=$87.32)
tvlInsuranceCapMult = 20→ maxc_tot= 100 SOL (≈ $8,700), grows as insurance grows from new-account feesmaintenanceFeePerSlot = 265lamports → ~0.0572 SOL/day/account ≈ $5/day at SOL=$87new_account_fee = 57_000_000lamports ≈ $5 perInitUser/InitLP(all routed to insurance)permissionlessResolveStaleSlots = 432_000(~48 h) +forceCloseDelaySlots = 432_000(~48 h) = auto-shutdown on 48 h oracle silencemaxStalenessSecs = 60(Pyth sponsor posts every ~2 s, so this is very loose)- 5× leverage (20% IM / 10% MM)
- No LP → no trading yet. Third-party matcher deployers create their own matcher program and call
InitLP(anyone can — no admin gate on the LP slot).
A permissionless keeper crank runs once per hour via cron (see scripts/mainnet-crank.ts). This keeps the market inside the 48 h staleness window and advances funding/accounting. Anyone can run it with their own wallet — it's a free tx signature (~5000 lamports) with no reward since it's permissionless:
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com \
npx tsx scripts/mainnet-crank.tsExample crontab entry (adjust paths for your system):
0 * * * * cd /path/to/percolator-cli && SOLANA_RPC_URL=https://api.mainnet-beta.solana.com /path/to/npx tsx scripts/mainnet-crank.ts >> mainnet-crank.log 2>&1The script reads the manifest at mainnet-market.json, so dropping a copy of that file next to the script on any host is enough to run an independent keeper.
Program: 2SSnp35m7FQ7cRLNKGdW5UzjYFF6RBUNq7d3m5mqNByp (percolator-prog, v12.21)
Matcher: 4HcGCsyjAqnFua5ccuXyt8KRRQzKFbGTJkVChpS7Yfzy (percolator-match, ABI v2)
Slab: 52e67qT6aUiP41CR2JaZQfSAkbZr5MTTZUeYWWwb2zCN (admin-free inverted SOL/USD, Chainlink)
Chainlink: 99B2bTijsU6f1GCT73HmdR7HCFFjGMBcPZY6jZ96ynrR (SOL/USD)
Build provenance (deployed program SHA matches local cargo build-sbf byte-for-byte):
BPF binary SHA-256: 51008b3d16986adac08e276a3a3e683787a5372ed1dc84af43fd89b1343d0501
BPF binary size: 447,520 bytes ELF
percolator-prog: de98bf203ee8df383a0181ae7ed0dccc1867db27
percolator (engine): 5940285737b514af4416cd8394773abc79e6366d
MAX_ACCOUNTS: 4096
- MarketConfig lost
oracle_price_cap_e2bps+min_oracle_price_cap_e2bps(16 bytes); gainedoracle_target_price_e6+oracle_target_publish_timeand a 1-byteinsurance_withdraw_deposits_onlyflag. CONFIG_LEN: 400 → 384. - RiskParams wire still 160 bytes:
max_crank_staleness_slotsis read+discarded, replaced bymax_price_move_bps_per_slot(must be > 0, enforces §1.4 solvency envelope). - Engine
MAX_ACCRUAL_DT_SLOTS = 100(was 10 000 000). Hard cap:permissionlessResolveStaleSlots ≤ 100, andh_max ≤ permissionlessResolveStaleSlots. Effective auto-shutdown window is ~40 sec, so a continuous (sub-40 s) cranker is mandatory once a market has any traffic. - InitMarket account list shrank to 6 keys:
[admin, slab, mint, vault, clock, oracle]. Token program / rent / system program are no longer passed. - SetOraclePriceCap (tag 18) removed. Authority rotations still go through
UpdateAuthority(tag 32). - Conf filter must be in
[50, 1000]bps; oracle staleness must be in(0, 600]seconds.
SLAB_LEN 1_525_624 bytes (unchanged — engine grew +16 to offset MarketConfig shrink)
HEADER_LEN 136 (admin + insurance_authority + insurance_operator)
CONFIG_LEN 384 (v12.21: -16 vs v12.20)
ENGINE_OFF 520 = align_up(136 + 384, 8)
ENGINE_LEN 1_492_176 (+16 vs v12.20: rr_cursor + sweep_generation + price_move_consumed)
ACCOUNT_SIZE 360 (unchanged)
MAX_ACCOUNTS 4096 (configurable via `small`/`medium` cargo features)
The parser in src/solana/slab.ts derives the MAX_ACCOUNTS-dependent offsets from the slab account data length, so small, medium, and full deployments parse transparently.
Removed since v12.18: SetRiskThreshold, UpdateAdmin (tag 12), SetMaintenanceFee, SetOracleAuthority (tag 16), SetInsuranceWithdrawPolicy, QueryLpFees. v12.21 also drops SetOraclePriceCap (tag 18). All four authority roles (admin, hyperp_mark, insurance, insurance_operator) are rotated via UpdateAuthority { kind, new_pubkey } (tag 32). CatchupAccrue (tag 31) and WithdrawInsuranceLimited (tag 23, gated on insurance_operator) remain.
npx tsx scripts/setup-devnet-market.tsSOLANA_RPC_URL=https://devnet.helius-rpc.com/?api-key=YOUR_KEY npx tsx tests/preflight.tsSee DEPLOY_CHECKLIST.md for full coverage details (198+ automated checks).
Risk-increasing trades require a recent keeper crank (within 200 slots).
percolator-cli keeper-crank --slab <slab-pubkey> --oracle <oracle-pubkey>Matchers are programs that determine trade pricing. The 50bps passive matcher accepts all trades at oracle price ± 50bps spread. You can create custom matchers with different pricing logic.
A matcher program must implement:
- Init instruction (tag
0x02): Initialize context with LP PDA for security - Match instruction (tag
0x00): Called by percolator duringtrade-cpi
CRITICAL: The matcher program MUST error if the LP PDA is not a signer. The percolator program signs the LP PDA via invoke_signed during CPI. If your matcher accepts unsigned calls, attackers can bypass LP authorization and steal funds. Always check lp_pda.is_signer and return MissingRequiredSignature if false.
The matcher context must also store the LP PDA and verify it matches on every trade call. This prevents unauthorized programs from using your matcher.
use solana_program::{account_info::AccountInfo, entrypoint, program_error::ProgramError, pubkey::Pubkey};
entrypoint!(process_instruction);
fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
data: &[u8],
) -> Result<(), ProgramError> {
match data[0] {
0x00 => {
// Match instruction - MUST verify LP PDA signature
let lp_pda = &accounts[0];
let ctx = &accounts[1];
// Verify LP PDA is a signer (signed by percolator via CPI)
if !lp_pda.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Verify LP PDA matches stored PDA in context
let ctx_data = ctx.try_borrow_data()?;
let stored_pda = Pubkey::new_from_array(ctx_data[16..48].try_into().unwrap());
if *lp_pda.key != stored_pda {
return Err(ProgramError::InvalidAccountData);
}
// Process trade...
Ok(())
}
0x02 => {
// Init instruction - store LP PDA for verification
let lp_pda = &accounts[0];
let ctx = &accounts[1];
// Store LP PDA in context at offset 16
let mut ctx_data = ctx.try_borrow_mut_data()?;
ctx_data[16..48].copy_from_slice(&lp_pda.key.to_bytes());
Ok(())
}
_ => Err(ProgramError::InvalidInstructionData),
}
}CRITICAL: You must create the matcher context AND initialize the LP in a single atomic transaction. This prevents race conditions where an attacker could initialize your context with their LP PDA.
// Find the FIRST FREE slot (match percolator's bitmap scan)
const usedSet = new Set(parseUsedIndices(slabData));
let lpIndex = 0;
while (usedSet.has(lpIndex)) {
lpIndex++;
}
// Derive LP PDA for the index we'll create
const [lpPda] = deriveLpPda(PROGRAM_ID, SLAB, lpIndex);
// ATOMIC: All three in ONE transaction
const atomicTx = new Transaction().add(
// 1. Create matcher context account
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: matcherCtxKp.publicKey,
lamports: rent,
space: 320,
programId: MATCHER_PROGRAM_ID,
}),
// 2. Initialize matcher context WITH LP PDA
{
programId: MATCHER_PROGRAM_ID,
keys: [
{ pubkey: lpPda, isSigner: false, isWritable: false },
{ pubkey: matcherCtxKp.publicKey, isSigner: false, isWritable: true },
],
data: initMatcherData,
},
// 3. Initialize LP in percolator
buildIx({ programId: PROGRAM_ID, keys: initLpKeys, data: initLpData })
);
await sendAndConfirmTransaction(conn, atomicTx, [payer, matcherCtxKp]);percolator-cli deposit \
--slab <slab-pubkey> \
--user-idx <lp-idx> \
--amount <amount>The current matcher uses this unified context layout:
Offset Size Field Description
0 8 magic 0x5045_5243_4d41_5443 ("PERCMATC")
8 4 version 3
12 1 kind 0=Passive, 1=vAMM
13 3 _pad0
16 32 lp_pda LP PDA for signature verification
48 4 trading_fee_bps Fee on fills
52 4 base_spread_bps Minimum spread
56 4 max_total_bps Cap on total cost
60 4 impact_k_bps Impact multiplier
64 16 liquidity_notional_e6 Quoting depth (u128)
80 16 max_fill_abs Max fill per trade (u128)
96 16 inventory_base LP inventory state (i128)
112 8 last_oracle_price_e6 Last oracle price
120 8 last_exec_price_e6 Last execution price
128 16 max_inventory_abs Inventory limit (u128)
144 112 _reserved
The context data starts at offset 64 in the 320-byte account (first 64 bytes reserved for matcher return data).
# Initialize a new market (see init-market --help for all params)
percolator-cli init-market --slab <pubkey> --mint <pubkey> --vault <pubkey> \
--index-feed-id <hex> --max-staleness-secs <n> --conf-filter-bps <n> ...
# View slab state
percolator-cli slab:get --slab <pubkey>
percolator-cli slab:header --slab <pubkey>
percolator-cli slab:config --slab <pubkey>
percolator-cli slab:nonce --slab <pubkey># Initialize user account
percolator-cli init-user --slab <pubkey>
# Deposit collateral
percolator-cli deposit --slab <pubkey> --user-idx <n> --amount <lamports>
# Withdraw collateral
percolator-cli withdraw --slab <pubkey> --user-idx <n> --amount <lamports>
# Trade (no CPI)
percolator-cli trade-nocpi --slab <pubkey> --user-idx <n> --lp-idx <n> \
--size <i128> --oracle <pubkey>
# Close account
percolator-cli close-account --slab <pubkey> --idx <n># Initialize LP account
percolator-cli init-lp --slab <pubkey>
# Trade with CPI (matcher)
percolator-cli trade-cpi --slab <pubkey> --user-idx <n> --lp-idx <n> \
--size <i128> --matcher-program <pubkey> --matcher-ctx <pubkey># Crank the keeper (liquidations are processed automatically during crank)
percolator-cli keeper-crank --slab <pubkey> --nonce <n> --oracle <pubkey># Update admin
percolator-cli update-admin --slab <pubkey> --new-admin <pubkey>
# Top up insurance fund
percolator-cli topup-insurance --slab <pubkey> --amount <lamports>
# Update market configuration (funding and threshold params)
percolator-cli update-config --slab <pubkey> \
--funding-horizon-slots <n> \
--funding-k-bps <n> \
--funding-scale-notional-e6 <n> \
--funding-max-premium-bps <n> \
--funding-max-bps-per-slot <n> \
--thresh-floor <n> \
--thresh-risk-bps <n> \
--thresh-update-interval-slots <n> \
--thresh-step-bps <n> \
--thresh-alpha-bps <n> \
--thresh-min <n> \
--thresh-max <n> \
--thresh-min-step <n>The oracle authority feature allows the admin to push prices directly instead of relying on Chainlink. This is useful for testing scenarios like flash crashes, ADL triggers, and stress testing.
# Set oracle authority (admin only)
percolator-cli set-oracle-authority --slab <pubkey> --authority <pubkey>
# Push oracle price (authority signer required)
# Price is in USD (e.g., 143.50 for $143.50)
percolator-cli push-oracle-price --slab <pubkey> --price <usd>
# Disable oracle authority (reverts to Chainlink)
percolator-cli set-oracle-authority --slab <pubkey> --authority 11111111111111111111111111111111Security Notes:
- Only the market admin can set the oracle authority
- Only the designated authority can push prices
- Zero price (0) is rejected to prevent division-by-zero attacks
- Setting authority to the zero address disables the feature
# Unit tests (offline)
pnpm test
# Preflight — 93 checks across 25 sections, 3 market types
# Behavioral correctness + conservation invariants
SOLANA_RPC_URL=https://devnet.helius-rpc.com/?api-key=YOUR_KEY npx tsx tests/preflight.ts
# Live state verification — 100 checks, exhaustive before/after state diffs
# Verifies exact field deltas on every instruction (deposit, trade, crank, etc.)
npx tsx scripts/live-verify.ts
# Integration tests (T1-T22, needs SOLANA_RPC_URL)
npx tsx tests/runner.tsSee DEPLOY_CHECKLIST.md for full coverage details (198+ automated checks).
# Setup a new devnet market with funded LP and insurance
npx tsx scripts/setup-devnet-market.ts# Crank bot - runs continuous keeper cranks (every 5 seconds)
npx tsx scripts/crank-bot.ts
# Random traders bot - 5 traders making random trades with momentum bias
# Routes to best LP by simulated price (vAMM vs passive)
npx tsx scripts/random-traders.ts# Add a vAMM-configured LP (creates matcher context + LP account + deposits collateral)
npx tsx scripts/add-vamm-lp.ts# Dump full market state to state.json (positions, margins, parameters)
npx tsx scripts/dump-state.ts
# Dump comprehensive market state to market.json (all on-chain fields)
npx tsx scripts/dump-market.ts
# Check liquidation risk for all accounts
npx tsx scripts/check-liquidation.ts
# Check funding rate status and accumulation
npx tsx scripts/check-funding.ts
# Display market risk parameters
npx tsx scripts/check-params.ts# Find user account index by owner pubkey
npx tsx scripts/find-user.ts <slab_pubkey> # List all accounts
npx tsx scripts/find-user.ts <slab_pubkey> <owner_pubkey> # Find specific account# Haircut-ratio system stress test - conservation, insurance, undercollateralization
npx tsx scripts/stress-haircut-system.ts
# Worst-case stress test - gap risk, insurance exhaustion, socialized losses
npx tsx scripts/stress-worst-case.ts
# Oracle authority stress test - tests price manipulation scenarios
npx tsx scripts/oracle-authority-stress.ts
npx tsx scripts/oracle-authority-stress.ts 0 # Run specific scenario by index
npx tsx scripts/oracle-authority-stress.ts --disable # Disable oracle authority after tests
# Pen-test oracle - comprehensive security testing
# Tests: flash crash, price edge cases, timestamp attacks, funding manipulation
npx tsx scripts/pentest-oracle.ts
# Protocol invariant tests
npx tsx scripts/test-price-profit.ts # Price-profit relationship validation
npx tsx scripts/test-threshold-increase.ts # Threshold auto-adjustment verification
npx tsx scripts/test-lp-profit-realize.ts # LP profit realization and withdrawal
npx tsx scripts/test-profit-withdrawal.ts # Profit withdrawal limit enforcement# Update funding configuration parameters
npx tsx scripts/update-funding-config.tsPercolator supports multiple oracle modes:
- Pyth - Uses Pyth Network price feeds via PriceUpdateV2 accounts
- Chainlink - Uses Chainlink OCR2 aggregator accounts
- Oracle Authority - Admin-controlled price push for testing
The program auto-detects oracle type by checking the account owner. If an oracle authority is set and has pushed a price, that price is used instead of Pyth/Chainlink.
Oracle Authority Priority:
- If
oracle_authority != 0ANDauthority_price_e6 != 0AND timestamp is recent: use authority price - Otherwise: fall back to Pyth/Chainlink
Inverted markets use 1/price internally. This is useful for markets like SOL/USD where you want SOL-denominated collateral and let users take long/short USD positions. Going long = long USD (profit if SOL drops), going short = short USD (profit if SOL rises).
Matchers are external programs that determine trade pricing. The percolator-match program supports two modes:
Passive Mode (mode=0): Fixed spread around oracle price
- Simple bid/ask spread (e.g., 50bps = 0.5%)
- No price impact based on trade size
vAMM Mode (mode=1): Spread + impact pricing
trading_fee_bps: Fee charged on every fill (e.g., 5 = 0.05%)base_spread_bps: Minimum spread (e.g., 10 = 0.10%)impact_k_bps: Price impact at full liquidity utilizationmax_total_bps: Cap on total cost (spread + impact + fee)liquidity_notional_e6: Quoting depth for impact calculation
The random-traders bot routes to the LP with the best simulated price, computing quotes using each LP's matcher parameters.
Apache 2.0 - see LICENSE