RPL-3 lets a chain host a perpetual-futures exchange without running it. It is a genesis-gated
feature (the perps section); on a chain without it every perp action is refused and nothing else
changes. The design is docs/superpowers/plans/2026-10-03-rpl3-perps.md; this page is the
integrator's guide. The engine, its guest, the prover and the trading site are durian.market's
(../durian.market); the chain's half is here.
The chain never matches an order. It records, and a proof settles:
- Orders enter through consensus, unexecuted. A trader signs an order, a cancel or a withdrawal request; a validator signs oracle prices; a deposit rides a bundle. The ledger checks what it can check by lookup (the key, the nonce window, the shape of the order) and appends the input to the block's word string. Whether the order is any good is not its business.
- Each block closes with a digest. The block's inputs, then one
Closeinput carrying the height, the block time and each market's oracle median, are hashed toD_hand recorded. - The engine is proved. The engine is a zkVM guest (
perp-engine.image.bin, fixed byengine_hcin the genesis). OnePerpStateProofruns it over a window of blocks and proves that the exchange's state moved from the root the chain holds to a new one, over exactly the digests the chain recorded, paying exactly the withdrawals it lists. - Anyone may prove. A state proof carries no signature and no bundle; the STARK is the authority. There is no prover register: whoever has the engine, the inputs and the time can advance the chain, and a second proof of the same window is simply refused (the window must start at the proved height).
- Settlement is final at proof. Until a proof lands, an account's collateral and positions are the engine's state as of the proved height and nothing the chain holds as a note. When it lands, the root moves and each payout becomes a note.
Nothing about balances, positions or the book is in the ledger. The ledger holds only what it needs to admit the next transaction: the trading keys and their nonce windows, the oracle, the proved root and height, the digests not yet proved, and the withdrawals waiting to be paid.
rand-node genesis --perps perps.json takes a PerpsConfig:
{ "collateral_asset": 0,
"max_tier": 18,
"max_window_blocks": 64,
"max_block_inputs": 8,
"min_deposit": 1000000,
"engine_hc": "<64 hex>",
"genesis_root": "<64 hex>",
"markets": [
{ "id": 0, "symbol": "BTC-PERP", "lot": 1000000, "tick": 1000000,
"max_leverage": 20, "maintenance_bps": 500, "taker_fee_bps": 5, "maker_fee_bps": 2 } ] }| field | meaning |
|---|---|
collateral_asset |
the asset deposits are made in and withdrawals paid in: 0 is RAND, otherwise a token the same genesis lists |
max_tier |
the highest zkVM tier (10, 12, 14, 16, 18 or 20) a state proof may be made at; it bounds what a proof costs to verify |
max_window_blocks |
the most blocks one state proof may cover, 1 to 64 |
max_block_inputs |
required. The most perp inputs (deposits, orders, cancels, withdrawals; not the Close) one block records, 1 to MAX_BLOCK_INPUTS = 1024. With max_window_blocks it bounds a window's engine work (below) |
min_deposit |
optional, default 0 (no floor): the smallest deposit, in collateral units; below 2^63 |
engine_hc |
the engine guest's program commitment; a state proof of any other program is refused. Hex byte order: each u32 word of hc as 8 little-endian hex characters (word8_to_hex); rand perp image-hc --image <FILE> prints an image's hc in that format, and rand perp prove refuses an image that does not match before proving |
genesis_root |
the engine's state root at height 0: perp_digest(PERP_STATE, State::genesis(markets).encode()). perp-prover genesis-state writes the words and rand perp genesis-root --state hashes them |
markets |
the MarketSpec list below |
MarketSpec: id, symbol, lot (the size increment in base atomic units), tick (the price
increment, in quote atomic units per BASE_UNIT = 10^9 of base), max_leverage,
maintenance_bps, taker_fee_bps, maker_fee_bps. The engine keeps its own copy of these in its
state, which genesis_root already commits to; the ledger keeps them to refuse an order for a
market that does not exist or is off the lot and tick grid.
Requirements, each refused by name at genesis (Genesis::validate, PerpsConfig::check):
max_tieris one of the six tiers;max_window_blocksis in1..=64;max_block_inputsin1..=1024;min_depositbelow 2^63;- 1 to
MAX_MARKETS= 2 markets — the ledger pins 2, the pinned engine's capacity (a new engine image may raise it) — ids0, 1, …in order,lotandtickpositive,max_leveragein1..=100,maintenance_bpsat least 1 (the engine requires a maintenance margin), each*_bpsat most 10 000; - the chain has
confidential,tokens,gas,hardening_v6andhc_auth(the CLI says them as--tokens,--gas-price,--hardening-v6,--auth-guest); - a non-zero
collateral_assetis a token the file lists.
The section is bound into the genesis hash last, and into the state root under rand-state-9
(below). The genesis fixes it for the chain's life: there is no action that adds a market or
changes a bound.
Sizing a window. The engine's work per window grows with the inputs it reads, so
max_block_inputs × max_window_blocks must fit the engine's cycle budget at max_tier. At tier
16 the pinned engine has about 65 000 cycles, some 20 000 of them fixed per window; an operator
sizes the two bounds to fit what is left. The devnet runs max_block_inputs = 8 with
max_window_blocks = 4.
The bound is the worst single block, and that is a mass liquidation: one counterparty against
the 14 other traders, whose close liquidates all 14. Measured on the pinned engine (durian.market
scripts/check-perp-guest.sh, case block-14liq; README "Perps" has the table): 153 845 cycles
with no inputs before the close, 212 018 with 24, 292 773 with 120. So max_block_inputs = 120
needs max_tier 20; tier 18 with 15 % headroom holds about 24 inputs. The production sizing is
max_tier 18 with max_block_inputs ≤ 24; the check's gate protects it (case
block-24-14liq, 212 018 cycles, must fit tier 18 with 15 % headroom), while block-14liq, the
120-input ceiling, is reported but not gated. A per-close liquidation cap that would let more
inputs fit is a later engine milestone. Tier 16 holds no close
that liquidates more than a few accounts at any input count (7 liquidations alone are 76 493
cycles), so the devnet's tier-16 genesis proves its fill blocks but not a mass liquidation.
Every block records at most max_block_inputs perp inputs (BlockFull) and at most
MAX_ACCOUNT_INPUTS_PER_BLOCK = 8 from one account (AccountBlockFull) — a deposit counts
towards the account it credits. Both are refused by validate and by apply before any write,
against the inputs the block has recorded so far; neither is permanent (the next block has room).
A pool does not refuse on them: its ledger is the tip's, whose block inputs every close empties,
so still_applies never reads them and an input a full block left out stays pooled for the next.
A block carrying more is refused whole; the proposer's trial apply packs exactly the first
max_block_inputs that apply.
With at most 15 accounts, the per-account cap also bounds a block's words whatever
max_block_inputs says: 15 × 8 inputs × 21 words (a withdrawal's, the longest) + one Close
over 2 markets (12) = 2 532 words, inside the guest's 8 192-word block buffer.
Tags 34 to 39, appended after Invoke (33). The four signed ones are bundle-less and carry a
signature over Transaction::perp_sign_message(binding_domain, chain_id, action).
Signed messages. Over the chain's binding domain (BIND-1), as every other signed action message is:
binding_domain0 (absent):blake3("rand-perp-sign-1", bincode(chain_id, tag, unsigned));binding_domain1:blake3("rand-perp-sign-2", bincode(genesis_hash, (chain_id, tag, unsigned)))— the genesis hash leads, as inBindingDomain::token_mint_message.
unsigned is the action with an empty signature. The tag keeps one variant's signature from
verifying as another's, the chain id and (under domain 1) the genesis hash keep it off other
chains: a signature made for domain 0 is BadSignature on a domain-1 chain. The rand perp
commands learn the domain from the node (RpcClient::binding_domain, as a mint does).
An account id is word8_from_bytes(Address::from_public_key(trading_key)), the eight words of
the trading key's address; rand perp keygen prints it. A trading key is a Dilithium2 key, and it
is not the owner's spend key: it signs orders and nothing else.
| tag | action | signed by | fee floor |
|---|---|---|---|
| 34 | PerpDeposit { trading_key } |
none: its bundle authorises it | 0.001 RAND (BUNDLE_BASE), paid from the bundle |
| 35 | PerpOrder { account, body, signature } |
the account's trading key | 0 (bundle-less) |
| 36 | PerpCancel { account, nonce, target, signature } |
the account's trading key | 0 |
| 37 | PerpWithdraw { account, nonce, amount, recipient, r, time, envelope, signature } |
the account's trading key | 0 |
| 38 | PerpOracle { validator, prices, nonce, signature } |
a validator's key | 0 |
| 39 | PerpStateProof { from_height, to_height, new_root, payouts, fees, proof } |
none: the STARK is the authority | 0 |
A PerpDeposit's bundle is an ordinary bundle to the fee split (Ledger::bundle_fee_split): the
floor it pays is the schedule's, plus the genesis fees.prove_base where a chain sets it, and the
fees rules apply to it as to any bundle — under burn_base its base burns, under burn_floor
its whole floor, counted in base_fees_burned; the collateral burn beside it is the deposit, not a
fee. rand perp deposit's default fee is that floor (wallet::schedule_floor).
Every rule below is checked by validate, in this order; the per-block caps come after the
action's own rules, and the signature last: it is the one expensive check. apply is only ever
reached through apply_tx, which runs validate on the same state first, and then re-checks,
before its first write, exactly the rules a write depends on: the section gate and the per-block
caps; for a deposit the collateral shape, the amount and floor and (for a new account) the
account cap and the reserved id; for an order and a cancel the account and the nonce; for a
withdrawal the account, the nonce, the request id, the pending cap, the opening and the
one-per-account rule; for a state proof every rule of check_state_proof (window, digests,
payouts) and the counters' overflow. It does not re-check an order's shape, a withdrawal's
envelope, recipient or time, an oracle's validator and prices, or any signature or STARK — those
validate decided on the same state a moment before. So a refusal leaves the ledger as it was.
The bundle burns the collateral and nothing else: RAND through burn_r with burn_a and
burn_asset zero, or the collateral token through burn_a with burn_r zero. Anything else is
CollateralAssetMismatch; a zero amount is ZeroAmount. The account is the one the trading key
owns: a deposit by a new key opens it, a deposit by a key that already owns it tops it up.
- At least the genesis
min_deposit(DepositTooSmall), when one is set. - At most 15 trader accounts (
TooManyAccounts): the engine has 16 slots and keeps one for its insurance fund. The id[0; 8]is that fund's and no key may own it (ReservedAccount). - An account id whose recorded key differs from the deposit's is
KeyMismatch. - A RAND deposit's
burn_ris counted insupply.burnedby the common path; the audit counts it back as the exchange's (below).
- The account must exist (
UnknownAccount): deposit first. - Nonce window. Per account:
nonce_highand a 64-bitusedbitmap overnonce_high-63 ..= nonce_high. A noncenis accepted iffn > nonce_high(the bitmap shifts left byn - nonce_high, to 0 past 63,nonce_high = n, bit 0 set), or it is in the window with its bit clear (the bit is set). Anything else isNonceUsed. So orders need not arrive in order, a trader may have many in flight, and one more than 63 below the highest is refused because the window no longer remembers it. A cancel takes its own nonce from the same window. - An order's body is checked for shape: a known market (
UnknownMarket);side0 buy or 1 sell;kind0 limit or 1 market;tif0 GTC, 1 IOC or 2 post-only;sizea positive multiple of the market'slot; a limit order'spricea positive multiple oftick; a market order carries no price (allBadOrder).reduce_onlyis a boolean. - A cancel's
targetis an order's nonce. The chain does not check that it names an order: the engine ignores a cancel of nothing.
A request to move collateral out of the engine to a note the chain will compute.
- The account exists;
amountis positive (ZeroAmount) and below 2^63 (AmountTooLarge): a note at or above 2^63 cannot be spent, so a request for one could only fail at payout while holding a slot. - At most 8 withdrawals pending at once (
TooManyWithdrawals), which isMAX_PERP_PAYOUTS: a state proof pays at most 8, so no more may wait. Without the cap one holder could queue requests no proof can ever pay. - The envelope and the recipient pass the same checks as an RPL-2 payout's
(
check_note_envelope,check_recipient). timeis a block height within the ledger's proof window (time <= heightandheight - time <= proof_window_blocks), the same check a bundle'stimemeets, not a wall-clock value.- The nonce is taken from the window, and the request is held under its request id, the transaction hash as eight words.
- One pending withdrawal per account (
WithdrawalPending, not permanent): the next request waits until a proof settles this one. With the cap of 8 it keeps one account from holding every slot. - No two pending withdrawals share
(recipient.pk, r)(DuplicateOpening). The payout note isH(pk, PERP_FROM, amount, asset, time, r)andris public on the wire: a copy of a pending request's(pk, r)could commit to the same note, and the request whose id sorted first would mint it with its own envelope, leaving the owner's never recorded.
A validator's prices, one per market named.
- The signer is a validator in the register and not jailed (
NotValidator). - At least one price, every price positive (
BadPrice: the engine reads a median of 0 as "no price"); markets known (UnknownMarket) and strictly ascending (UnorderedPrices). - The nonce is above the validator's last (
OracleNonce). The CLI uses the wall clock in ms. - Apply records
(price, height)for each market under the validator's address, and the nonce. An oracle transaction is not an input of its own: its prices reach the engine through the block'sClose.
At the end of every block on a perps chain (Ledger::close_block, before the anchor):
- Submissions of validators that have left the register are dropped, so a departed key does not sit in the root for ever. A jailed validator's submissions stay (it may serve again) and weigh 0.
- Each market's median is the stake-weighted median of its fresh submissions: those given at
height - 30or later (ORACLE_STALE_BLOCKS), each weighted by its validator's stake now, sorted by price; the median is the first price at which the running stake reaches half the total, rounded up. Only with a quorum: the fresh stake (non-stale, non-jailed, in the register) must be at least half of the active stake (every registered, non-jailed validator's). Without one — nothing fresh, or too little of the set behind it — the close carries 0, the engine's "no price", and the old median is not kept. A chain of one validator is its own quorum. - The block's inputs, in block order, then exactly one
Close, make the block's words;D_h = perp_digest(PERP_BLOCK, words)is recorded for every closed height above the proved height. The words are left for the node to store; the inputs are emptied.
Every step reads only consensus state, the block's height and its timestamp.
from_height, to_height, the engine's new_root, the withdrawals it pays and the fees it
collected, and the proof. The cheap rules come first and decide whether the STARK is looked at at
all:
-
Window.
from_heightequalsproved_height;to_height > from_height; andto_height - from_height <= max_window_blocks(WindowMismatch). Every height infrom+1 ..= tohas a recorded digest (MissingDigest), which is the whole bound onto: it must be a closed block. -
Payouts. At most 8 (
TooManyPayouts). Each names a pending request (UnknownRequest), each request at most once (DuplicatePayout), each request recorded inside the window,from < height <= to(RequestOutsideWindow), and each amount is the full request or nothing: above it isPayoutTooLarge, a positive amount below itPartialPayout. They are listed in the order the engine emits them, which is the order the withdrawals were input, not sorted; the payouts digest binds that order. -
The segment. The ledger builds the public segment itself from its own proved root and recorded digests and the transaction's claims, so a prover cannot choose which block inputs the proof ran over:
[PERP_VERSION = 1, from_lo, from_hi, to_lo, to_hi, R_from(8), R_to(8), payouts_digest(8), fees_lo, fees_hi, n_blocks, D_{from+1}(8), …, D_to(8)] payouts_digest = perp_digest(PERP_PAYOUTS, [n, (request(8), amount_lo, amount_hi)*])The ledger puts the proved root at
R_from, the transaction'snew_rootatR_to. -
The STARK. Verified against
engine_hcand the segment, like a call's proof and after every other check. A tier abovemax_tierisTierTooHigh; any other failure isProofRefused. The engine's own two outputs must read: the version1, and a block count equal toto_height - from_height. -
Payouts become notes the chain computes. For each payout of a positive amount:
note_commitment(recipient.pk, PERP_FROM, amount, collateral_asset, time, r), with the request's recipient,randtime, andPERP_FROM = "rpl3-pay"as two little-endian words then zeros. The request's envelope is appended beside the commitment, so the owner's wallet finds the note by trial decryption as it finds any other. A payout of zero mints nothing. A payout whose note the tree already holds mints nothing and adds nothing to the audit. A payout is the full request or nothing — a consensus rule (PartialPayout), not only the engine's behaviour — so the amount sealed in the envelope is the amount paid. -
Settlement. The proved root and height advance, the digests the proof covered are dropped, and every pending request recorded at or below
tois settled, paid or not: the engine saw it in this window, and a request it skipped can never be paid by a later one.
The proof is not a bundle's: proof is kept inside the transaction's binding, as a call's is, and
proof_bytes is capped by max_proof_bytes.
A RAND deposit's burn_r is inside burned; a payout mints a note. The ledger keeps two counters
outside the state root, rand_in (Σ RAND deposited) and rand_out (Σ RAND paid by proofs), derived from the
chain like RPL-2's, and Ledger::audit folds them in as the identity's fifth
term, beside RPL-2's vaults. rand_getSupply serves perps_rand_out and perps_rand_held
(rand_in - rand_out). On a chain whose collateral is a token both are "0": a token's supply
is not audited by this term, because a token's total supply does not move on the way in or out.
The guest is given the proof's public segment above, and a private witness:
[n_state, state_words…, n_blocks, (n_words, block_words…)*]
It hashes the state it was handed and the state it ends with (R_from, R_to), each block's
words (D_h) and the payouts the engine made, runs the engine over the blocks, and requires each
to equal what the public segment says, with the fees. It outputs
[PERP_VERSION, n_blocks, n_orders, n_fills, n_liquidations, n_payouts, 0, 0]: the ledger reads
the first two. A segment whose version word is 0xFFFF_FFFF is the guest's echo mode and is never
admissible, because the version must equal 1.
| name | what | where |
|---|---|---|
PERP_BLOCK = 21 |
a block's input digest D_h |
Poseidon2 domain, ledger and guest |
PERP_STATE = 22 |
the engine's state root R |
Poseidon2 domain |
PERP_PAYOUTS = 23 |
a state proof's payouts digest | Poseidon2 domain |
rand-perp-sign-1 |
what a signed perp action signs, binding_domain 0 |
blake3 |
rand-perp-sign-2 |
what a signed perp action signs, binding_domain 1 (genesis hash first) |
blake3 |
rand-perps-1 |
the perps root, over proved root and height and five merkle roots | blake3 |
rand-perp-account-1, -oracle-1, -oracle-nonce-1, -digest-1, -withdrawal-1 |
the leaves of those five roots | blake3 |
rand-state-9 |
the chain's state root on a chain with the section | blake3 |
rand-state-9 wraps exactly the bytes the chain without the section commits, then appends the
perps root. It sits beside RPL-2's: program state first, then perps. A node whose database and
genesis disagree on the section refuses to start.
The perps root commits every consensus field: the accounts' keys and nonce windows, the oracle's submissions and each validator's last nonce (they decide whether the next oracle transaction is valid, so two nodes that differ in them must differ in root), the proved root and height, the recorded digests, and the pending withdrawals. The two audit counters and the two per-block transient fields are outside it.
acc = H(domain, [len(words) as u32])
for chunk in words.chunks(4000):
acc = H(domain, acc(8) ‖ chunk)
return acc
The length word keeps [a] and [a, 0] apart; the chunk size keeps 1 + 8 + 4000 under the
POSEIDON2 syscall's 4 096-word cap, so the guest computes the same digest in the same calls.
PerpInput::words: every u64 is two words, low first; every Word8 is its eight words.
| input | tag | layout | words |
|---|---|---|---|
| Deposit | 1 | [1, account(8), amount_lo, amount_hi] |
11 |
| Order | 2 | [2, account(8), nonce_lo, nonce_hi, market, side, kind, tif, reduce_only, price_lo, price_hi, size_lo, size_hi] |
20 |
| Cancel | 3 | [3, account(8), nonce_lo, nonce_hi, target_lo, target_hi] |
13 |
| Withdraw | 4 | [4, account(8), nonce_lo, nonce_hi, amount_lo, amount_hi, request(8)] |
21 |
| Close | 5 | [5, height_lo, height_hi, time_ms_lo, time_ms_hi, n_markets, (market, median_lo, median_hi)*] |
6 + 3n |
side: 0 buy, 1 sell. kind: 0 limit, 1 market. tif: 0 GTC, 1 IOC, 2 post-only.
reduce_only: 0 or 1. A block's words are its inputs concatenated in block order, then exactly one
Close. tests/vectors/perps-v1.json pins the encodings, the digest and the segment so the
engine's crate, which cannot depend on this one, can check that it agrees. It also pins the
multi-chunk digest with the real Poseidon2: digest_4000, digest_4001 and digest_8001 are
perp_digest(PERP_BLOCK, w) over w[i] = (i as u32).wrapping_mul(2654435761) of 4 000, 4 001
and 8 001 words (one chunk, the chunk boundary, the three-chunk boundary), written by
randprotocol-zkvm's tests/perps_vectors.rs.
The engine's state encoding (State::encode, hashed under PERP_STATE to give R):
[1 (version), next_order_seq(2), insurance(2), fees(2),
n_markets, Market*, n_accounts, Account*, n_orders, Order*]
Market = [id, lot(2), tick(2), max_leverage, maintenance_bps, taker_fee_bps, maker_fee_bps,
funding_index(2, i64), last_oracle(2), last_funding_ms(2)] 15 words
Account = [id(8), collateral(2), nonce_high(2), used(2), Position × MAX_MARKETS]
Position = [size(2, i64), entry_notional(2), funding_index(2, i64)] 6 words
Order = [seq(2), account_index, market, side, nonce(2), price(2), remaining(2), reduce_only] 12 words
The engine's constants (perp-core): MAX_MARKETS = 2 (which the ledger pins, perps::MAX_MARKETS),
MAX_ACCOUNTS = 16, MAX_ORDERS = 32
(resting orders, all markets), MAX_WITHDRAWS_PER_WINDOW = 8, BASE_UNIT = 10^9,
FUNDING_PERIOD_MS = 8 h, FUNDING_CLAMP_BPS = 50. The genesis state is
State::genesis(markets): counters 0, no accounts, no orders.
A Close carries the block's timestamp and the engine's funding runs on it, so on a perps chain
block time is consensus input, as on a bridged chain (Ledger::bounds_block_time): a block may not
rewind past its parent, may not step more than 60 000 ms past it, and the proposer's clamp and the
drift vote rule apply. NTP is a requirement for a perps chain's validators, as it is for a
bridged one.
- Persistence.
META_PERPSholds thePerpsstate as of the head, beside the other section states; it is written with the block's commit. - The
perp_inputscolumn family. Height (big-endian u64) to the block's input words. The words are written in the same batch as the block, so a crash cannot leave a perps block stored without the words a prover needs. A block whose words do not match the chain (absent on a perps chain, present on one without) isCorrupt.rand_getPerpInputsserves them.rand-node verifycompares the stored words with the replayed chain's. - Pruning at proof. The family holds only the heights above the proved height: the batch that
commits a state proof deletes every row at or below
proved_height. A height a proof has covered isnullfromrand_getPerpInputs. - Rollback. The family is created on every database at first open, perps chain or not. A build
from before RPL-3 (v0.7.1 and earlier) lists seventeen families and RocksDB refuses to open a
database with an eighteenth, so before rolling back run
rand-node db drop-perp-inputs --datadir <dir>with the node stopped. It is refused, touching nothing, on a database that holds perps state or any input row. - Genesis.
rand-node genesis --perps perps.json(docs/cli.md); the node refuses to start when its database and its genesis disagree on the section. - State-proof admission. A state proof is bundle-less and fee-less, and its STARK is the most
expensive check a node runs, so two node policies (never validity rules) protect it. A
ProofRefusedverdict is cached keyed by the transaction hash and the segment the tip builds for it (with the proved root it was made against), bounded, and emptied when the proved root moves: the same junk bytes re-sent are refused without verifying again. And the pool keeps one reserved slot for a state proof: it is admitted past the count and byte caps, never evicted to make room, and offered to a block after governance and ahead of fee order, so a pool full of free orders cannot keep out the proof that settles them. A second proof while the slot is held is pooled like anything else. - Transport.
rand_getPerps,rand_getPerpAccount,rand_getPerpAccountsandrand_getPerpInputsare on the public listener;rand_getLimitsgainsperps;rand_getSupplygainsperps_rand_outandperps_rand_held(docs/rpc.md). Every perp action renders inrand_getTransactionunder the kindsperp_deposit,perp_order,perp_cancel,perp_withdraw,perp_oracleandperp_state_proof.
rand perp keygen | deposit | order | cancel | withdraw | oracle | prove | genesis-root | image-hc | state | account | inputs, in docs/cli.md. rand perp prove is the command perp-prover runs: it
submits and waits for inclusion, and exits non-zero on any refusal, including a R_from that is
not the chain's proved_root, which is how the prover learns to roll back.
- No prover bond (I1). A state proof is not bound to a bonded prover register; the refusal cache and the reserved pool slot above are the minimum. A prover register with a bond, so junk proofs cost their sender, is a follow-up.
- No withdrawal fee (I2). The spec's bundled withdrawal is deferred: a request is bundle-less and fee-less, bounded only by one pending per account and 8 in all.
- Provers versus 1 s blocks (I7).
MAX_WINDOW_BLOCKSis 64, and a tier-16 proof takes 76 to 400 s, so on 1 s blocks a single prover falls behind the digests it must cover. A demo chain runs 2 s blocks; back-pressure on inputs while proofs lag, and eliding empty blocks from a window, are follow-ups. - The gate's error (M3).
PerpError::Disabledis the section gate's refusal. The five bundle-less actions are gated at the action step; a state proof is gated before the size caps (so a chain without the section answersDisabled, notProofTooLarge). - Token collateral (M6). A token's supply is not audited by the perps term: the audit cannot detect a token deposit and payout imbalance.
- Engine capacities. The ledger pins
MAX_MARKETS= 2, the pinned engine's capacity, and nothing else of the engine's:MAX_ACCOUNTS(15) andMAX_PERP_PAYOUTS(8) are ledger rules chosen to fit it, not engine constants read from it.
- No prover register and no payment. Proving is open and unpaid; the
feesthe engine collects stay in its state. A prover that stops is replaced by anyone who starts. - No forced exit. A withdrawal is a request the engine must be proved over. If no one proves, collateral waits; there is no escape hatch that returns it without a proof.
- No backstop vault. The chain holds no reserve for losses the engine's insurance fund cannot cover; the fund is a balance inside the engine's state, not a note or a vault.
- Markets are genesis-only. No action lists, changes or delists one.
- The mark price is the oracle median. The chain supplies one number per market per block and nothing else: no separate index price, no premium.
- No sealed orders. Orders are public in the pool and in the block, before the engine sees them.
- The supply of a token collateral is unaudited by the perps term (above).
- On no chain yet. The section is inert on every chain whose genesis omits it.
The engine (perp-core), the guest (perp-guest), the prover (perp-prover) and the site are in
../durian.market; its README is the runbook. The wire shapes a prover reads are in
docs/rpc.md; the job file rand perp prove takes is in docs/cli.md.
../durian.market/scripts/devnet-perps.sh on 2026-10-04: fullnode feat/rpl3 8840fe51,
durian.market feat/perps ac0bd09, one validator, 2 s blocks, genesis perps with max_tier
16, max_block_inputs 8, --binding-domain 1, engine_hc 15e24915…580bb4. Two traders deposit
500 RAND, cross 1 unit at 2000 RAND (fill at height 32), A withdraws 1 RAND (request at 36).
Re-run with test FRI after the liquidation-scoping fix re-pinned the engine: durian.market
27493ef, engine_hc f286a611…1d1573, fullnode binaries of 8840fe51 (c69a0b0f is docs only), the
same genesis shape (row "test, re-pin" below; fill at height 49, request at 53). The production
row is still the 15e24915…580bb4 engine's.
| fri | window | withdraw at | proved | windows (all tier 16) | cycles per window | prove s (total, max) | proof bytes (max, total) | peak RSS | deposit s A/B | engine (proved) | fills (view) | A's 1 RAND paid |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| test | 4 | 36 | 36 | 9 | 18 521–24 828 | 663.5, 78.9 | 357 286, 3 186 104 | — | 28 / 26 | 2 orders, 1 fill, 1 payout | 1 | yes, note at height 405 |
| test, re-pin | 4 | 53 | 56 | 14 | — | 1 042.4, 105.1 | 356 487, 4 937 279 | — | 52 / 36 | 2 orders, 1 fill, 1 payout | 1 | yes, A 9 499.999 → 9 500.999 RAND |
| production | 8 | 36 | 40 | 5 | 28 973–36 631 | 356.2, 71.8 | 1 556 425, 7 762 669 | 22.2 GB | 27 / 26 | 2 orders, 1 fill, 1 payout | 1 | yes, note at height 237 |