The crates
A Cargo workspace. Dependencies point one way — core and trie know nothing
about the network, and networking knows nothing about execution.
| Crate | Lines | Responsibility |
|---|---|---|
cli | 15.6k | Entry point, argument parsing, genesis bootstrap, wiring every service together — and 58 diagnostic tools under examples/ |
core | 8.4k | Base types — Header, Block, Transaction, Receipt — consensus rules, validation, chain config, activation heights, the typed chain-event channel |
trie | 8.8k | RSK Unitrie: nodes, paths, account encoding, key mapping, and the snapshot traversal and chunk proof |
execution | 53.4k | EVM executor, block processor, every RSK precompile, the BTC–RSK Bridge, REMASC, merged mining, supply conservation, the tracer |
storage | 16.7k | RocksDB persistence behind a write cache, the position layer, epoch GC, block pruning, the rskj importer |
networking | 10.1k | P2P: RLPx, discovery, sessions, peer management, peer scoring and bans, the snap wire messages |
sync | 33.3k | Sync state machine, header and body pipeline, snapshot sync on both sides, coherence invariants, the Φ watchdog, transaction pool and rate limiter, gas price tracker, transaction relay |
rpc | 11.6k | axum JSON-RPC server, the WebSocket transport and eth_subscribe, and the debug/trace namespaces |
alerts | 2.7k | Two-way-peg monitoring, node-health alarms, and the shared email transport |
flatdb | 3.4k | An append-only trie store behind the same seam as RocksDB: mmap'd heap, keyless open-addressed index, CRC'd checkpoint. Faster serially on real state, and not yet the mainnet default |
Around 166,000 lines in all. Nearly half the codebase is execution, and most of that is the Bridge. That ratio is the
honest shape of an RSK client: the EVM is a solved problem with a good Rust implementation, and the
peg is not.
The pipeline
peers
│ RLPx frames, chunked at 32 KB
▼
┌─────────────┐ headers ┌──────────────┐
│ networking │─────────────▶│ sync │ skeleton → chunks → bodies
│ discovery │◀─────────────│ tracker │ 4 requests in flight per peer
│ sessions │ requests └──────┬───────┘
└─────────────┘ │ blocks, in order
▲ ▼
│ ┌──────────────┐
│ relay │ execution │ REVM + precompiles + Bridge
│ │ processor │ one journal per block
┌──────┴──────┐ └──────┬───────┘
│ tx pool │ │ EvmState
└─────────────┘ ▼
┌──────────────┐ ┌──────────────┐
│ trie │───▶│ storage │ RocksDB,
│ (Unitrie) │ │ 7 col.family │ 7 column families
└──────┬───────┘ └──────┬───────┘
│ state root │
▼ ▼
compare with ┌──────────┐
block header │ rpc │
└──────────┘
Every block ends with the same question: does the recomputed state root equal the one in the header? If it does not, the node stops rather than building on state it cannot justify. Nearly every entry in the compatibility catalogue was found by that comparison failing at a specific mainnet height.
Networking
Rustock speaks the RSK variant of devp2p, which is close enough to Ethereum's that the differences are easy to miss and fatal when missed.
- RLPx — full encrypted transport, inbound and outbound handshakes.
- Multi-frame messages. Standard Ethereum clients send each P2P message as one frame
regardless of size. rskj splits anything over 32 KB into multiple frames sharing a
contextId, each with its own header and MAC. A client that does not reassemble them never receives a large body response — it just sees the sync stall. - Kademlia discovery over UDP, with peer exchange and a node table persisted across restarts so a cold start is not a cold network.
- Serving peers. Rustock answers
BlockHeadersRequest,BlockHashRequest,SkeletonRequestandBodyRequest— it contributes to the network rather than only consuming it. - Transaction relay. Transactions from peers are validated, admitted to the pool and rebroadcast to every other connected peer.
Sync
The high-level shape matches rskj's skeleton sync — find a connection point, pull a skeleton of every Nth header, download the in-between chunks, then bodies, then execute. The difference is how much of that runs at once.
Multi-peer parallel header download
Header chunks are spread across every connected peer, with up to four chunk requests in
flight per peer. A PeerChunkTracker assigns chunks, records which peer holds which, buffers
out-of-order responses and drains them back into strict order before they touch the store. Responses are
matched to chunks by content, not by arrival order, so reordering across peers is safe, and
fill_pipeline tops every peer up to capacity each round. rskj downloads header chunks from
one peer, one request at a time.
Overlapped bodies and execution
Body downloads run ahead of execution rather than in lockstep with it, so the network is never idle waiting for the EVM and the EVM is rarely idle waiting for the network. The ordering guarantee is restored at the boundary: blocks are executed strictly in order regardless of the order they arrive.
Snapshot sync
A node can also join by downloading the state at a recent checkpoint rather than executing its way there, and can serve snapshots to other peers — including rskj clients, in rskj's own format. The header chain to the checkpoint is verified under the same proof-of-work rules a full sync applies.
The load-bearing property is completeness. An inclusion proof shows each node sent is in the trie; it does not show that none between them was skipped, and a truthful subset leaves the client with state full of holes that looks like success. Rustock proves both at once by re-running the server's traversal over only the bytes the peer supplied and requiring it to reproduce the chunk. The full design →
Tip following
Once the node catches up, NewBlockHashes announcements drive it. New blocks are fetched,
validated and executed as they arrive; competing branches are tracked with their total difficulty, and
when a heavier branch appears the canonical chain pointers are rewritten.
Sync speedups, with notes on porting each back to rskj →
Execution
Rustock executes with REVM, wrapped in enough RSK-specific machinery that the result is not Ethereum. Four structural things are worth knowing.
1. The trie is written once per block
Both clients accumulate a whole block in memory and materialise the trie once at block end — but by different routes:
Once per block except under RSKIP-144, where each parallel sublist commits its own changes in sublist order and the trie is the merge. That is what makes the sequential sublist's starting state well defined, and it is why there is no reconciliation of several journals into one.
| rskj | rustock | |
|---|---|---|
| Per-call-frame isolation | Nested Repository tracks | REVM journal checkpoints |
| Per-transaction boundary | txSubTrack.commit() → cache | none |
| Block accumulation | MutableTrieCache, an in-memory map | EvmState from evm.finalize() |
| Trie materialised | track.save(), block end | apply_state_changes, block end |
A consequence: a REVERT never touches the trie. There is nothing to roll
back at the storage layer, because reverted writes are simply absent from the state that reaches
apply_state_changes. Gas burned by a reverted call does survive — it moves balances, and
balances are state.
2. The per-transaction boundary is consensus-visible
rskj deletes self-destructed accounts at the end of each transaction. A later transaction in
the same block therefore sees the address as nonexistent and re-creates a fresh (0, 0)
account merely by calling it. REVM keeps one journal for the whole block and wipes a destroyed account
lazily, leaving sticky SelfDestructed and Touched flags that cannot distinguish
"destroyed" from "destroyed, then re-created". Rustock manufactures the boundary it does not have: after
each transaction commits it wipes info and storage and clears the marks, so a later touch reads as
"alive again at block end". Found by mainnet #3,173,807, and pinned by five regression
tests.
3. The raw-storage overlay
Some rskj writes correspond to no EVM operation at all — direct addStorageBytes calls
from Java. REVM cannot represent them, so Rustock carries a second channel with its own per-transaction
scope, drained at block end and applied alongside everything else. The case that forced it is REMASC's
siblings cell, written unconditionally on every REMASC save as a single 0xc0
byte: created at block #1, never changing value, and part of the state root ever since.
4. Receipts carry no post-transaction state root
RSK is Byzantium from genesis, so a receipt's postTxState is the one-byte status
indicator, never a 32-byte root. That is what makes whole-block accumulation viable at all: a
pre-Byzantium chain would need the state root after every transaction, forcing per-transaction trie
materialisation on both clients.
The Unitrie
RSK's world state is not a hexary Merkle-Patricia trie. The Unitrie is a radix-2 binary trie with shared path compression and values embedded in the node when they are small enough. Accounts, code and storage all live in one structure under a key-mapping scheme, rather than in a tree of per-account storage tries.
Rustock implements it from scratch, and the encoding is byte-exact: a node written by Rustock and the same node written by rskj are the same bytes. That is verified continuously rather than asserted — the rskj importer copies trie values across unchanged, and a divergence would show up immediately as a state-root mismatch. The ground truth for the first fixes was rskj's own dump of block #3,397 (the first mainnet block with an uncle), which Rustock now matches leaf for leaf across all 7,588 of them.
The trie also serves eth_getProof-friendly node access, and is what makes historical
state queries work: every node ever written is kept, so any block's state can be read as long as its
nodes have not been collected. A node left alone collects nothing and is an archive node; the
epoch garbage collector is what trades that away when disk matters
more than history.
The Bridge and REMASC
Two precompiles carry most of what makes RSK different from an EVM chain.
The Bridge is the BTC–RSK two-way peg: it maintains a Bitcoin SPV header chain,
validates peg-in transactions against it, queues and signs peg-outs, manages the federation lifecycle
(including the newer signer-validation protocol), and exposes governance. All 70 methods in rskj's dispatch table now have an
arm, both the transaction-callable ones and the local-only getters reachable through
eth_call. Twenty-two of them previously fell through a catch-all that answered with empty
bytes, which decodes as zero or as the empty string rather than failing; a test over the dispatch
source now fails when a row is added without an arm, so an unimplemented method has to be written down
rather than left as a silent gap.
REMASC distributes block rewards and fees at the end of every block, with a delay and a sibling-punishment scheme of its own. It is executed as part of block processing, not as a transaction, and its storage layout is one of the places where an accidental rskj write became permanent state.
Storage
One RocksDB instance, ten column families: headers, block_bodies,
receipts, total_difficulty, block_numbers,
tx_index, trie_nodes, bridge_events,
block_hashes_by_number and snap_chunks. A write cache sits in front of the trie store so a block's nodes
are written in one batch.
The last two are newer and worth a word. bridge_events is keyed
topic0 || block || tx_index || log_index, which makes "every occurrence of this event, in
block order" a prefix scan and a block range within it a seek — without it, finding a peg-in means
walking every receipt in the chain. block_hashes_by_number records every block hash at a
height, canonical or not, because block_numbers answers which block is canonical at
height N and uncle selection needs the siblings that lost.
The store is content-addressed and keeps every historical version unless a collector is enabled, which has a measurable cost: on RSK mainnet the database is around 130 GB, and the live set — the nodes reachable from the current state root — is 0.87% of its entries. Consecutive live nodes sit roughly 115 stored entries apart, so traversal throughput tracks density rather than the algorithm: 4,092 keys/s at 0.87% against 118,893 keys/s at 100%, same code, same machine. Two designs addressing that are written up in research.
RPC layer
An axum HTTP server implementing JSON-RPC 2.0 with the eth, net,
web3, rpc, rsk, txpool, debug,
trace, sco and mnr namespaces, plus eth_subscribe
over a WebSocket on its own port, an administrative subset of rsk_ behind
--rpc-admin, and an evm_ development namespace behind --dev-rpc. State queries are served from the
Unitrie at any historical block; eth_call and eth_estimateGas run against a
forked state with full precompile and Bridge support; filters are persistent.
What it deliberately does not do is anything involving keys. There is no wallet,
eth_sendTransaction and personal_* are unimplemented on purpose, and
transactions are expected to be signed externally and submitted through
eth_sendRawTransaction. Full method reference →