Skip to content

aeyakovenko/percolator-cli

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

267 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

percolator-cli

Command-line interface for interacting with the Percolator perpetuals protocol on Solana.

Related Repositories

Third-party repositories are community contributions. Do not trust — always verify. Review the code yourself before running or deploying anything.

Disclaimer

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.

Installation

pnpm install
pnpm build

Configuration

Create 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

Mainnet Bounty 5 — bounty_v16_multimarket_sol_20x (v16, LIVE)

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 match

Configuration

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)

Trading is permissionless — but you push your own oracles

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 reconfigConfigureAuthMark / ConfigureHybridOracle — but not to trading.)

To trade you catch up just the asset(s) you touch (permissionless):

  1. Freshen that asset's Pyth pull legs — publish the shard-0 PriceUpdateV2 accounts (e.g. pyth-solana-receiver against hermes.pyth.network).
  2. Catch-up crank the asset with PermissionlessCrank action:0 repeatedly: each crank advances accrual by ≤ max_accrual_dt (20 slots), so a long-idle asset needs many cranks (a day of drift ≈ thousands) until its slot_last is within max_accrual_dt of the current slot.
  3. Then InitPortfolioDepositTradeNoCpi (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:

  1. 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;
  2. 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);
  3. 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 fires PermissionlessCrank action:1 as 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;
  4. 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.log

Trading 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 stale last_oracle_price carries that mark until the next composite refresh propagates.
  • MAX_ACCRUAL_DT_SLOTS=10 caps 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). InitLP still requires matcher_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.


Deprecated: Mainnet Bounty 5 v1 — STOXX/SOL single-market (wound down)

Status (2026-05-25): the v1 bounty-5 (single STOXX50/SOL market, slab DV4QaQapFp94FjFA8kfXTXo3oe9sGv1wwaszakHjg7hP under the pre-v16 program 4ToDRrQW5j3oeQm8uTAwV9Rp6NhYfH5E5hMKcXkqfwfz) was resolved at the fair mark 1.209466 and wound down — force-close the 3 accounts → WithdrawInsuranceCloseSlab → unwrap, recovering ~17.85 SOL to admin. The 4ToDRrQW… program is retired; bounty-5 now runs as the v16 multi-market group on the new program 4m3ipBQDYX6JQ9YSmUXDjESDHMtGWtiXforkWr9Qoxdi (see the LIVE section above). permissionless_resolve_stale_slots on v1 was the full ~30 d, so the slab stayed crankable until wind-down.

Deprecated: Mainnet Bounty 4 (wound 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 a KeeperCrank invocation that satisfied the engine's BelowProgressFloor permissionless-recovery gate even though the market was solvent — see /tmp/bug.md for the forensic report.

Root cause: bounded_price_step_cap_abs(now_slot) == 0 when now_slot == engine.current_slot, which can happen for same-slot duplicate cranks in cron traffic. The fix (d76ea67 Prevent same-slot target-lag recovery on percolator-prog) makes the wrapper withhold the recovery target when crank_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 ID 4ToDRrQW5j3oeQm8uTAwV9Rp6NhYfH5E5hMKcXkqfwfz was upgraded in place to wrapper d76ea67 for bounty 5 — the BPF binary and program data address are unchanged, only the program code reflects the fix.


Deprecated: Mainnet Bounty 3 (wound down)

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 via solana 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.

Deprecated: Mainnet Bounty 2

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

Deprecated: v12.20 Mainnet (sunset)

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 commit 74e902f1 if 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.

Reproducing the binary from source

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 → max c_tot = 100 SOL (≈ $8,700), grows as insurance grows from new-account fees
  • maintenanceFeePerSlot = 265 lamports → ~0.0572 SOL/day/account ≈ $5/day at SOL=$87
  • new_account_fee = 57_000_000 lamports ≈ $5 per InitUser / InitLP (all routed to insurance)
  • permissionlessResolveStaleSlots = 432_000 (~48 h) + forceCloseDelaySlots = 432_000 (~48 h) = auto-shutdown on 48 h oracle silence
  • maxStalenessSecs = 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).

Hourly Crank

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.ts

Example 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>&1

The 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.

Devnet (v12.21, current)

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

v12.21 highlights

  • MarketConfig lost oracle_price_cap_e2bps + min_oracle_price_cap_e2bps (16 bytes); gained oracle_target_price_e6 + oracle_target_publish_time and a 1-byte insurance_withdraw_deposits_only flag. CONFIG_LEN: 400 → 384.
  • RiskParams wire still 160 bytes: max_crank_staleness_slots is read+discarded, replaced by max_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, and h_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.

On-chain layout (BPF)

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.

Instruction set (v12.21)

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.

Create a Test Market

npx tsx scripts/setup-devnet-market.ts

Preflight Test (93 checks)

SOLANA_RPC_URL=https://devnet.helius-rpc.com/?api-key=YOUR_KEY npx tsx tests/preflight.ts

See DEPLOY_CHECKLIST.md for full coverage details (198+ automated checks).

Keeper Crank

Risk-increasing trades require a recent keeper crank (within 200 slots).

percolator-cli keeper-crank --slab <slab-pubkey> --oracle <oracle-pubkey>

Adding Your Own Matcher

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.

Matcher Interface

A matcher program must implement:

  1. Init instruction (tag 0x02): Initialize context with LP PDA for security
  2. Match instruction (tag 0x00): Called by percolator during trade-cpi

Security Requirements

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.

Creating a Custom Matcher

Step 1: Write the matcher program

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),
    }
}

Step 2: Create LP with ATOMIC transaction

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]);

Step 3: Deposit collateral to LP

percolator-cli deposit \
  --slab <slab-pubkey> \
  --user-idx <lp-idx> \
  --amount <amount>

Matcher Context Layout (Unified Version 3)

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).

Commands Reference

Market Operations

# 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>

User Operations

# 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>

LP Operations

# 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>

Keeper Operations

# Crank the keeper (liquidations are processed automatically during crank)
percolator-cli keeper-crank --slab <pubkey> --nonce <n> --oracle <pubkey>

Admin Operations

# 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>

Oracle Authority (Admin Only)

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 11111111111111111111111111111111

Security 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

Testing

# 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.ts

See DEPLOY_CHECKLIST.md for full coverage details (198+ automated checks).

Scripts

Market Setup

# Setup a new devnet market with funded LP and insurance
npx tsx scripts/setup-devnet-market.ts

Bots

# 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

LP Setup

# Add a vAMM-configured LP (creates matcher context + LP account + deposits collateral)
npx tsx scripts/add-vamm-lp.ts

Market Analysis

# 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

User Tools

# 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

Stress Testing & Security

# 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

Configuration

# Update funding configuration parameters
npx tsx scripts/update-funding-config.ts

Architecture

Price Oracles

Percolator supports multiple oracle modes:

  1. Pyth - Uses Pyth Network price feeds via PriceUpdateV2 accounts
  2. Chainlink - Uses Chainlink OCR2 aggregator accounts
  3. 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:

  1. If oracle_authority != 0 AND authority_price_e6 != 0 AND timestamp is recent: use authority price
  2. Otherwise: fall back to Pyth/Chainlink

Inverted Markets

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

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 utilization
  • max_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.

License

Apache 2.0 - see LICENSE

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors