Use this page to understand what happens to a transaction after rand_sendTransaction accepts it and before a block carries it: why it was refused if it was, why it may vanish from the pool without ever failing, what order a block is filled in, and what a flood of bad proofs can and cannot do to a validator. The mempool is crates/randprotocol-node/src/mempool.rs; the guards in front of it are admission.rs.
No senders, so no nonces
On an account chain a mempool orders each sender’s transactions by nonce. A shielded chain has no senders: a bundle carries no address and no signature, and two bundles are related only when they touch the same note. So there is no per-sender ordering left to do. What replaces the nonce bookkeeping is conflict tracking: the pool indexes every pending transaction’s nullifiers and commitments, and refuses a newcomer that claims any of them. The same goes for the few counters the ledger accepts exactly one transaction per value of: a validator’s register nonce (an Unbond or a Withdraw), a token’s mint_nonce (shared by TokenMint and SetAuthority), the registry index a RegisterToken is held to, and the bridge’s pause_nonce and list_nonce. Each is a slot at most one pooled transaction may hold. At most one of two such transactions could ever be included, and carrying the other would cost the proposer block space and every node a proof verification per gossip round.
The pool holds at most 10,000 transactions (Mempool::new(10_000) in node.rs) and, since v0.6.8, at most eight blocks’ worth of bytes: MAX_POOL_BLOCKS = 8 times the chain’s max_block_bytes, 152 MiB on chain 21 (eight 19 MiB blocks; 160 MiB on chain 20) (audit v6, CH-7). Before that only the count was capped, while one proof-carrying transaction runs from about 1.3 MB to over 12 MB. Each is validated against the ledger at the tip of the consensus tree, not the last committed block: the state a proposer would actually build on.
Admission, cheap before expensive
A transaction arriving over RPC or gossip goes through these steps in order, and the first refusal is the error you see:
- Duplicate. The hash is already pooled:
already in mempool. - Conflict. A nullifier, a commitment or one of the nonce and index slots above is already claimed by a pending transaction:
conflicts with a pending transaction over <hex>. For a token slot the hex is a display key: a tag word (toknfor amint_nonce,tidxfor a registration index), then the index and the nonce. A bridge attestation two relayers raced is told apart by its digest:conflicts with a pending transaction over bridge attestation <hash>. - Full.
mempool full. The capacity check is last of the three. At the byte cap a newcomer may take the place of pooled entries that rank strictly below it — by fee above each one’s current floor per KiB, then by fee — evicting the cheapest first until it fits; if those are not enough, or it only ties, it is the one refused. The governance actions are outside both caps and are never evicted: the four bridge governance actionsPauseMints,UnpauseMints,RegisterBridgedToken,ListBacking, and since v0.6.8RotatePqGuardians,RotatePauseKey, their…V2forms,CancelRotation,AdmitValidatorandSlashEquivocation. Their bytes and entries still count toward the caps.PauseMintsis the brake for a guardian-key compromise: bundle-less, fee-less, and the one transaction that must reach a block while an attacker is minting, so a full pool must not keep it out. The exemption is bounded by the conflict step: thepause_nonceand thelist_nonceeach admit one pooled transaction at a time, so the four bridge actions can add at most two entries past the cap; the others each spend a nonce or a quorum of their own. - The cheap half of
Ledger::validate. Everything that can go stale between admission and inclusion and costs no proof: the anchor is one of the last 1,024 roots on chains 20 and 21 (genesisproof_window_blocks; 256 without it),timeis inside the window, the nullifiers are unspent, the commitments absent, an attestation’s digest unclaimed, a staking action’s register nonce current, a token action’smint_nonceor registration index the one the registry is at (wrong mint nonce,wrong token indexonce the chain has moved past it), a pause’s or a listing’s nonce the bridge’s current one. The commitments a transaction creates are computed here too, including the note the ledger itself derives for aWithdraw, aBridgeAttestor an aggregator payout, which is not on the wire but must not collide either. Size caps are checked before anything is decoded: a proof over the chain’smax_proof_bytes(2 MiB by default, 4 MiB on chain 20), an attestation over 16 KiB, or a transaction larger than a block (max_block_bytes: 4 MiB by default, 20 MiB on chain 20) is refused outright, so an oversized blob buys no work. A bundle proof in the sealed (pruned) marker form is refused at the same step outside sealed-form sync, asPrunedFormOutsideSync. Then, still without verifying anything, the pool reads the proof headers. Three policies come first: a call whose input, keccak or sha256 table is under 2^7 rows is not pooled (CallRevealsPrivateInputs), nor is a deploy no call could hold (ProgramUncallable), nor a transaction whose bundle or call proof has a header field the honest prover would not write (NonCanonicalProof). The last two are also ledger rules on chain 20, underhardening_v6. Since v0.6.8 a header whose declared degree is past the tallest table’s ceiling is refused asNonCanonicalProofbefore the check walks it (audit v6, ZKV-4), and a panic anywhere in this pre-screen is a refusal of that transaction (VerifierPanicked), not a dead node. Then the fee floor: on chain 20 the pool decodes the gas limit a call or invoke proof declares and refuses a fee underBUNDLE_BASE + gas_price × limit + byte_price × ⌈bytes/1024⌉at the tip’s prices, plus an invoke’s cell fee, asFeeTooLow(Fees). That is the block’s own rule, so the pool demands exactly what a block does. - The expensive half, off the consensus loop. The transaction is queued, 64 deep at most, for one of four verification workers. A worker runs the rest of
Ledger::validateon a blocking task: the bundle digest, the gas limit the bundle proof declares (since chain 18 exactly 20,479, elseBundleGasLimit), the bundle proof and, since chain 17, its authorisation proof against the transaction’s binding (on chain 21 againsthc_author, for a multisig-shaped proof,hc_auth_multisig; since v0.8.0 eachOkverdict is kept in a verify cache of 32,768 entries, so the proof is not verified again when the block applies), and for a call its own proof and its fee floor. It validates against a snapshot of the tip that is cloned lazily, at most once per tip change and only while something is waiting. A gossiped transaction that finds the queue full is dropped, neither verified nor forwarded, rather than blocking the loop that votes; the peer’s next gossip round carries it again. - Re-check and pool.
insert_verifiedruns step 4 again against the tip the transaction is actually pooled on, because the tip may have moved while the proof was verified, then admits it and, for gossiped traffic, forwards it.
Steps 1 to 4 are Mempool::precheck; step 5 is the only one that verifies a STARK. Because that cost is paid on a worker, a flood of bad proofs occupies four threads and never the consensus loop. Transactions and actions lists the ledger’s own admission order, which steps 4 and 5 between them run.
Two guards and a cache for gossip
In front of admission, two things apply only to transactions arriving from peers; RPC submissions skip them, because that port is the operator’s own and is already bounded by the request-body limit. The one RPC path that is metered is the faucet, below.
- A per-peer byte meter. Since v0.6.8 every gossip frame is charged to its forwarder’s byte budget before it is even decoded, a burst of eight and a refill of two frames of the maximum transmit size a second; past it the frame is dropped undecoded (audit v6, GOSSIP-1). The transaction id, which hashes the whole proof, is computed only within the forwarder’s allowance.
- A per-peer transaction meter. A token bucket per forwarding peer, burst 16, refilling at 4 per second (
PEER_TX_BURST,PEER_TX_PER_SEC). It meters the peer that forwarded the transaction, not its author, whom a shielded chain cannot name. - A verdict cache. A transaction this node has already refused for a reason that is a function of its bytes alone, such as a bad proof, a bad signature or an oversized part, is refused again from an 8,192-entry FIFO cache (
REFUSED_CACHE_ENTRIES) instead of being verified a second time. A verdict that says something about this node’s state, an unknown anchor or a spent nullifier, is never cached: a node one block behind would otherwise poison itself against transactions that are about to be valid. The list is an allowlist, so a new error is state until someone decides otherwise. These entries show where the line runs:BadPauseSignature(the pause signature does not verify) is not permanent since v0.6.8 (audit v6, BRG-18). It checks the signature under the chain’s current pause key, which bridge rules v2 can rotate, so it is state: a node one block behind a rotation would otherwise refuse the new holder’sPauseMintsuntil restart. A PQ co-signature index out of range left the cache for the same reason. Through v0.6.7, on chains 18 and 19, both were cached as permanent. A repeated bad pause now costs a Dilithium2 verification per delivery, bounded by the per-peer meter.PrunedFormOutsideSyncis deliberately not permanent. The marker form of a sealed bundle hashes to the raw transaction’s own id, and the cache keys on that id, so caching the refusal would let any gossip peer censor the honest transaction network-wide.- A proof’s declared gas limit is its own bytes against a genesis constant, so
BundleGasLimitandAuthGasLimitare permanent.FeeTooLowis not: on a gas-metered chain the floor moves with the prices. The three header policies of step 4 are never cached either: each is this build’s knowledge, which a later build may move. - On a chain with a faucet allowlist (chains 20 and 21),
FaucetRecipientNotAllowedis permanent: it compares the mint’s own recipient with a genesis constant.FaucetBudgetExhaustedis not: it is state, and the same mint is admitted again in the next epoch. - A token action’s
BadSignatureis not permanent either, unlike a staking action’s: a token’s mint authority is state, aSetAuthoritycan hand it to another key, and the same bytes can be refused before that rotation commits and accepted after it. The byte-level token refusals (a bad name or symbol, too many decimals, a zero amount, token index 0) are cached; so are a burn field the action may not set, a mint whose commitment does not open to its amount, and a bridge deposit whose blinding is not the derived one.
The faucet limit
rand_mint is fee-less and costs one pooled transaction per call, and on chain 21, as on chain 20, the faucet is on with a bridge holding value behind it. The genesis limits what it can do: a mint pays only the 16 allowlisted wallets and at most 10,000 RAND per 1,000-block epoch (Staking), and only one of the 18 keys on the genesis staking.faucet_minters list may sign one (MinterNotAllowed, never cached: a ledger rule since chain 16, node policy on chain 15, where the list was the genesis validators). On top of that each node meters its own faucet: eight mints back to back, refilling at one per second (FAUCET_MINT_BURST, FAUCET_MINT_PER_SEC). Past that the call answers faucet is rate limited on this node (8 mints back to back, refilling at 1/s); try again shortly. The limit is per node process, not per caller, because the RPC port has no peer identity to meter and the resource protected is this node’s pool. Together with the capacity exemption above, a faucet flood cannot fill the pool ahead of a PauseMints.
Gossipsub runs with application-level validation, so a transaction is forwarded to other peers only once it has verified here. A peer that floods invalid transactions is throttled by the meter, answered from the cache, and pays for nothing on the consensus loop.
Building a block
When a validator leads a view, it asks the pool for candidates. candidates_within keeps only transactions that still apply to the ledger it is building on, re-running the cheap half of admission: anchor still in the window, time still in it, nullifiers not yet spent, commitments not yet present. It also drops a token action whose mint_nonce or registration index the chain has moved past, and a pause or a listing at a stale nonce. Since v0.6.8 it re-prices every call and invoke at the prices in force on the tip it builds on, from the gas limit remembered at admission, and leaves out one whose fee is now under its floor; it stays pooled, and is offered again once the prices fall (audit v6, CH-9). Since chain 20 it then orders the rest governance first, then by the fee above the current floor, per KiB of transaction, then by fee, highest first, ties broken by hash, so every honest proposer building on the same pool picks the same block, and fills up to the budget of 2,000 transactions and the chain’s block cap (MAX_BLOCK_TXS; max_block_bytes, 4 MiB by default, 20 MiB on chain 20 and 19 MiB on chain 21). Bytes are what a block is short of, which is why the surplus is taken per KiB. A chain with no gas section and a node with no gas policy skip that key and order by governance, fee and hash, as chain 15 did. Governance first is the other half of the pause’s guarantee: a PauseMints pays no fee by design, since it must work from a wallet holding no RAND, so plain fee order would sort the emergency brake behind every transaction that pays.
One more rule applies while a call or an invoke is ready (audit v6, POOL-2; node policy on every chain, not a validity rule). Only those pay the byte price, and a transfer that pays a little over its flat base out-ranks them per KiB, so a stream of transfers could keep a ready call out of every block. So flat-fee transactions take at most three quarters of the block’s bytes in a first pass; once the calls are placed, whatever room is left is filled with the transfers held back, in their order. No block space is lost, and with no call ready nothing changes (Fees).
A transaction too large for the space left is skipped, not the end of the block: a smaller one behind it still travels. A chain-18 transfer is about 2.85 MB, a 1.49 MB bundle proof and a 1.36 MB authorisation proof, so chain 20’s 20 MiB block had room for about seven, chain 21’s 19 MiB block has room for six at that size (constraint set 9’s proof sizes are not measured here yet), and a default 4 MiB block for one. On chain 15, at about 1.43 MB per transfer, 20 MiB held fourteen. Under load the fee is what decides which (Fees). Since v0.8.0 the pool is also what a replica rebuilds a block from: the leader gossips the header and the transaction hashes (a compact block, Consensus), and a replica fetches by hash only the bodies its pool and its recent-transactions cache lack; a gossiped transaction the per-forwarder meter refused is kept unverified in a one-block overflow cache for that rebuild, never pooled.
Pruning
After every commit, prune drops from the pool exactly what candidates_within would have skipped: transactions whose note was spent by someone else, whose commitment now exists, whose nonce or registration index the chain has moved past, or whose anchor or time has scrolled out of the window (1,024 blocks on chains 20 and 21, 256 through chain 19). A bundle that loses a double-spend race therefore disappears on its own, without ever producing an error to anyone. Acceptance by rand_sendTransaction is not commitment: poll rand_getTransaction until it returns a block, and if the anchor window passes without one, rebuild the bundle on a fresh anchor and submit again.
Watching it
rand_status reports mempool_size, verify_queue (transactions waiting for a worker) and refused_cache (entries in the verdict cache). A verify_queue that sits at 64 means proofs are arriving faster than four workers verify them, about 16 ms each with a warm key; a mempool_size that grows while blocks stay full means the fee, not arrival order, is deciding inclusion.
What is not there yet
- No replacement. Since chain 18 the gas and byte prices move a call’s floor every block, up to their ceilings; a transfer’s floor is fixed. There is no replacement of a pooled transaction by a higher-fee one over the same notes, which the conflict rule refuses. A call priced out waits in the pool, unoffered, until the prices fall or its anchor window passes. On v0.6.7 (chains 18 and 19) a pooled call kept the floor it was admitted at, was still offered after a rise, and failed in the proposer’s trial apply as
FeeTooLow. The fullnode’s block-space document lists the fee-ordered mempool as the step before a bigger block, and block-level aggregation as what lifts the per-block ceiling. - No expiry by age. A pooled transaction leaves only by inclusion, by eviction at the byte cap, or by the anchor and time windows: 1,024 blocks on chains 20 and 21, about 23 minutes at the 1.34 s per block the explorer reported on chain 20 (1.17 s on chain 21’s first 2,300 blocks). Through chain 19 the windows were 256 blocks, about five minutes.
- No mempool RPC. There is no method that lists pending transactions;
mempool_sizeinrand_statusand the count, bytes and oldest age inrand_getMempoolInfoare the only views, and the explorer indexes committed blocks only.