@johnhenry/raijin-mempool
v0.1.1
Published
Transaction mempool with fee-based ordering for the Raijin mesh rollup
Readme
@johnhenry/raijin-mempool
Transaction mempool with fee-based ordering and eviction for the Raijin mesh rollup — a browser-native rollup framework where the users ARE the validators.
Accepts transactions, verifies signatures through an injected verifier, deduplicates by sender+nonce, orders by fee for block building, evicts the lowest-fee transaction when full, and optionally gossips accepted transactions to peers.
Install
npm install @johnhenry/raijin-mempoolThe fee convention — read this first
defaultFeeExtractor reads the first 8 bytes of tx.data as a big-endian uint64 fee (shorter data ⇒ fee 0n). Two traps hide in that sentence:
- It collides with the state machine's convention.
@johnhenry/raijin-core'sStateMachinetreatstx.data[0]as the transaction type. Adatalayout can't serve both defaults at once — if your transactions carry typed data, supply your ownFeeExtractor(e.g.(tx) => tx.valuefor tip-style fees). - The config doc-comment in older builds says the default is
tx.value. It is not. The default is the 8-byte data prefix; verify withdefaultFeeExtractor(tx)if in doubt.
This pool is the one @johnhenry/raijin-validator runs. It used to ship a second, simpler class of the same name; it no longer does, and Mempool imported from either package is now this implementation — see "Which mempool am I holding?" below.
Quick start
import { Mempool, defaultFeeExtractor } from '@johnhenry/raijin-mempool'
const pool = new Mempool({
verifier: async (tx) => myVerifySignature(tx), // required
maxSize: 4096, // default 4096
feeExtractor: (tx) => tx.value, // optional — see trap above
gossip: { broadcast: (tx) => channel.send(tx) }, // optional
})
pool.onDropped((tx, reason) => console.log('dropped:', reason))
// reasons: 'duplicate' | 'invalid-signature' | 'pool-full' | 'evicted'
await pool.submit(tx) // true if accepted (also gossiped), false if dropped
const forBlock = pool.pendingForProposer(100) // top-100 by feeAPI
Mempool
new Mempool(config: MempoolConfig) — config.verifier is required; maxSize, feeExtractor, gossip optional.
| Member | Purpose |
| --- | --- |
| submit(tx): Promise<boolean> | Dedup (sender+nonce) → verify signature → evict-or-reject if full → accept + gossip. |
| remove(tx): boolean / removeBatch(txs): number | Drop by sender+nonce identity (e.g. after block inclusion). |
| pending(): Transaction[] | All transactions, fee-descending, nonce-ascending on ties. |
| pendingForProposer(limit?): Transaction[] | The same ordering, truncated to limit. |
| has(tx) / hasNonce(sender, nonce) | Membership checks by sender+nonce. |
| hasCapacityFor(tx): boolean | Cheap backpressure check, no signature verification: true while there is room, or when tx out-bids the lowest pending fee (so submit would evict). Added in 0.1.0 for ValidatorNode gossip. |
| size | Current count. |
| onAccepted(handler) / onDropped(handler) | Event hooks; onDropped receives a reason string. |
Eviction rule: when the pool is full, an incoming transaction must have a strictly higher fee than the current lowest to displace it. Equal fee ⇒ dropped as 'pool-full'. The displaced transaction is emitted as 'evicted'.
Identity is sender+nonce, not content: a second transaction from the same sender with the same nonce is a 'duplicate' even if its fee is higher — there is no replace-by-fee.
orderByFee(txs, feeExtractor): Transaction[]
Pure sorting helper (fee descending, nonce ascending on ties). Returns a new array; usable without a Mempool.
defaultFeeExtractor(tx): bigint
The 8-byte big-endian data prefix described above.
Types
MempoolConfig, FeeExtractor ((tx) => bigint), TransactionVerifier ((tx) => Promise<boolean>), GossipTransport ({ broadcast(tx) }), MempoolEvents.
Using it as a block builder's source
The intended consumption pattern — take the top of the pool, build, then prune exactly what was included:
const txs = pool.pendingForProposer(maxTxPerBlock) // top-N by fee
const block = await buildBlock(txs) // your producer
// …after the block finalizes:
pool.removeBatch(block.transactions) // prune by sender+noncepending() returns a fresh array each call, so it's safe to build from while submit()s keep arriving — but the pool contents can change between pendingForProposer() and removeBatch(), which is fine: removeBatch returns how many were actually removed, and transactions that arrived meanwhile simply wait for the next block.
Which mempool am I holding?
One implementation, two import paths. @johnhenry/raijin-validator re-exports this class:
import { Mempool } from '@johnhenry/raijin-mempool' // same class
import { Mempool } from '@johnhenry/raijin-validator' // as this oneThis was not always true, and older documentation says so: raijin-validator used to define its own FIFO Mempool with no signature verification that threw when full, and ValidatorNode used that one. It doesn't exist any more. ValidatorNode now constructs this pool, with maxSize: maxMempoolSize and a verifier built from the node's identity.verify — the same SignatureVerifier the state machine uses — so a transaction with a bad signature is rejected at submitTransaction() rather than becoming a revert receipt one block later.
Fee ordering is therefore active inside a validator by default. The one thing to watch is the fee convention above: defaultFeeExtractor reads tx.data's first 8 bytes, which collide with the state machine's type byte, so supply your own FeeExtractor if your transactions carry typed data.
Provenance
Previously published unscoped as raijin-mempool: 0.0.1 (initial release, 2026-03-15), then 0.0.2 (2026-07-16 — published with a broken build, because the release workflow used plain npm publish, which does not rewrite workspace:* internal dependency specifiers into real resolved versions; that tarball was unpublished), then 0.0.3 (2026-07-16, same day — republished correctly via pnpm publish, the last unscoped version, live until this move).
Moved into the @johnhenry npm scope and restarted at 0.0.0 — no functional changes in the move. The workspace:* publish bug can no longer recur: the monorepo has since converted to npm workspaces with real semver ranges, and releases go out via npm publish --workspaces. See the root CHANGELOG for the full project history.
License
MIT
