Architecture

Nine crates, one pipeline: bytes arrive from a peer, become a header, become a block, become state, and become an answer to an RPC query.

The crates

A Cargo workspace. Dependencies point one way — core and trie know nothing about the network, and networking knows nothing about execution.

CrateLinesResponsibility
cli15.6kEntry point, argument parsing, genesis bootstrap, wiring every service together — and 58 diagnostic tools under examples/
core8.4kBase types — Header, Block, Transaction, Receipt — consensus rules, validation, chain config, activation heights, the typed chain-event channel
trie8.8kRSK Unitrie: nodes, paths, account encoding, key mapping, and the snapshot traversal and chunk proof
execution53.4kEVM executor, block processor, every RSK precompile, the BTC–RSK Bridge, REMASC, merged mining, supply conservation, the tracer
storage16.7kRocksDB persistence behind a write cache, the position layer, epoch GC, block pruning, the rskj importer
networking10.1kP2P: RLPx, discovery, sessions, peer management, peer scoring and bans, the snap wire messages
sync33.3kSync 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
rpc11.6kaxum JSON-RPC server, the WebSocket transport and eth_subscribe, and the debug/trace namespaces
alerts2.7kTwo-way-peg monitoring, node-health alarms, and the shared email transport
flatdb3.4kAn 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, SkeletonRequest and BodyRequest — 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.

rskjrustock
Per-call-frame isolationNested Repository tracksREVM journal checkpoints
Per-transaction boundarytxSubTrack.commit() → cachenone
Block accumulationMutableTrieCache, an in-memory mapEvmState from evm.finalize()
Trie materialisedtrack.save(), block endapply_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 →