This is the operator handbook for deploying the percli on-chain program and a live market on devnet or mainnet. It walks you from a clean machine to a running keeper bot, covers the v0.9 → v1.0 migration path, and documents the production checklists you should run through before sending real value.
Audit status: percli has not been audited by a third-party security firm. Treat all instructions in this document as the recipe for a sandboxed deployment until that changes. Mainnet operators are responsible for their own threat modelling.
- Prerequisites
- Build the on-chain program
- Deploy to devnet
- Initialize your first market
- Verify the deployment
- Upgrade an existing program
- Migrating v0.9.x markets to v1.0
- Authority transfer (two-step)
- Run a keeper
- Mainnet checklist
- Troubleshooting
| Tool | Version | Why |
|---|---|---|
| Rust | 1.79+ stable | Workspace MSRV |
| Solana CLI | 2.0.x or newer | Wallet, RPC, program deploy |
cargo-build-sbf |
bundled with Solana CLI | Compiles the program for the SBF target |
solana-keygen |
bundled with Solana CLI | Generates the program keypair |
| (optional) Anchor CLI 1.0 | only if you want to regenerate IDLs | |
(optional) jq |
for parsing JSON CLI output |
Install the Solana toolchain:
sh -c "$(curl -sSfL https://release.anza.xyz/v2.2.0/install)"
export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"Install percli:
# from crates.io (with chain + pyth features)
cargo install percli --features "chain pyth"
# or from source
git clone https://github.com/kamiyoai/percli
cd percli
cargo install --path crates/percli --features "chain pyth"Sanity-check your environment:
solana --version # solana-cli 2.x
cargo build-sbf --version # solana-cargo-build-sbf 2.x
percli --version # percli 1.0.0The program lives in crates/percli-program and declares its program ID in
src/lib.rs. The default ID is the public devnet ID
PercQhVBxXnVCaAhfrPZFc2dVZcQANnwEYroogLJFwm. You should not deploy under
this ID unless you also control the upgrade authority for it. For your own
deployments, generate a fresh keypair (see step 3) and update declare_id!
in crates/percli-program/src/lib.rs and the [programs.localnet] /
[programs.devnet] section of Anchor.toml before building.
Build the SBF artifact:
cargo build-sbf --skip-tools-install --manifest-path crates/percli-program/Cargo.tomlThis produces target/deploy/percli_program.so. The binary is reproducible
under the same toolchain version — if you need bit-for-bit reproducibility for
audit purposes, pin the Solana CLI and Rust toolchain in CI (see
.github/workflows/sbf.yml).
solana-keygen new --outfile target/deploy/percli_program-keypair.json
solana address -k target/deploy/percli_program-keypair.json
# → prints the new program IDUpdate declare_id! in crates/percli-program/src/lib.rs and the program ID
in Anchor.toml to match this address, then rebuild (step 2). The on-chain
discriminator and PDA derivation depend on the program ID, so any mismatch
will silently break instruction dispatch.
solana config set --url devnet
solana-keygen new --outfile ~/.config/solana/id.json # if you don't have one
solana airdrop 5 # 5 SOL on devnetProgram deploys cost ~5 SOL on devnet (rent for ~1 MB of program data).
solana program deploy \
--program-id target/deploy/percli_program-keypair.json \
target/deploy/percli_program.soYou should see Program Id: <YOUR_PROGRAM_ID>. The upgrade authority is
your default solana config get keypair unless you pass --upgrade-authority.
A market is a single perp pair with one collateral token, one Pyth oracle,
and one matcher signing key. The on-chain account is a PDA derived from
["market", authority] and a token vault PDA derived from
["vault", market].
percli chain reads connection settings from environment variables and the
default Solana CLI config. The minimum set:
export PERCLI_RPC_URL=https://api.devnet.solana.com
export PERCLI_PROGRAM_ID=<YOUR_PROGRAM_ID>
export PERCLI_KEYPAIR=$HOME/.config/solana/id.jsonThe signer of percli chain deploy becomes the market authority. Keep this
key safe — it controls all parameter updates, oracle/matcher rotation, and
authority transfers. You can rotate it later using
the two-step authority transfer.
You need three pubkeys before deploying:
| Field | Devnet example | Notes |
|---|---|---|
--mint |
4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU (devnet USDC) |
Any SPL token mint |
--oracle |
J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix (Pyth devnet SOL/USD) |
Must be a Pyth Price account |
--matcher |
<YOUR_MATCHER_KEY> |
The only signer allowed to call trade. Often a multisig or a dedicated keypair held by the matching engine. |
--init-price is the bootstrap mark price (in token base units). It's used
to seed the engine's mark estimate before the first crank arrives.
percli chain deploy \
--mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU \
--oracle J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix \
--matcher $(solana address) \
--init-price 100000000You should see:
Deploying market...
Authority: <YOUR_PUBKEY>
Market PDA: <MARKET_PDA>
Vault PDA: <VAULT_PDA>
Mint: 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU
Oracle: J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix
Matcher: <YOUR_PUBKEY>
RPC: https://api.devnet.solana.com
Tx: <TX_SIGNATURE>
Market deployed.
Under the hood this submits a single transaction with two instructions:
system_program::create_accountallocating8 + 168 + size_of::<RiskEngine>()bytes for the market PDA (the discriminator, the v1 header, and the engine state).percli_program::initialize_marketwhich writes the v1 discriminatorb"percmrk\x01", the header, and runs the engine init.
Account size note. The host-side
size_of::<RiskEngine>()is ~536 bytes larger than the SBF (on-chain) value due to platform alignment differences in the engine's[Account; MAX_ACCOUNTS]array. The chain client always allocates the host-side (larger) value, which the program'sdata_len() >= MARKET_ACCOUNT_SIZEconstraint accepts. Don't try to hand-roll the create_account size from the SBF constant — you'll get aConstraintRawfailure.
percli chain query market --address <MARKET_PDA>You should see the freshly initialized header (authority, mint, oracle,
matcher, pending_authority = 11111111111111111111111111111111) and zeroed
engine counters.
To inspect the raw account on a block explorer:
solana account <MARKET_PDA> --output json | jq '.account.data[0]' | head -c 32
# → "cGVyY21yawE=" (base64 of `percmrk\x01`, the v1 discriminator)The 8th byte (0x01) is the layout version. Pre-v1 markets show 0x74
('t') and must be migrated — see section 7.
cargo build-sbf --skip-tools-install --manifest-path crates/percli-program/Cargo.tomlsolana program deploy \
--program-id <YOUR_PROGRAM_ID> \
--upgrade-authority ~/.config/solana/id.json \
target/deploy/percli_program.soThe upgrade authority defaults to whoever first deployed the program. You can
inspect it via solana program show <PROGRAM_ID>.
Don't forget the data migration. A program upgrade only swaps out the bytecode — it does not rewrite existing market accounts. If your upgrade changes the layout (as v0.9 → v1.0 does), you must run
migrate_header_v1on every market account before the new bytecode will accept other instructions for that market. See section 7.
v1.0 introduces a pending_authority field in MarketHeader, expanding it
from 136 bytes to 168 bytes. Migration is a separate, idempotent
authority-only instruction. It does not realloc the account (no rent top-up
required), it shifts the engine bytes 32 bytes forward inside the existing
buffer and stamps a new layout-version byte.
Before upgrading the program bytecode, snapshot every v0.9 market on your deployment so you can prove the migration was lossless:
for market in $(percli chain query markets); do
solana account "$market" --output json > "snapshots/$market.before.json"
doneDeploy the v1.0 program bytecode (section 6) but do not yet trade against
any v0.9 market — every v0.9 instruction handler now requires the v1
discriminator and will reject the legacy b"percmrkt" byte pattern with
AccountNotFound.
percli chain migrate-header-v1Output:
Migrating market header from v0 (136 bytes) to v1 (168 bytes)...
Tx: <TX_SIGNATURE>
Engine bytes shifted forward by 32; pending_authority slot added.
Discriminator version byte stamped to 0x01 (v1).
Behind the scenes the handler:
- Verifies the account is owned by the program.
- Verifies the discriminator at
[0..7]isb"percmrk"and the version byte at[7]is0x74(v0). Already-v1 accounts fail withAlreadyMigrated. - Re-derives the Market PDA from the v0-encoded authority and verifies the
stored bump matches the canonical PDA bump (rejects tampered headers
with
CorruptState). - Verifies the signer matches the v0 header's authority field.
- Shifts the engine bytes from
[144..)to[176..)viacopy_within(in-place, no realloc). - Writes a fresh v1 header with
pending_authority = Pubkey::default(), preserving authority/mint/oracle/matcher/bump/vault_bump. - Stamps
data[7] = 0x01. - Emits
HeaderMigrated { authority, market, mint, oracle, matcher, account_size }.
migrate_header_v1 is idempotent-by-rejection: a second call returns
AlreadyMigrated rather than corrupting state.
for market in $(percli chain query markets); do
solana account "$market" --output json > "snapshots/$market.after.json"
# confirm the engine bytes are unchanged after the 32-byte shift
diff <(jq -r '.account.data[0]' snapshots/$market.before.json | base64 -d | tail -c +145 | xxd) \
<(jq -r '.account.data[0]' snapshots/$market.after.json | base64 -d | tail -c +177 | xxd)
done(Adjust offsets if your engine size differs.) percli chain query market is
also a quick sanity check.
transfer_authority and accept_authority implement the standard
propose-then-accept rotation pattern. This is critical: a single-step transfer
can permanently brick a market by handing the authority to a typo'd or
unreachable pubkey.
The current authority sets header.pending_authority to the new pubkey.
header.authority is not changed.
percli chain transfer-authority --new-authority <NEW_PUBKEY>The program emits AuthorityTransferInitiated { market, old_authority, pending_authority }.
Self-transfer (new_authority == header.authority) is rejected with
Unauthorized to keep events clean. If a transfer is already in flight to
some other key, this overwrites the previous pending key — which is the
intended way to change your mind before the new key has accepted.
percli chain transfer-authority --new-authority 11111111111111111111111111111111Passing the default pubkey (all zeros) clears pending_authority and emits
AuthorityTransferCancelled { market, authority, previous_pending }. This
works because accept_authority rejects the default pubkey as a signer.
The new authority signs the accept call. The CLI uses whatever keypair is
in PERCLI_KEYPAIR (or ~/.config/solana/id.json), so make sure you've
switched contexts to the new key first.
PERCLI_KEYPAIR=/path/to/new/authority.json percli chain accept-authorityThe program verifies:
- The discriminator is v1 (
is_v1_market). - The signer is not
Pubkey::default()(defense in depth — the runtime already rejects this). header.pending_authority != Pubkey::default()(elseNoPendingAuthority).header.pending_authority == signer(elseUnauthorized).
On success it rotates header.authority, clears header.pending_authority,
and emits AuthorityAccepted { market, old_authority, new_authority }.
percli chain query market --address <MARKET_PDA>
# authority should now be NEW_PUBKEY
# pending_authority should be 11111111111111111111111111111111The keeper bot polls Pyth for the latest oracle price and submits crank
calls at a configurable interval. It also auto-liquidates undercollateralized
accounts and emits structured JSON logs suitable for shipping into a log
aggregator.
percli keeper \
--rpc https://api.devnet.solana.com \
--pyth-feed J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix \
--interval 10 \
--json-logsRecommended deployment shapes:
- Devnet: a single keeper on a small VM, intervals of 10–30 seconds.
- Mainnet: redundant keepers in different regions with intervals of 1–5 seconds, fronted by a leader-election layer (e.g. systemd timer with jitter, or a small consensus loop). The on-chain program is idempotent against duplicate cranks, so the failure mode of two keepers landing in the same slot is just wasted compute.
Run the keeper as the matcher keypair, not the authority — this keeps your authority key offline.
Treat this as the gate between "it ran on devnet" and "it touches user funds".
- Audit. Don't ship without one. Until then, deployments are sandboxes.
- Fresh program ID. Generated specifically for the deployment, not reused from devnet.
- Upgrade authority on a hardware wallet or multisig. Squads, Realms, or a Ledger-backed key. Never the same key as the deploy fee payer.
- Authority key offline. The market authority should live on a hardware wallet or multisig and only sign parameter updates and key rotations. Day-to-day operations run as the matcher.
- Matcher key on the matching engine host. Rotated quarterly via
update_matcher. - Oracle feed validated. Confirm you're using a Pyth
Priceaccount (not aPriceFeedreference), the publisher set is the production set, andMAX_PRICE_AGE_SECS(60s in code) matches your Pyth SLA. - Insurance fund seeded. The first deposit into the insurance vault
should be made via
top_up_insurancefrom a treasury account. Theinsurance_floorparameter prevents the fund from being drained below this level bywithdraw_insurance. - Risk parameters reviewed.
maintenance_margin_bps,initial_margin_bps,liquidation_fee_bps,liquidation_fee_cap,max_crank_staleness_slots— every value should have an owner who can explain why that number. - Keeper redundancy in place. At minimum two keepers in different regions, both monitored.
- Crank cadence verified. A long crank gap can wedge mark prices.
accrue_marketis permissionless — ensure your monitoring will trip an alarm beforemax_crank_staleness_slotslapses. - Authority transfer drill. Run a full
transfer-authority → accept-authoritycycle on devnet before mainnet, with the same keys you'll use in production. - Backup snapshots. Daily
solana account <MARKET_PDA>snapshots and engine event tail to S3 / object store. - Incident runbook. Documented procedures for: keeper down, oracle down, mass-liquidation event, authority key compromise.
The program ID in Anchor.toml / lib.rs doesn't match the deployed program.
Re-check solana program show <PROGRAM_ID> and the declare_id! macro,
rebuild, and redeploy.
The chain client allocated an account smaller than the program expects. You're
probably running an out-of-date percli chain against a v1.0 program. Upgrade
to percli >= 1.0.0.
The market is already at the v1 layout. Nothing to do.
The market discriminator is b"percmrk" but the version byte at offset [7]
is neither 0x74 (v0) nor 0x01 (v1) — the account data is corrupt or
forged. Investigate before attempting any further mutation.
The v0 header's bump byte doesn't match the canonical PDA bump for the
encoded authority. The account is either corrupted on disk or was created
under a different program ID. Do not force-migrate; investigate.
There's no in-flight transfer. Make sure the current authority ran
transfer-authority --new-authority <YOUR_KEY> first.
The signer doesn't match header.pending_authority. Check the keypair you're
signing with (PERCLI_KEYPAIR env var) and confirm the pending key with
percli chain query market.
The Pyth price account hasn't published a new value within
max_crank_staleness_slots. Either the oracle is down or you're cranking
against the wrong feed. Check solana account <ORACLE_PUBKEY> and confirm
the timestamp.
The transaction simulation is hitting the default 200k CU limit. Add a
ComputeBudgetInstruction::set_compute_unit_limit(400_000) prefix in
crates/percli-chain/src/rpc.rs or pass --compute-unit-limit to
solana program deploy for the deploy itself.
For the full instruction-by-instruction ABI, including discriminators,
account orders, and emitted events, see ABI.md.