The organising constraint
Rustock must agree with rskj bit for bit, because a disagreement is a chain fork rather than a bug. That is why so many of these components are either a port of a specific rskj class or an instrument for proving equivalence with one.
Components marked extended existed in outline before this window; the rest are new. Components marked rustock-only have no rskj counterpart at all. If you know rskj and want the file that answers to a class you already know, the rskj → rustock map pairs the two codebases package by package.
The parts that exist only here
Several components are not ports of anything. They exist because Rustock is a second implementation and has to prove it agrees with the first — and a few of them are simply things rskj never needed to build.
Supply conservation
Per block and per transaction, a check that execution did not create rBTC from nothing. The worst failure this node could have, and otherwise invisible: state roots would agree with an rskj carrying the same bug.
No rskj equivalentWhole-chain replay
Re-executes all of mainnet block by block, comparing each state root against the header. Turns "we believe this matches rskj" into a measurement.
No rskj equivalentCoherence invariants
Eight stated relations over the node's position markers, checked after every commit — and four of them now repaired automatically rather than only reported.
No rskj equivalentThe Φ progress watchdog
A progress measure with an escalation ladder, for the node that believes it is syncing and is not.
No rskj equivalentBlock pruning
rskj keeps every block forever. Rustock can delete headers, bodies, receipts and index entries below a retention floor.
No rskj equivalentrskj database import
Reads a running rskj node's RocksDB and converts it to Rustock's layout — the reason every other component could be tested against real mainnet data rather than fixtures.
No rskj equivalentSegmented trie tooling
Splits the trie into self-contained chunks that can be distributed and read independently, with tooling to build, verify and repair them.
No rskj equivalentBridge event index
Peg activity queryable by height and type, rather than by re-scanning every receipt in the chain.
No rskj equivalentRead-only diagnostics
Fifty-eight command-line tools, most of which open the database with no lock and no WAL replay — so they can be pointed at a production store while it is running, or wedged.
No rskj equivalentFoundations
rskj database import rustock-only
Reads a running rskj node's RocksDB — unitrie state, blocks, receipts — and converts it into Rustock's own column-family layout, resuming if interrupted.
Without it a new node would have to execute 9.2 million blocks from genesis before it could do anything. With it, the node starts from mainnet state, which is what made every other component testable against real data rather than fixtures.
Whole-chain replay rustock-only
Re-executes the entire mainnet history block by block and compares each resulting state root against the one in the header.
This is the instrument the rest of the work depends on. It does not add a feature; it turns "we believe this matches rskj" into a measurement, and it found consensus bugs in the Bridge that no unit test would have — including ones where Rustock and rskj agreed for millions of blocks and then diverged at a single transaction.
Consensus
The two-way peg — Bridge precompile extended
The contract at 0x…01000006 that moves BTC to and from RSK: peg-in registration
with partial Merkle proofs, peg-out queueing and BTC transaction building, federation governance and
migration, the BTC header chain, the lock whitelist, locking cap, and the fee market for peg-outs.
It is the largest and most consensus-critical component, and the one where rskj's behaviour is
least like anything documented — bitcoinj byte orders, Java HashMap iteration order
deciding which peg-out is paid, and a dozen frozen bugs that have to be reproduced exactly because the
chain's history depends on them.
All 70 methods in the table now have a dispatch arm. Twenty-two
of them used to fall through a catch-all that answered with empty bytes — which decodes as zero, or as
the empty string, rather than failing. A federator client asking
getStateForBtcReleaseClient got a plausible-looking nothing. A test over the dispatch
source now fails when a row is added without an arm, so "unimplemented" has to be written down rather
than left as a silent gap.
The union bridge extended
Seven further Bridge methods, enabled at reed810 (RSKIP-502). A selector the Bridge does not know fails in exactly the way a malformed call does, so the gap never named itself: testnet #7,625,612 looked like a wrong status flag and #7,629,709 like a transaction burning twice the block's gas. Both were this.
Two details decided it and neither is in the RSKIPs, so both were read off the chain. The union
cells are keyed by keccak of the name, where the older Bridge cells right-align the ASCII — and every
value is RLP, so a boolean false is the empty string rather than a zero byte. Read raw,
the stored contract address yields nothing, the address falls back to the zero constant, and every
authorized call is then rejected as unauthorized.
Authorization is also checked against the immediate caller rather than the transaction sender. It has to be: a contract never originates a transaction, so an authorization keyed to the sender would leave the union bridge contract unable to authorize itself.
RSKIP-529's base and super event methods are not implemented yet.
Merged mining
Builds block templates, serves the mnr_* RPC that mining software speaks, validates
and imports submitted solutions, selects uncles, and handles the Bitcoin coinbase, Merkle proof and
RSKIP110 fork-detection data.
Without it the node can follow the chain but never contribute to it. Merged mining is how RSK inherits Bitcoin's hashpower, so this is the difference between an observer and a participant.
Block validity rules
The eight rules rskj applies to every incoming block before executing it — transaction gas-price bounds, the REMASC transaction, uncle constraints, extra-data limits, fork-detection data — that Rustock did not have.
These are the rules that reject a block. Whole-chain replay cannot exercise them, because mainnet contains no invalid block: the only way to have them is to write them from rskj's source and test them directly.
Versioned block headers (RSKIP-351)
A V1 or V2 header carries a compressed RLP([version, hash]) in its logs-bloom slot, and
that hash commits to an extension body Rustock parsed but had no model of. Translated from rskj's
BlockHeaderExtension hierarchy, with three byte-level decisions pinned by tests rather
than left to the reader: the hash commits to keccak256(logs_bloom) and not to the bloom,
which is what lets a peer verify a header without being sent 256 bytes; V2 inserts its base event
before V1's edges, because the subclass adds its own field and then calls super;
and the edges are packed little-endian before being RLP-encoded, where RLP is
big-endian everywhere else.
Transaction sublists (RSKIP-144)
A Rootstock block can be partitioned into sublists intended for parallel execution, and the
header's txExecutionSublistsEdges say where each one ends. A miner chooses those edges, so
consensus has to bound them: an edge past the end of the transaction list, or out of order, partitions
a block in a way no other node reproduces. Ported from rskj's
ValidTxExecutionSublistsEdgesRule with its twelve-case test suite.
The bound itself is the first place the specification and the implementation disagree. RSKIP-144 specifies four execution threads; rskj's constant is two. Rustock follows the code, because three edges is precisely the case where the document's reading accepts a block rskj rejects.
The other half is receipts. A split block accumulates cumulativeGasUsed per
sublist rather than across the block — a rule that appears in no RSKIP at all. Testnet
#7,690,876 is where it surfaced, and it surfaced cleanly: the state root matched
exactly and the receipts root did not. It is also a lesson about activation heights. RSKIP-144 turns on
at reed810, but the edges field is emitted empty on every header from there until #7,690,876 — so
100,002 verified blocks had the rule enabled and not one of them exercised it. "Above the activation
height" and "uses the feature" are different ranges.
Two further consensus conditions followed. A key in one sublist's write set and another's read or write set invalidates the block — read against read is fine, because only a write makes an ordering observable. That is a rule a validator applies, not one it may assume the miner established: where sublists conflict, the sequential interleaving is the parallel result sometimes and not others, so two clients would agree by luck. And rskj bars REMASC and Bridge calls from a parallel sublist outright, failing the sublist and so the block; reaching the precompile is what counts, not what came of the call, so a Bridge call that reverts still bars the transaction.
Parallel execution
The sublists now run on their own threads. run_sublist executes one contiguous run of a
block's transactions in the block's own transaction indices rather than sublist-relative ones — the
Bridge reads the index, the tracer selects on it and the collision tracker attributes by it, so a
rebased index would be wrong in three places at once.
Each sublist's changes go into the trie as that sublist's own commit, in sublist order. The trie is the merge, which is what rskj does and what makes the sequential sublist's starting state well defined — there is no reconciliation of several journals into one, which is where this would otherwise have become delicate.
What made the sublists mergeable at all was moving the gas fee. Under RSKIP-144 rskj postpones it and credits the block's whole total to REMASC immediately before the REMASC transaction runs. Crediting per transaction instead has every sublist writing REMASC's account cell, and those writes would have to be summed rather than merged. The end state is identical either way; what differs is what an earlier transaction sees if it reads REMASC's balance, and that is consensus.
How it is verified. 3,812 block verifications on testnet, six ranges spanning the chain from the reed810 activation to the head, each replayed at one thread and at two, every block compared against rskj's own state rather than against the other thread count — a shared mistake would agree with itself. Zero divergences. Plus a block whose sublists share a sender, the race the rule exists to forbid, caught as a write-write collision when the sublists really do run apart.
--execution-threads defaults to 1 and is clamped at 2, because rskj's
TX_EXECUTION_THREADS caps a block at two parallel sublists. Sender recovery was moved onto
the same scheduler and onto libsecp256k1 — 247.9µs to 58.8µs per recovery, measured over
three hundred real mainnet transactions — but it stays on the calling thread below sixteen
transactions, which is every block mainnet currently produces.
The miner side
A port of rskj's ParallelizeTransactionHandler. The partition a miner chooses is not
consensus — a validator only checks that the sublists do not collide — but matching it means Rustock's
blocks are partitioned the way the network's other miner would partition them, and it means rskj's own
tests can be the oracle.
The template builder used to emit no partition at all, so nothing Rustock mined could ever run in parallel. It now executes the selected transactions once, learns what each touched, places them with the ported partitioner and rebuilds the block in sublist order. rskj reorders its receipts from that single pass rather than re-executing, relying on a collision-free partition reaching the same state either way — which is true, and is exactly the theorem the validator exists to check rather than assume, so the block that gets published is the one that was executed.
The signature cache
The same transaction has its sender recovered several times over its life: once when the pool admits it, again when the block carrying it executes, again on every RPC render, and again on each re-execution after a reorg. The arithmetic is identical every time, and it is the most expensive thing the node does per transaction.
The obvious cache is the wrong one. A Mutex<HashMap> consulted once per
transaction would serialise the exact region parallel recovery exists to parallelise — and would do it
invisibly: the work still happens, one thread at a time, with no error and nothing in the
logs. So the lock is never taken from a worker. A block is looked up once before the threads start, the
misses are recovered in parallel with no cache access at all, and the results are written back once
after the threads join: two acquisitions per block rather than one per transaction. The cache
deliberately offers no single-transaction lookup, because offering one would invite calling it from
inside the parallel region.
It is keyed by transaction hash, which is sound only if that hash separates every pair of
transactions that could recover to different senders — in particular two differing only in
v, since v selects both the signing hash and the recovery id. The property was
already true; it is now written down and pinned by a test, because a cache keyed on a hash should not
rest on an unstated invariant.
Two tiers, which is what rskj's two caches are for. The sizes suggest capacity tuning; the real reason is eviction isolation. Pool entries are long-lived — a transaction waits an unknown number of blocks to be mined — while a peer's block traffic is validated once, bursty and chosen by someone else. One shared map lets the second evict the first, and the pool-to-execution hop then misses every time: the cache still works, it just silently stops paying.
Testnet
Mainnet replayed clean for 9.3 million blocks while testnet died at block #1. The causes turned out to be almost entirely per-chain configuration that had been defined and then never selected — which is why each one looked implemented.
- Block #1 — RSKIP-97. Rustock computed activation as
number >= orchid. That is only the default:testnet.confsetsrskip97 = -1, so on testnet the ten-minute difficulty drop is permanent. Mainnet does not override it, so>= orchidwas accidentally right there and nothing surfaced it. - Genesis — RSKIP-126. rskj seeds the precompile storage roots from two places, the block executor and the genesis loader, because genesis is constructed rather than executed. Rustock had only the first, which is right for mainnet, where wasabi100 is an ordinary block at #1,591,000, and silently wrong for testnet, where it is height 0. With nothing seeding them, Rustock's testnet genesis reduced to its allocation — and that allocation was mainnet's, so the two chains agreed on a state root they must not share.
- Block #60 — REMASC. rskj's
remasc.jsonis per-network: maturity 4,000 on mainnet, 50 on testnet. The executor selected Bridge constants by chain and then hardcoded the mainnet REMASC config on the line above. Mainnet's maturity meant no payout was attempted before block 4,000, which is why the preceding 59 testnet blocks agreed. - #7,139,903 — the federation UTXO cell. rskj picks the cell by network and activation; after RSKIP-293 testnet spends from a post-hop cell while mainnet always uses the general one. Rustock had all three key constants and used only the first, so a pegout batch built from the wrong inputs produced a different transaction, hash and outpoint values — seven leaves, from one wrong cell.
- #7,610,038 — the base-event recovery window. Between vetiver900 and #7,800,000, testnet rskj accepts a V2 header that is one RLP element short and substitutes an empty base event. The decoder read that single trailing element as the base event, so the edges were taken for it. Edges are emitted on every versioned header even when empty; the base event is the field that can be absent. Checked by sweeping 275 headers across the V2 range against the hashes rskj stores them under.
What remains is the replay itself: Rustock now gets past every divergence found so far, but a continuous genesis-to-tip testnet replay has not been completed.
Supply conservation rustock-only
Checks, per block and per transaction, that execution did not create rBTC from nothing, and rejects the block if it did.
RSK's supply is bounded by the BTC locked in the peg. A bug that mints rBTC is the worst failure this node could have, and it would otherwise be invisible — state roots would agree with an rskj that had the same bug, and nothing else looks at the total.
State and storage
FlatDB — a trie store that stores no keys
A port of rskj's co.rsk.flatdb branch, and the one structural change to how trie nodes
are held. RocksDB is a general key/value store being asked to do something very specific; FlatDB is
built for the specific thing. It holds data whose key is the hash of its value, and every
decision in it follows from that plus three facts about the workload: no deletions, no range queries,
no mutation.
The consequence worth the port is that the index stores no key bytes at all — a
table of 8-byte slots over an append-only heap. A probe resolves a collision by fetching the candidate
record and recomputing H(value), which means every successful short-record read is also a
verification of the bytes against their own address. A corrupted record cannot come back looking
correct, which is not a property the store it replaces has.
The precondition was checked rather than assumed, because the whole design rests on it: converting the real mainnet store copied 25,371,307 nodes across three epochs with none skipped, where the converter skips any entry whose key is not its value's digest. On disk a flush is three ordered steps — heap, index, then checkpoint — and only the checkpoint makes a batch real, so a crash between steps loses the batch rather than half of it.
What it is worth is 1.76× serially on real mainnet state. That is the honest
figure and it is lower than the one the synthetic fixture gave; block execution issues
dependent reads and cannot batch them, so the serial column is the one execution lives in.
--trie-engine flatdb selects it and the default stays rocksdb, for one precise
reason: it has not yet run on a real chain. The research note has the
measurements, including the three results that repetition took back.
Unitrie storage and epoch garbage collection
RSK's single trie for accounts, code and storage, plus a mark-and-sweep collector that works over rotating epochs rather than the whole store.
State grows without bound otherwise: every historical state is retained, while only a recent window is reachable. The epoch design lets the collector mark from one deep root and sweep whole epochs, which is what makes the collection affordable on a 130 GB store.
One root turned out not to be enough. A node that serves snapshots must also keep the checkpoint it offers, which sits 10,000–15,000 blocks back — three to four times deeper than the burial depth, and so not reachable from the collection root. Measured, the margin was not there: ~3,900 blocks fill a 1 GB epoch, so with four epochs the sweep horizon is ~12,000 blocks against a checkpoint up to 15,000 old. The collector now takes pinned roots.
Marking the checkpoint instead is not an alternative: two states at different heights share most of their nodes but neither contains the other, so dropping the collection root would sweep what the node executes on — a failure this store has actually suffered. The live set has to be the union, and the union is nearly free, because a key is queued only when it is new: measured on mainnet, pinning two checkpoints added 0.58% of the live set and 1.3% of the mark.
The collection root is walked first, and that ordering is load-bearing rather than incidental. A missing entry means opposite things for the two kinds of root — under the collection root the live set is incomplete and the sweep would destroy reachable state, so the cycle aborts; under a pin it means that checkpoint is already half-collected, which is reported and does not stop anything. A pin that could abort a cycle would stop the collector permanently the first time a checkpoint slipped, turning one broken thing into two.
Segmented trie tooling rustock-only
Splits the trie into self-contained chunks that can be distributed and read independently, with tooling to build, verify and repair them.
Useful for analysis and distribution — and the exercise that established a precise limit worth knowing: the chunks are self-contained for Rustock's own read set, and a foreign client with a different access pattern will hit missing nodes.
Block pruning rustock-only
Deletes headers, bodies, receipts and transaction-index entries below a retention floor, in one batch per block so the store is never half-pruned.
Full history is not needed to follow the chain, and disk is the binding constraint on a node that also holds a 130 GB trie.
Bridge event index rustock-only
Indexes the events the Bridge emits — peg-ins, peg-out requests, releases, federation changes — so peg activity can be queried by height and type rather than by re-scanning receipts.
Peg activity is the thing operators most need to see, and it is the part hardest to reconstruct after the fact.
Sync and reliability
The position layer, coherence invariants and the Φ watchdog
Three storage pointers — head, executed head, canonical index — unified behind a single transition type that writes them in one batch; eight stated invariants (I1–I8) checked continuously; and a progress measure Φ that detects a node that believes it is syncing and is not.
Nearly every outage in this node's history was two of those three pointers disagreeing, and they used to be written from a dozen places. The redesign makes the incoherent states unrepresentable rather than merely unlikely, and the rollback decision is a pure function simulated over thousands of generated chains and forks.
Four of the eight invariants are now repaired, not only reported — and that change was earned rather than designed. The module was built to make disagreements loud; on 24 September 2026 a one-block sibling fork at the tip left execution on the losing side and I5 named it correctly every thirty-five seconds for twelve minutes while nothing consumed the report. An invariant that is only ever printed is a diagnostic. In production the repair now fires several times an hour on ordinary tip forks, almost always one block deep — and the violation is never logged at all, because it is fixed before it is old enough to be worth reporting.
Snapshot sync
Joining the network by downloading the state instead of executing every transaction into it. Both sides: serving snapshots to peers, and using one to catch up.
What is given up is the re-execution of history, not the verification of it. The header chain to the snapshot point is verified under the same proof-of-work rules a full sync applies, and every chunk of state is proved against that chain's state root as it arrives.
The property worth naming is completeness. Inclusion proofs — the natural thing to reach for — show that each node sent is in the trie. They do not show that none between them was skipped, and a peer that sends a truthful subset leaves the client with state full of holes: worse than a failure, because it looks like success and breaks later, somewhere else. Both properties are proved at once by re-running the server's traversal over nothing but the bytes the peer supplied and requiring it to reproduce the chunk. Omitting a node changes the replay; changing a node changes a hash.
Three things the client is never told, because it can work them out: where a node sits in the traversal (the replay computes it), how big the trie is (the root node commits to its own subtree size, and every proof carries the root), and where a chunk stopped. rskj sends all three as fields the client must then check. The strongest check is a field that does not exist.
Chunks sit on a fixed offset grid, which is what makes a server's work reusable: the checkpoint moves
only every 5000 blocks — about 1.7 days — so every client syncing in that window wants the same bytes,
and off a grid they would each ask at different boundaries. Cells are cached under
state_root || index, and two generations are kept, the checkpoint now
offered and the one before it, so a client that took its status just before a roll goes on being
answered rather than dropping onto the cold path for the rest of its download.
Measured against mainnet state at #9,272,510: 2.1 disk reads per node served, a 100 KB chunk verified in ~11 ms, witness overhead 2.9%, and a contiguous sweep confirming consecutive chunks tile the offset space with no gaps. Serving one chunk costs 1.73 s from cold disk, 29 ms warm, and 0.46 ms from the cache.
Every failure is charged to whoever sent the answer rather than whoever was asked, which is what lets a chunk be taken from any peer: a proved chunk is good whatever its route, and blame following the sender is what keeps that safe. Declining to serve and speaking rskj's older format are explicitly not faults — a pruned peer is behaving correctly, and punishing it would teach the network to stop offering — so those peers are dropped from the rotation instead. One peer's bad data no longer ends the sync: the session is abandoned, the peer is charged, and another session starts against someone else.
The header walk to the checkpoint runs in parallel rather than serially. A skeleton request is addressed by height, not by hash, so it breaks the dependency that made the walk 48,000 round trips one at a time — and the identifiers it returns are never believed, only used to decide where to ask. What makes an answer trustworthy is that the runs link, by arithmetic rather than search, down to a block already in the canonical index.
rskj clients are served in rskj's own format, which they are told apart by: their chunk request carries three elements where Rustock's carries four. The encoder is checked byte-for-byte against vectors generated by running rskj's code — which also established that for the same keys Rustock's trie is identical to rskj's: same roots, same RSKIP107 sizes, same consensus messages, node for node.
Afterwards the canonical number → hash index is filled in the background for the history
below the checkpoint window. Consensus does not need it — every execution-path lookup is bounded well
inside that window — but RPC about old heights does, and so does serving history to other peers, without
which the node takes history from the network and gives none back. Measured on mainnet: 415 MB, and a
walk that runs at 82,700 headers/s warm against 1,770 cold — two minutes or ninety, depending entirely
on page cache. It yields between batches and records a cursor before each pause, so a node that restarts
daily still converges rather than starting the hour again each morning.
What a server will spend on a stranger is bounded in bytes, because bytes is what is actually scarce. Computing cells is not: a server answers for exactly one state, so there are only ~9,200 distinct cells, and the worst a peer can do is make the server compute a cache that every later client reuses. Egress has no such ceiling — a peer holding the three concurrent requests it is allowed pulls cached cells at ~300 MB/s indefinitely. Two token buckets, one per peer and one for the whole server, meter it at 8 and 32 MB/s. A peer over its rate is made to wait rather than refused: waiting needs no protocol support, and with only three slots a slowed peer throttles itself.
A server must never serve a state it holds only in part, and
it cannot check cheaply: children_size is hash-committed inside the root's own message, so
a root reports the size of a subtree that is no longer on disk, and proving completeness means walking
ten million nodes. Worse, the traversal skips a node it cannot resolve and carries on without
advancing the offset, so a collected subtree becomes a chunk that looks well formed and is not —
the client's replay disagrees with the root hash and it scores an honest peer as malicious. So the
server replays every cell it builds against the state root before sending it, using the same verifier
the client runs. A cell that fails strikes the state off, and the checkpoint stops being offered.
Refusing costs the client one round trip; serving costs the node its reputation.
Transaction pool and account rate limiter
A mempool with nonce-gap handling and pending/queued separation, plus a port of rskj's
TxQuotaChecker: each account accumulates "virtual gas" and pays a cost computed from six
factors when it broadcasts or replaces a transaction.
The limiter is what stops one account flooding the shared mempool by broadcast-then-replace. It is consensus-adjacent rather than consensus-critical, but a node without it is trivially degraded by a single peer.
Gas price tracker
Ports rskj's GasPriceTracker: a percentile over the last 512 transactions' gas prices,
floored at the block minimum × 1.1, with a 50-block window deciding whether the fee market is
"working".
It answers two questions the node previously got wrong. eth_gasPrice was reporting the
validity floor rather than the price to pay for inclusion, so a wallet trusting it during
congestion would underpay; and the rate limiter's low-gas-price factor was pinned to 1 instead of
ranging to 4, making a floor-priced flood four times cheaper than rskj allows.
Interfaces
JSON-RPC surface extended
The eth_, net_, web3_, rsk_,
txpool_, debug_, trace_, sco_ and
mnr_ namespaces, shaped to rskj's output rather than go-ethereum's.
That distinction is the whole of the work. rskj's answers differ from geth's in ways that look like
typos and are not — bare hex where geth prefixes 0x, an array where geth has an object,
JSON numbers where geth has hex strings — and a client written against rskj silently reads nothing
from a geth-shaped node.
eth_getProof over a unitrie
EIP-1186 defines its proof arrays as RLP-encoded Merkle-Patricia nodes, and RSK has no Merkle-Patricia trie — accounts, code and storage all live in one binary radix trie with its own node encoding. There is no way to satisfy the letter of the standard, so Rustock answers with EIP-1186's envelope, which is trie-agnostic and is what a caller came for, over proof arrays of unitrie node messages.
A stock EIP-1186 verifier will not verify these — it fails to parse them, which is the safe failure,
and the module says so at the top rather than leaving it to be found. The encoding is not invented:
rskj already returns proofs as a hex array of toMessage() bytes, so this reuses RSK's
convention instead of adding a second one. rskj does not implement the method at all, which makes
offering it a deliberate divergence rather than a port. The design, and the
judgement calls in it.
Gas estimation in one pass
The obvious implementation of eth_estimateGas is a binary search: execute at the cap,
halve the interval until it closes, about twenty-four executions per call. Rustock answers in one.
One execution already contains the answer. RSK's gas counter is not monotonic — at a CALL
it adds the callee's whole allowance and subtracts what comes back — so the peak of that
counter, not its final value, is what the caller had to hold. That gap is precisely what binary search
rediscovers one halving at a time. The old estimator stays behind a flag, where it also serves as the
test oracle.
Transaction tracing — the debug_ and trace_ namespaces
One inspector, rendered two ways. debug_traceTransaction and the block variants give
the opcode stream in the shape of rskj's DetailedProgramTrace;
trace_transaction, trace_block, trace_get and
trace_filter give the call tree — what an explorer shows as internal transactions.
Tracing a historical transaction is harder than it sounds, because its pre-state does not exist anywhere: only per-block roots are stored, so the whole block is re-executed from its parent with the tracer switched on at one index. The consensus block executor is shared rather than copied for this, which is the load-bearing decision — two copies of that loop would drift, and this codebase has already had that failure twice in its orphan-recovery paths.
Both namespaces come from the same inspector so they cannot disagree about the
same transaction. The rest of the work is rskj's shape, which is full of things a client written
against Parity will get wrong: a call to the Bridge never appears in a call tree at all
(rskj emits no subtrace for a precompile), a failed CREATE vanishes with its whole
subtree, trace_get indexes the block rather than walking the transaction, and
trace_filter matches whole transactions rather than individual traces.
WebSocket subscriptions
eth_subscribe over a WebSocket on its own port: newHeads,
logs and newPendingTransactions, fed by one typed chain-event channel that
the sync service publishes to as it follows the tip.
Without it a client has to poll, which is slower and — for logs — lossy across a reorg, exactly
where it most needs to be told. The care is in the retraction: a reorg re-sends the abandoned branch's
logs with removed: true before the replacements arrive, because this node sees a tip fork
several times an hour and a subscriber that never heard about one would accumulate logs from blocks no
longer on the chain.
Peer scoring, punishment and banning
A port of rskj's co.rsk.scoring: fifteen event counters per peer, kept by node id
and by address, a punishment whose length grows on repeat offences, and address/CIDR
bans that persist across restarts — with the sco_* namespace to inspect and override it
all.
It was the one defensive gap against rskj. Before it, a peer feeding bad headers or flooding the mempool was sidelined for minutes and then welcomed back, with no operator control and no visibility. The care in it is aimed at the failure nobody notices: an honest-but-unreliable peer must never be punished, because a node that excludes peers rskj keeps ends up partitioned.
Operations and evidence
Prometheus metrics
The node had no observability surface at all — no endpoint, no counters, no expvar — and that cost more than it looks. Two sync improvements landed in October, one removing about 22% of body requests and one returning CPU in REMASC, and neither could be given a figure in blocks per second, because nothing recorded where a sync spent its time or how many peers it held while spending it. Both ended up measured by hand, which gives answers about the parts rather than about the node.
Twelve counters and gauges now cover execution, gas, transactions, headers, bodies, peers and sync
height. --metrics-port opens a listener and leaving it unset opens none. It is a separate
listener from the JSON-RPC server on purpose: a scrape is not an RPC call, the RPC port is usually
bound to localhost while metrics are scraped from elsewhere, and neither should contend with the
other.
Alerting: peg-outs and node health rustock-only
Watches the Bridge for peg-outs above a threshold and reports the total value in transit on a schedule — and, over the same email transport, alarms on the node itself: execution stalled, the best peer far ahead, or this node ahead of every peer, which on a chain that forks at the tip several times an hour usually means it is building on a branch the network has abandoned.
A peg-out is the only operation on this chain where a bug loses real BTC. This is the component that means a human finds out within minutes rather than from someone else's incident report.
Read-only diagnostics rustock-only
Fifty-eight command-line tools over a live database, most of which open it
read-only — no lock, no WAL replay, no writes, so they can be pointed at the
production store while it is running or wedged. resume_point answers "where would
execution resume" by applying the very function the node applies; height_peek lists every
block stored at a height, canonical or not; diff_roots and diff_state locate
where two state roots disagree.
They exist because a wedged node is a bad place to learn things. On 24 September 2026 the node was
stopped mid-stall and both fixes were checked against the frozen database before any code was deployed
— resume_point said "resume at #9,268,362, one block re-executed", which is exactly what
the fix does, and height_peek turned up two facts live RPC probing had missed. The
alternative is inferring the store's state from log lines, which is how the same day's first
investigation went to the wrong place entirely.
Some exist to keep a design claim honest rather than to debug anything. pin_cost walks
the collector's mark from two roots and reports what the second one added, which is how "pinning costs
only the delta" became 0.58% instead of an argument. header_verify_bench measures header
verification under the full consensus rules — and carries a negative control, tampering
with a header's merged-mining field and requiring rejection, because a throughput number from a rule
that quietly no-ops is worse than no number.
The rskj compatibility catalogue rustock-only
Thirty-three documents recording, with rskj source citations, every place where Rustock had to
reproduce something that is not in any specification: Java artifacts (HashMap ordering,
BigInteger sign bytes, RskAddress rendering the zero address as
null), deliberate RSK divergences from Ethereum, frozen bugs, and rskj-versus-geth RPC
differences.
A from-scratch client can read every line of rskj and still fork, because the behaviour that matters is often an accident of the JVM rather than a decision anyone wrote down. This catalogue is where each of those was found, proven against mainnet, and written down so it is found once. The catalogue →