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-06-03, LIVE): single-asset perpetuals market — STOXX50 priced in SOL via a 3-leg Pyth composite (STOXX·EUR × EUR/USD ÷ SOL/USD, inverted). 20× leverage, wSOL collateral,
HYBRID_AFTER_HOURSoracle mode so the EWMA carries the mark when STOXX is closed. New layout (commit792256bcollapsed the 7 market-level authorities into a singlemarketauth; commitdba87a9unified asset 0 with assets 1..N): the market account is 2,955 B / 0.0215 SOL rent instead of the old 116 KB / 2.06 SOL — 99× cheaper.(The previous 3-asset
BhkMic5g…bounty market was wound down 2026-06-03 via theunsafe_forced_closeadmin escape hatch, recovering +6.576 SOL from every old-layout zombie account under PROG including the long-stranded AWCZ2pK dust and the 2026-05-26 attacker husk8oYjDr2…. See Deprecated below.)
On-chain addresses
Program: 4m3ipBQDYX6JQ9YSmUXDjESDHMtGWtiXforkWr9Qoxdi (v16 program, marketauth layout)
Market: 4AXbMuJzrUv5KtVs6zc5jDtXTB4XKhKtEBGU6BmkVut4 (STOXX/SOL, launched 2026-06-03)
Vault PDA: 84o6UPbT4WbhYiK3aWFWCwBeMDc79zB7rbY8fY8Nby53
Collateral: So11111111111111111111111111111111111111112 (wSOL)
marketauth: A3Mu2nQdjJXhJkuUDBbF2BdvgDs5KodNE9XsetXNMrCK (admin; single collapsed key)
Keeper key: 9WiMAQtdx8zXMovePuaZ7v472UsFgZ7vkL7rr7APuxBQ (dedicated; dormant-when-empty)
Keeper pf: 9cQXfQcu5sTf4fZBSJs5hHSMkqpUrN8AynoAkafkBShz
Insurance: 0 SOL (not seeded at launch)
Matcher: (none — bilateral TradeNoCpi only on this market)
Manifest: mainnet-stoxx-sol-market.json
Asset 0 — STOXX/SOL (the only asset; this is a single-asset market)
| Pyth feed | PriceUpdateV2 account | |
|---|---|---|
| leg 0 | STOXX50·EUR dd08f0a4… |
C2Cf16vF6LX8GrWJwfZga5z5tjVsax5VWnL2T7Q8CF91 |
| leg 1 | EUR/USD a995d00b… |
Fu76ChamBDjE8UuGLV6GP2AcPPSU6gjhkNhAyuoPm7ny |
| leg 2 | SOL/USD ef0d8b6f… |
7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE |
Composite formula: STOXX·EUR × EUR/USD ÷ SOL/USD (via oracle_leg_flags = DIVIDE_LEG3 = 0x02), then inverted so SOL is the base unit. At launch
(slot 424152688) the EWMA seeded at mark_ewma_e6 = 961,571 = 0.9616
SOL per STOXX share (STOXX ~$72, SOL ~$69 → 1.04 STOXX/SOL inverted).
oracle_mode = HYBRID_AFTER_HOURS (mode 1). When all legs are fresh inside
max_staleness_secs the engine reads the composite oracle; when any leg
goes stale (STOXX overnight + weekends), it falls back to the EWMA mark.
SOL/USD is kept fresh by Pyth's sponsored shard-0 crank; STOXX·EUR + EUR/USD
are self-pushed by the keeper from hermes.pyth.network during EU market
hours (Eurex 07:00–22:00 UTC Mon–Fri) via the local pyth-pusher subprocess.
Build provenance
BPF binary SHA-256: 58d155fa5f64684a66f23e93a7f2b23cac06165a58e3c0303194e13390539eef
BPF binary size: 1,047,480 bytes ELF
percolator-prog: 2c7035f (HEAD — tests-only chain on top of the behavior
change 7144d9b 'Bind matcher config to LP portfolios':
SetMatcherAuthorization is replaced by SetMatcherConfig
(same tag 68, different semantics), the matcher tuple
is now stored in a 104-byte tail on the LP portfolio,
TradeCpi drops signer_b (7 fixed accounts, was 8), and
the old kind=5 matcher-auth account is gone. Commits
65fd34e / aa17204 / cbcf3aa / 8a2d26e / 86825b9 /
2c7035f only add tests/v16_cu.rs coverage; BPF is
byte-identical to 7144d9b.)
prior: 8306372 / BPF 1c1ca8ff… / 978,504 B (deterministic backing residual reward counter)
6da5d8c / BPF 5c6625df… / 978,504 B (asset restart byte-hygiene tests)
b469dae / BPF 3acd544c… / 971,344 B (matcher PDA binding + oracle-lag insurance gate)
517a55a / BPF 927f565c… / 966,168 B (bound unsigned matcher fills to LP auth)
9343dad / BPF 5b9464a5… / 956,592 B (cross-market reward rejection)
198651f / BPF c3ec9493… / 956,104 B (fixed try_empty stack)
c050578 / BPF 11eafaf1… / 952,544 B (had try_empty warning)
0a631cf / BPF b0cc3f80… / 952,272 B (deployed 2026-06-04)
70294cb / BPF 1aedbfa2… / 918,184 B (deployed 2026-06-03)
engine pin: 4897680 (unchanged through 8 deploys now — 'Add v16 reset
finalization API'. Engine main has progressed to a390ac6
'Widen v16 lien accounting proofs' via several proof-only
commits the wrapper has not yet bumped to.)
Layout: marketauth-collapse (commit 792256b)
+ asset-0 unified with assets 1..N (commit dba87a9)
+ matcher-config tail on portfolios (commit 7144d9b)
WrapperConfigV16 = 432 B (was 624 B; 7 keys → 1 marketauth)
MarketGroupHeader = 710 B (was 638 B; engine accounting block)
PortfolioAccount = 9,299 B (was 9,195 B; +104 B matcher
config tail that records matcher_program,
matcher_context, matcher_delegate, enabled)
DEFAULT_MARKET_SLOT_CAPACITY = 1 (market reallocs on asset activate)
MARKET_ACCOUNT_LEN: 2,955 B for a 1-slot market = 448 (header+config)
+ 710 (MG header)
+ 1 × 1,797 (slot stride: 512 oracle + 1,285 engine)
Verify locally:
git clone https://github.com/aeyakovenko/percolator-prog.git
cd percolator-prog && git checkout 2c7035f
cargo build-sbf --tools-version v1.52
sha256sum target/deploy/percolator_prog.so
# Expected: 58d155fa5f64684a66f23e93a7f2b23cac06165a58e3c0303194e13390539eef
solana program dump -u m 4m3ipBQDYX6JQ9YSmUXDjESDHMtGWtiXforkWr9Qoxdi /tmp/deployed.so
head -c 1047480 /tmp/deployed.so | sha256sum # must matchConfiguration
| Param | Value | Notes |
|---|---|---|
mm / im (margin) |
500 bps / 500 bps | 20× nominal leverage, no opening buffer |
max_price_move_bps_per_slot |
49 | §1.4 envelope at mm=500 + max_accrual_dt=10 |
max_accrual_dt_slots |
10 | per-crank accrual step; keeper targets ≤20 slots gap (2 cranks/cycle) |
h_min / h_max |
0 / 6_480_000 | up to ~30 d profit maturity |
max_trading_fee_bps |
10,000 | hybrid mode cap (100%) |
trade_fee_base_bps |
1 | hybrid base; +EWMA movement bps off-hours |
liquidation_fee_bps |
5 | 0.05% per liq, capped at 50 SOL |
min_nonzero_mm_req / _im_req |
500 / 600 | exact-N proof room |
min_liquidation_abs |
0 | no per-call dust floor |
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 |
35 lamports | ≈ $0.50/day account hold (SOL ≈ $69 at launch) |
permissionless_market_init_fee |
7,500,000 lamports | ≈ $0.50 to permissionlessly append a new asset slot |
max_staleness_secs |
259,200 (3 d) | covers weekend STOXX gaps; HYBRID_AFTER_HOURS falls back to EWMA past this |
hybrid_soft_stale_slots |
200 | soft-stale window before EWMA-only fallback inside max_staleness |
mark_ewma_halflife_slots |
300 | EWMA half-life ≈ 2 min |
insurance_withdraw_max_bps |
5,000 | 50% per-window withdraw cap |
insurance_withdraw_cooldown_slots |
216,000 (~24 h) | between withdraws |
| Insurance seed | 0 SOL | not seeded yet at launch — top up via TopUpInsurance / TopUpInsuranceDomain |
InitPortfolio + Deposit are cheap (rent ~0.065 SOL + tx fees); there is
no per-account creation fee on this market (the newAccountFee field was
removed in the new layout). Bilateral TradeNoCpi works without a matcher;
both sides simply sign.
Before a trade the asset must be cranked up to within max_accrual_dt
slots of the current slot. The keeper does this when there are positions
open; users entering against an idle market can self-crank by packing one or
more PermissionlessCrank action:0 ixs into their own trade tx.
scripts/mainnet-stoxx-sol-keeper.ts (cron wrapper:
scripts/stoxx-sol-keeper-cron.sh) runs once per minute on the
dedicated keeper key. It costs roughly nothing when the market is empty:
| state | cranks/cycle | cost |
|---|---|---|
DORMANT — no portfolios with capital, gap < HEARTBEAT_SLOTS (5M slots ≈ 23 d) |
0 | 0 SOL/day |
DORMANT heartbeat — gap > HEARTBEAT_SLOTS |
1 | ~12 cranks/year → effectively 0 |
| ACTIVE — any portfolio has capital | up to 9 in one tx | 5,000 lamports/tick × 1,440/day = 0.0072 SOL/day ≈ $0.50/day |
Active-state cranking pulls the engine's asset.slot_last back to within
TARGET_GAP=20 slots of the clock — even though the on-chain
max_accrual_dt_slots=10 cap means each crank only advances 10 slots, the
script packs ceil(gap / max_accrual_dt) + 1 cranks per tick. Zero priority
fee — only the 5,000 lamport base fee.
The keeper also self-pushes STOXX·EUR + EUR/USD from Pyth Hermes via the
local ~/pyth-pusher subprocess only when the market is active (or
heartbeating). Off-hours dormancy doesn't push anything.
Cron entry (single line; the wrapper exports PATH + cd's into the repo):
* * * * * /bin/sh /home/anatoly/percolator-cli/scripts/stoxx-sol-keeper-cron.sh # percolator-stoxx-sol-keeperLog: ~/.cache/percolator/stoxx-sol-keeper.log.
Cause group.insurance on 4AXbMuJzrUv5… 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, and the 3-leg oracle composition
(DIVIDE_LEG3 + invert) are all in scope. (No insurance seeded yet at
launch — top up via TopUpInsurance is the prerequisite to any drain
attempt being profitable.)
Status (2026-06-03): the v16 multi-market bounty-6 (
BhkMic5g…, 3 assets, on the pre-marketauth116 KB layout) was wound down + drained via theunsafe_forced_closeadmin escape hatch. Insurance + vault residue, every portfolio, the 2026-05-26 attacker husk8oYjDr2…, and the long-stranded dust-pathology huskAWCZ2pK…all rent-reclaimed (+6.576 SOL total). The old keeper portfolio5iWTBYod…is gone. See Bounty 7 above.
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; the live bounty (now Bounty 6) runs as the v16 multi-market group on4m3ipBQDYX6JQ9YSmUXDjESDHMtGWtiXforkWr9Qoxdi(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 6 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 6 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