Skip to content
Back to Blog
Ecosystem Updates

Rustock node:Six months later

Read Time: 10 mins
Rustock Node: Six Months Later

By Sergio Demian Lerner, Co-founder and Chief Scientist at RootstockLabs.

In this follow-up to A blockchain node written entirely by AI: can we trust it?, Sergio shares his experience developing Rustock, an experimental Rust implementation of the Rootstock node, using AI coding agents. He explores the technical challenges, the improvements made and what a second node implementation could mean for Rootstock.


I started working on the Rustock node to try to improve rskj’s snap sync protocol. Rustock is a Rust implementation of the Rootstock network node, available at rustock.dev, and it’s a project begun by Javier Álvarez Cid-Fuentes, which I am now the one pushing forward.

I had two main reasons to pursue this project: contributing to the Rootstock network and, at the same time, getting into a serious AI coding project using the most modern models. The big advantage of doing the research on Rustock rather than on rskj has to do with development cycles and with motivation. rskj is the codebase that fixes Rootstock’s consensus. Since 99% of the network’s nodes run rskj, its core developers must be extremely cautious about changing the node. The decision is a personal one: waiting years to see something implemented takes nerves of steel, and after a few months I have usually moved on to a project that motivates me more. The waiting kills me.

Rustock node

Working on Rustock gave me the chance to see my experiments running on the network without the long wait. Having a second implementation of the same consensus also gave me something I did not have before: the ability to run comparative performance tests, and to carry back to rskj the improvements that are concise, self-contained, and at the same time deliver the largest efficiency gains.

When I first looked at the Rustock node I understood what “experimental” really meant. There were many basic functions that had never been exercised and simply did not work. Other functions, the moment you scratched the surface, turned out to skip critical verifications and to carry a long list of security holes. The models that built Rustock put the emphasis on passing the tests, but the tests did not cover enough adversarial cases.

I started the slow job of fixing each problem. But at the same time I realized that modern models like Claude Opus 5 could review the code far faster than I could, and my time for programming is very limited: my main job, for many years now, has been research. So I decided to continue the experiment with extreme vibe coding: I set myself the rule of never, ever looking at the source code. I would correct and steer the agent on algorithms, and shore up its gaps about the internals of Rootstock consensus, but never look at the code. Treat it like assembly language.

I set some ground rules for code generation: test cases and adversarial thinking. Write documents explaining the algorithms and comparing them against rskj for every new piece of functionality. Nothing exotic. My role was to step in four or five times a day, answer the model’s questions, ask it to continue, without stopping, weekends included. The code must go on.

The goal: snap sync, block pruning, header-only sync, merged mining, an immutable header database (the “freezer”), a trie garbage collector, a segmented database for fast re-execution, and every existing RPC method, including trace and debug. Twenty-two days uninterrupted, around 400 commits, roughly eighteen a day. This would have been impossible without AI. Not because humans cannot write this code, but because a team of five programmers working in parallel cannot easily coordinate the deep, cross-cutting changes these features require. Merging the various components alone would have taken weeks or months, between dependencies, interference and incompatibilities.

Where the node stands today: nine crates, around 120,000 lines of Rust, some two thousand tests, 58 RSKIPs implemented, and 112 written-up findings about where rskj’s behaviour and its specification part ways. All of that, including the documentation, architecture notes, JSON-RPC reference, compatibility analysis and a live status page for a node following mainnet, is at rustock.dev.

Below is what was implemented, what each component buys, and what each one is good for.

Rustock's architecture: how networking, synchronization, execution and storage work together.

 

Security hardening: the part nobody asks for

This is where most of the early time went, and it is the least glamorous item on the list. Rustock came with many vulnerabilities. The original agents that built its foundations paid no attention to defending the protocol against an adversary: messages were trusted, sizes were unbounded, a peer that sent garbage was sidelined for a few minutes and then welcomed back.

An external consensus audit gave us a concrete starting point. Of the five high-severity findings, four were real and all four were reachable on demand by an ordinary transaction, with no privileged sender, no reorg, and no unusual chain state: BASEFEE missing for about 1.1M mainnet blocks, two secp256k1 precompiles charging zero gas on a failed call, and two Bridge methods returning the wrong codes. Of the forty medium and low findings, thirteen were genuine divergences and the rest were cases where the RSKIP text had drifted from what rskj actually does.

That last point became the project’s acceptance criterion, and it is worth stating explicitly: Rustock’s target is agreement with deployed rskj mainnet, not agreement with the RSKIP specification. Where the two disagree, rskj wins. Changing Rustock to match the spec would introduce a fork. Half of the audit’s findings dissolve under that rule. The other half get sharper, because “the spec says so” stops being an argument.

 

Snap sync: joining the chain without replaying it

I started with my project goal: State snapshot synchronization. This is the continuation and enhancement of the work started by Julián Len, Patricio Gallardo, Iago Lluque and Javier Álvarez Cid-Fuentes. A node joining the network has two options: execute every transaction from genesis (correct by construction, but days of work), or download the state trie directly and check it against a state root it trusts for other reasons. Snap sync is the second.

What is given up is the re-execution of history, not the verification of it. The client walks the header chain back from the checkpoint a peer offers, verifying merged-mining proof of work and every consensus rule on each header, and only then downloads state under that header’s state_root, proving every chunk as it arrives. A peer that invents a checkpoint has to have mined it. Both sides are implemented, and both wire dialects: Rustock serves and reads its own chunk format, and serves and reads rskj’s.

The work produced three RSKIPs describing the protocol, a cross-client comparison (cross-client-snap-sync.md) and an implementation report (snapshot-sync.md):

  • Send referenced uncle headers alongside trunk headers
  • Snapshot sync wire messages
  • The snapshot sync protocol (informational)

 

Headers that carry their uncles

Snap sync work revealed that the Rootstock wire protocol was not optimized for header-only synchronization. A detail of Rootstock consensus turned into one of the more consequential pieces of work. In RSK, total difficulty advances by the trunk block’s difficulty plus the difficulty of the uncles it references. A node that downloads only headers therefore cannot compute the chain’s accumulated work: the uncle headers live inside bodies, which it does not have. That single fact blocks header-only sync, blocks an exact difficulty comparison during snap sync, and makes a pruned node unable to recompute its own chain’s weight.

The fix needed a new version of the wire protocol. RSK’s peer-to-peer subprotocol is negotiated in the RLPx handshake as a name/version pair, the way Ethereum nodes agree on eth/68; deployed rskj speaks rsk/62 and has for years. Rustock introduces rsk/63, which is 62 plus two backwards-compatible extensions. Advertising both at once is safe: rskj intersects a peer’s capabilities with its own and picks the highest in common, so an rskj node simply negotiates 62 and nothing breaks. A node that has agreed to 63, on the other hand, has agreed to read three new messages: BlockRangeUpdate (type 26), BlockHeadersWithUnclesRequest (27) and BlockHeadersWithUnclesResponse (28), the first three type codes rskj does not define, along with a fifth element in the status message.

The second and third of those are what solve the uncle problem: a request that asks for a run of headers and gets back each trunk header together with the uncle headers it commits to, so the receiver can recompute ommers_hash from the list it was sent and check it against the header before crediting a single unit of difficulty. That is now RSKIP-698, implemented on both the serving and the receiving side in Rustock, and it is what makes an exact total difficulty computable from headers alone.

 

The difficulty gate: deciding what a peer’s claim is worth

A peer announces a total difficulty in its status message, and nothing makes that figure true. Sync decisions are made from it, including who to follow and whose checkpoint to trust, so a peer that inflates it steers work toward itself, and one that inflates it enormously can hold a node’s attention indefinitely. The gate answers one question before spending anything on that peer: could any valid chain carry this much work?

It has two halves, and separating them is what makes it useful. Below the checkpoint height the work is already known, so refuting a claim is pure subtraction, with no requests and no cost. Above the checkpoint there is nothing to check against, and that is exactly where a lie would live, so the node samples the claimed range and bounds the work any chain could carry there: about 585 messages and 0.6 MB buys a verdict on a peer before a single byte of state is requested. The useful discipline was writing down which half carries the adversarial weight, because that is what tells you where the next hour of work belongs.

 

Header-first sync: ascending from genesis

Snap sync, as shipped, walks headers downward from the peer’s checkpoint until it reaches ground the node already holds. On a fresh node that is genesis, so the walk downloads every header anyway. Measured on mainnet, that walk is 92.4% of a snapshot sync: 6,971 s out of 7,545 s. What snap sync actually saves against a full sync is bodies and execution, not headers.

That observation suggested inverting the shape: walk up from genesis to the checkpoint first, verifying proof of work and every body-independent consensus rule, and only then ask for state. The wire has no forward request: BlockHeadersQuery is {hash, count} with no direction field, unlike eth’s GetBlockHeaders, so “ascending” still means fetching descending 192-header chunks and linking them by hash. What changes is which end is trusted.

On raw speed the two shapes come out within a few percent of each other on a single peer, which is what the design note predicted. The argument for ascending was never single-peer throughput. What it buys is elsewhere, and it is substantial: the chain’s total difficulty comes out exact rather than as a lower bound, the sync becomes resumable across restarts, peak memory drops from ~300–400 MB to 208 MB, and the difficulty gate stops being necessary at all, because there is nothing to pre-filter when the header pass is unconditional and the exact work falls out of it.

 

The freezer: an immutable header database

A header lives in RocksDB under its hash. Hashes are uniformly distributed, so 192 consecutive blocks live in 192 unrelated places, and 192 consecutive headers is exactly the unit a syncing peer asks for. Serving nine million headers means answering roughly 48,000 such requests, each one 192 independent point lookups, each reading a whole SST block (and often an index block above it) to return 1.1 KB. Measured: 7.1 KB of disk read to deliver 1.1 KB of header.

The first fix was to stop issuing those lookups serially. Running them through a 16-deep thread pool raised throughput from 934 KB/s to 5,600–7,100 KB/s (sevenfold) and left amplification exactly where it was, at ~6.5×. That is the whole design argument in one measurement: concurrency hides latency, it does not reduce the bytes read. Only the layout does.

So the freezer moves settled headers out of the B-tree into append-only files with a fixed-width ten-byte index, addressed by arithmetic rather than by lookup: a run of 192 consecutive headers becomes a run of adjacent bytes, one read. Headers are the right thing to attack this way: they are what a syncing peer asks for most, they are immutable once settled, and they are read in runs. The freeze depth is 20,000 blocks, one week at Rootstock’s measured 30.3-second block time, which is 20× the deepest reorg the canonical index will follow. On the production node at head #9,288,884 the freezer holds 10.1 GB of headers (1,085 bytes each, 99.78% of the chain) plus 10.5 GB of uncles.

The freezer archives the uncle headers alongside the trunk, in the same format, and that turned out to matter as much as the read win. Uncle headers normally live inside bodies, so a node that has pruned its bodies would have no record of the work its own chain absorbed. With a second copy in the freezer it can still recompute its total difficulty, still prove the uncles it absorbed existed, and still serve them to a peer. That is what makes header-only and heavily pruned nodes possible at all.

 

Block pruning

Deleting history is easy; knowing how much you are allowed to delete is not. Rootstock exposes precompiles that read attributes of blocks up to 4,000 back, so executing a block at height h may read from h − 4000. After a 4,000-block reorg the node re-executes from head − 4000, and executing that block may reach a further 4,000 back. The shallowest safe retention is therefore 8,000 blocks. That is a floor, not a default: both the CLI flag and the RPC are clamped up to it. A configuration asking for less is corrected rather than honoured, because the cost of being wrong is a node that cannot execute after a reorg and cannot say why.

Pruning leaves a hole in the middle of the chain, never a missing beginning: genesis is always retained, which costs one block and keeps every “start of the chain” lookup working. The floor is recorded in the database along with its cumulative difficulty. Once everything below is gone, that record is the only remaining anchor for the chain’s accumulated weight. It is written before the deletions it describes, so an interrupted sweep understates what is held rather than claiming blocks that are already gone; over-reporting is the dangerous direction, because it invites a reader to ask for something deleted.

Pruning also has to be announced, which is the other half of what rsk/63 adds. A node that has discarded its old blocks has no way to say so under rsk/62: peers keep asking it for history it no longer holds, it keeps answering with nothing, and from the outside that is indistinguishable from a node that is simply refusing to serve. So 63 puts the served range in the status message at connection time (earliest_block, latest_block and the hash of the latter) and adds the BlockRangeUpdate message, which a node sends when that range changes, so a long-lived peer learns about a sweep without reconnecting. The lower bound is exact and the upper bound is loose by construction: a node never holds blocks below earliest_block, while latest_block is a snapshot of a head that has already moved on. Rustock announces the narrowed range before it deletes anything, so a peer is never told about blocks that are still there and never asked for blocks that are already gone.

 

Trie garbage collector

A node that executes every block writes a new version of every trie node it touches. Nothing is updated in place, so the store accumulates every historical state while only a small part is reachable from the current head. On Rustock’s RSK mainnet database at block 9,230,008: 1.25 billion trie nodes, 128.9 GB on disk, and 916 MB reachable from the head’s state root. The live fraction is 0.7%. Roughly 99% of the trie store is dead history.

Reclaiming that without a reference count on every node, and without a deletion pass proportional to the number of dead entries, is what the epoch collector does: the store is a fixed-size ordered list of epoch databases, each holding a contiguous slice of write history, and collection copies the live set forward and drops the oldest epoch whole. Content addressing is what makes it work: an entry’s key is the hash of its value, so a node can be copied between databases without any referrer changing.

The payoff is not really the disk space, it is the density. Throughput against a trie store tracks how much of it is live, not how clever the traversal is: the same algorithm on the same machine runs at 4,092 keys/s against the 128.9 GB archival store at 0.87% density, ~71,000 keys/s against an epoch store at ~20%, and 118,893 keys/s against a fully dense 1.2 GB extract, a factor of 29 between the ends. A full traversal of the archival store takes 2,992 s for that reason alone. Keeping the store small therefore makes every pass over it cheap, which is what makes the collector self-sustaining: marking cost scales with the live set rather than with the accumulated history, and collection took 0.19% of total run time in the measured experiments while leaving warm random reads 1.91× faster than against a single growing database. The design was written as a specification for rskj rather than as a description of Rustock’s code, precisely so it can be carried back.

 

Segmented database for fast re-execution

Re-executing the chain to check that a change has not broken consensus backwards is a read-bound job, and the reads are the worst kind: a trie descent issues dependent random reads, so the device sees one outstanding request at a time. The store makes it worse, because content addressing scatters a block’s working set uniformly across 131 GB.

The measurement that drove the design: traversal runs at 4,092 keys/s against the archival store at 0.87% density, and 118,893 keys/s at 100% density, using the same code and machine, a factor of 29. Throughput tracks density, not the algorithm. A segmented store is the same data reordered by block number instead of by hash: each segment holds exactly the nodes some block range touches, so density inside a segment is ~100% and the working set stays resident. For block k the lookup routes to one segment, not a probe across all of them.

Building it turned out to be parallelizable in a way the original proposal did not assume. Because the imported rskj snapshot is archival from block 1,591,000, every block’s pre-state is already on disk, so workers can replay disjoint ranges simultaneously, each seeding from its range start’s parent state root and sealing its own segments. Verification is free, since from that height every header carries a Unitrie root to check against. Work is claimed rather than assigned: a worker takes the next unclaimed chunk when it finishes one, so an uneven split self-corrects and the pooled ETA stays meaningful. The format is shared with the rskj side through a separate repository, since it is meant to be consumed by both.

 

Supply conservation: rejecting blocks that create rBTC

This is an implementation of one of the defence-in-depth proposals. The RSK peg is backed 1:1 by bitcoin: the Bridge holds the entire supply, and a peg-in transfers value out of the Bridge’s balance rather than creating it. It follows that no block may increase the total of all account and contract balances. A node that enforces this catches any bug that mints rBTC, no matter where it lives or how it was reached.

For any scope (one transaction, or one block), sum the balance increases and subtract the balance decreases over every account the scope modified. Positive means currency was created: the block is rejected, before any of its state is persisted. Negative means currency was destroyed: the block is accepted and the amount logged, because destruction has legitimate causes, such as a contract self-destructing to itself or a fee burn, and burning cannot steal from anyone. Both scopes are checked and neither subsumes the other: block-level netting hides a creation whenever one transaction mints an amount and another destroys the same amount, while a block-level discrepancy against the sum of its transactions means value moved outside any transaction at all.

 

Merged mining and the long tail of a real node

Rustock builds RSK blocks, hands them to mining software through the mnr_* JSON-RPC namespace, and completes them from the Bitcoin solutions submitted back, so a second implementation can take part in securing the chain, not just follow it.

Several other pieces were not on the original list but took real time, and a node is not usable without them. Peer scoring and banning is a port of rskj’s co.rsk.scoring: before it, a peer that fed bad headers, stalled chunks or flooded the mempool was sidelined for a few minutes and then welcomed back, with no ban and no operator control. The trace_* and debug_* namespaces (what a block explorer shows as internal transactions) are built on a single inspector with two renderings, so the two namespaces cannot disagree about the same transaction. There is WebSocket transport with eth_subscribe, configurable eth_getLogs limits, and an alerting crate that mails an operator when the node stalls, falls behind or runs ahead, with the hard constraint that mail must never block block processing.

 

Help build Rustock

Rustock is an experimental prototype. It should not be used in production, it has not been audited in depth beyond the one audit described above, and it will break. That is the honest label on the box.

But the case for a second Rootstock node implementation does not depend on this one being finished. A network where 99% of nodes run the same code has a single point of failure, and no way to tell a consensus bug from consensus itself. The most useful results in this article came from the gap between two implementations: measurements, protocol details and design choices that are simply not visible from inside a single client. Writing a second node is also how the knowledge about Rootstock consensus gets extracted from one codebase and written down. The RSKIPs, comparison documents, and specifications in this repository exist because something had to be reimplemented from them.

So: run it, break it, and tell me how. Start at rustock.dev (the quickstart is three commands, and the status page shows a node following mainnet right now). Read the design documents and tell me where the argument is wrong. Several of them are proposals rather than descriptions, and the ones written as specifications are written that way so rskj can take them. File an issue, send a pull request, or port one of these components back to rskj, which is where most of them were always aimed. I post what the node is doing as it happens at @RustockDev.

The Rustock source code is available on GitHub.

Recommended articles

Satora Integrates with Rootstock, Expanding Access to Bitcoin-Based Finance

Satora Integrates with Rootstock, Expanding Access to Bitcoin-Based Finance

The Bitcoin ecosystem continues to grow, with new networks and applications giving holders more ways to use their BTC. Connecting these environments is an important part of making Bitcoin more useful. Satora has now integrated Rootstock, introducing swaps between rBTC on Rootstock and BTC across Bitcoin on-chain, the Lightning Network, and Arkade. The integration expands […]

Ecosystem Updates
Rootstock Bridge Security 101: Commonly Asked Questions

Rootstock Bridge Security 101: Commonly Asked Questions

Most losses in Bitcoin-adjacent systems do not start on Bitcoin itself. They start at the edges where the Bitcoin network is exposed to other systems. Whether it’s the bridge that moves BTC onto another network and back again, the vendors who handle customer records, the firmware that is supposed to generate unguessable keys or the […]

Fundamentals
Vetiver 9.0.2: Patch Release

Vetiver 9.0.2: Patch Release

This patch release fixes an issue reported by users after upgrading to Vetiver 9.0.0, which prevents some Testnet nodes from successfully completing a full blockchain synchronization, especially after a node restart. It also introduces minor performance and security improvements. Although this is not a mandatory release, Testnet nodes should be updated to this version to […]

Ecosystem Updates