Documentation

Everything needed to build a Rustock node, put it on RSK mainnet and keep it there.

Requirements

Rustock builds with Rust stable (edition 2021) and links RocksDB, which is compiled from vendored C++ sources. That makes a working C/C++ toolchain and libclang hard requirements: librocksdb-sys compiles C++, and zstd-sys generates its bindings with bindgen, which needs libclang.so and clang's builtin headers at build time.

# Debian / Ubuntu
sudo apt install build-essential clang libclang-dev

# Fedora / RHEL
sudo dnf install gcc gcc-c++ clang-devel

# macOS — ships with the Xcode command line tools
xcode-select --install

If bindgen still reports Unable to find libclang, point it at the library explicitly: export LIBCLANG_PATH=/usr/lib/llvm-21/lib.

Hardware

MinimumComfortable
CPU4 cores8+ cores — execution and the sync pipeline are both parallel
RAM8 GB16 GB — the trie write cache and RocksDB block cache both benefit
DiskNVMe SSDNVMe SSD. A full archival store is ~130 GB and grows.
NetworkanySync is bound by execution and dependent random reads, not bandwidth

Spinning disks and network-attached storage are the wrong shape for this workload: a trie descent issues dependent random reads, so the device only ever sees one outstanding request at a time. See the measurements.

Quickstart

git clone https://github.com/SergioDemianLerner/rustock
cd rustock
cargo build --workspace --release

Then start the node. RSK mainnet (network id 30) is the default:

cargo run -p rustock-cli --release -- \
  --port 30303 \
  --data-dir ./data \
  --log-to-stdout

Logs are written to <data-dir>/rustock.log with daily rotation unless --log-to-stdout is given. The node starts discovery, fills its peer table, finds a connection point, and begins the skeleton sync described in Architecture.

Syncing from genesis takes days, because every block is executed. There are two faster routes. Snapshot sync downloads the state at a recent checkpoint instead, verifying the header chain to it under the same proof-of-work rules and proving every chunk against that chain's state root. If you already run rskj, the import path converts its database directly.

Talking to it

The JSON-RPC server binds 127.0.0.1:4444 by default:

curl -s -X POST -H 'content-type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
  http://127.0.0.1:4444/
{"id":1,"jsonrpc":"2.0","result":"0x8d1804"}

The full method list is on the JSON-RPC page. Batched requests work, and so do the standard web3 libraries pointed at the same endpoint.

Running a node

Networks

Network--network-idNotes
RSK Mainnet30Default. Merged-mined with Bitcoin mainnet.
RSK Testnet31Separate genesis, separate activation heights.
Regtestany otherLocal development chain.

Identity and reachability

The node key is generated on first start and persisted in the data directory; pass --secret-key to pin a specific hex-encoded secp256k1 key so your node id stays stable across data-directory resets. Behind NAT, advertise your reachable address with --external-ip 203.0.113.42, and forward TCP and UDP on the P2P port — discovery is UDP, sessions are TCP.

Exposing RPC

Do not expose the RPC port to the internet. Rustock's JSON-RPC has no authentication, no rate limiting and no CORS policy of its own — it is meant to sit behind something that does. Bind it to loopback (the default) and put a reverse proxy in front if it has to be reachable, or disable it entirely with --no-rpc.

As a systemd service

# /etc/systemd/system/rustock.service
[Unit]
Description=Rustock — Rootstock node
After=network-online.target

[Service]
User=rustock
ExecStart=/usr/local/bin/rustock --data-dir /var/lib/rustock --log-to-stdout
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

RocksDB opens a lot of files; raising LimitNOFILE is not optional on a full archival store.

CLI reference

FlagDefaultDescription
--port30303P2P listen port (TCP sessions and UDP discovery)
--data-dir./dataData directory for RocksDB and logs
--network-id3030 mainnet, 31 testnet, anything else regtest
--secret-keyauto-generatedHex-encoded secp256k1 private key for the node identity
--log-levelinfotrace…error, or RUST_LOG-style directives
--log-to-stdoutfalseLog to the console instead of a rotating file
--rpc-port4444JSON-RPC HTTP port
--rpc-host127.0.0.1JSON-RPC bind address
--no-rpcfalseDisable the JSON-RPC server entirely
--external-ipnoneExternal IP advertised in discovery
--import-rskjnoneConvert a synced rskj database — see below
--metadata-in-memoryfalseRebuild chain metadata in memory at start-up
--alerts-confignonePath to an alerts TOML file — see below
--rpc-adminfalseEnable the administrative rsk_ methods
--dev-rpcfalseEnable the evm_ development namespace
--trie-toolnoneTrie statistics, snapshot and node lookup — see below

Importing from rskj

A synced rskj database can be converted instead of syncing from peers. On RSK mainnet at block 9,230,008 this takes about an hour, against days of syncing.

rustock --import-rskj /path/to/database/mainnet --data-dir ./data
rustock --metadata-in-memory --data-dir ./data

It is a conversion, not a copy, because the two nodes store the same chain in structurally different ways:

rskjrustock
ContainerOne RocksDB per datasource directory (blocks/, unitrie/, receipts/)One RocksDB, 7 column families
Blockshash → RLP(header, txs, uncles) in a single valueSplit across headers and block_bodies
IndexA MapDB file of Java-serialised BlockInfoblock_numbers + total_difficulty
Stateunitrie/: hash → node messagetrie_nodes, byte-identical values

RocksDB cannot adopt another database's directory as a column family, and the block index is a Java-serialised MapDB file rather than a key-value store — so the import reads rskj's structures and rewrites them. Trie node values are byte-identical, which is both a convenience and a continuously-checked proof that the Unitrie encoding matches. Full notes, options and measured performance →

tools/dump-index is a companion Java tool that reads rskj's MapDB index directly. It exists as an independent cross-check on the importer: two different readers of the same file that must agree.

Alerts

Rustock can watch two things and email about them: movements across the two-way peg, and the health of the node itself. Both are off unless configured:

rustock --alerts-config ./alerts.toml --data-dir ./data

One file, three sections. The transport is a sibling of the alarms rather than a child of one of them, which is what makes it shared rather than borrowed:

# alerts.toml
[alerts.email]        # the transport, shared by everything below
[alerts.pegout]       # peg-out thresholds
[alerts.node_health]  # stalled / behind / ahead

Node health

AlarmFires when
stalledThe executed head has not moved for for_secs
behindThe best peer is more than block_gap blocks ahead, for for_secs
aheadThis node is more than block_gap blocks ahead of every peer, for for_secs

Being ahead of everyone is an alarm rather than a boast: on a chain this node follows several tip forks an hour, it usually means the node is building on a branch the network has abandoned. behind stays disarmed until the node has been in sync at least once, so a node that is still doing its initial sync does not alarm for the days that legitimately takes.

Peg-out

WatchedDefault thresholdSource
A single peg-out request> 100 BTCrelease_requested log
A single output awaiting signature> 100 BTCpegoutsWaitingForSignatures
Total value in transit> 200 BTCpegoutsWaitingForConfirmations

Every peg-out event is logged regardless of threshold — release_request_received, release_requested, release_btc, pegout_confirmed, pegout_transaction_created — at info on target rustock::alerts::pegout, with node health on rustock::alerts::health. A commented example ships as alerts.example.toml.

The trie tool

Three things live behind the --trie-tool* flags: a statistics scan over the live state, a way to extract one state root into a standalone database, and a lookup for a single node. They share one source argument, so anything the scan reports can be handed straight back to the lookup.

Scanning RSK mainnet's live state takes about 48 minutes, and almost none of that is the state itself — 12.2M nodes, under a gigabyte. It is the shape of the store they sit in: 1.25 billion entries across 129 GB, of which the live set is 0.87%. Consecutive live nodes are ~115 stored entries apart, so every read is a random one. Snapshotting those nodes into a database that holds nothing else makes a rescan minutes rather than an hour, and makes it immutable — successive scans against the live store are not comparable, because it moves as the node syncs. Trie tool documentation →

Testing

cargo test --workspace

2,475 tests across 139 files cover consensus validation, RLP encoding, P2P handshakes, sync state machines, storage, the Unitrie, EVM execution, every RSK precompile including the full Bridge surface and REMASC, the transaction pool, RPC methods, transaction relay and chain reorganizations.

The project also publishes an honest accounting of where that suite is thin relative to rskj's 6,810 tests, generated by scanning both repositories mechanically. It is worth reading before trusting any particular subsystem — see the coverage comparison.

Supply chain

Dependency checks run in CI on every push and pull request, and on a daily schedule so that advisories published against already-pinned dependencies are caught even when nobody touches the code. The same checks run locally:

cargo install cargo-deny --locked
cargo deny check      # advisories, bans, licenses, sources

The policy in deny.toml enforces:

  • crates.io only. Unknown registries and git dependencies are rejected. Published crates.io versions are immutable, so together with the checksums in Cargo.lock a compromised maintainer cannot alter a version already depended on — only publish a new one, which the lockfile will not pick up until someone deliberately runs cargo update.
  • No yanked crates. A yank usually signals a withdrawn or compromised release.
  • No wildcard version requirements, which would defeat pinning.
  • Known advisories, with every exception recorded in deny.toml alongside the reasoning for it.

Cargo.lock is committed and must stay that way: it is the primary supply chain control. CI verifies it is current (cargo metadata --locked) and that every non-workspace dependency carries a checksum. Treat lockfile diffs as security-relevant during review — a malicious dependency arrives through Cargo.lock, not through the Rust source.

Troubleshooting

Build fails with Unable to find libclang

bindgen cannot locate the clang shared library. Install libclang-dev (or clang-devel) and, if that is not enough, set LIBCLANG_PATH to the directory containing libclang.so.

No peers

Discovery is UDP and sessions are TCP, both on --port. If only TCP is forwarded the node will accept inbound sessions but never populate its table. Behind NAT, set --external-ip — without it the node advertises an address nobody can dial.

Sync stalls at a specific block

That is the interesting failure, and the one the compatibility catalogue exists for: a state-root mismatch at a fixed height means a consensus rule is being applied differently, not that the network is slow. Run with --log-level debug, note the block number, and open an issue with it — every entry in the catalogue started as exactly that report.

The database is enormous

Expected by default: the trie store keeps every node it ever writes, and the live set is under 1% of it. Two collectors now address that — an epoch garbage collector that marks from one deep root and sweeps whole epochs of trie state, and block pruning that drops headers, bodies, receipts and transaction-index entries below a retention floor. Both are opt-in: a node left alone still keeps everything, which is what makes it an archive node.