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
| Minimum | Comfortable | |
|---|---|---|
| CPU | 4 cores | 8+ cores — execution and the sync pipeline are both parallel |
| RAM | 8 GB | 16 GB — the trie write cache and RocksDB block cache both benefit |
| Disk | NVMe SSD | NVMe SSD. A full archival store is ~130 GB and grows. |
| Network | any | Sync 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-id | Notes |
|---|---|---|
| RSK Mainnet | 30 | Default. Merged-mined with Bitcoin mainnet. |
| RSK Testnet | 31 | Separate genesis, separate activation heights. |
| Regtest | any other | Local 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
| Flag | Default | Description |
|---|---|---|
--port | 30303 | P2P listen port (TCP sessions and UDP discovery) |
--data-dir | ./data | Data directory for RocksDB and logs |
--network-id | 30 | 30 mainnet, 31 testnet, anything else regtest |
--secret-key | auto-generated | Hex-encoded secp256k1 private key for the node identity |
--log-level | info | trace…error, or RUST_LOG-style directives |
--log-to-stdout | false | Log to the console instead of a rotating file |
--rpc-port | 4444 | JSON-RPC HTTP port |
--rpc-host | 127.0.0.1 | JSON-RPC bind address |
--no-rpc | false | Disable the JSON-RPC server entirely |
--external-ip | none | External IP advertised in discovery |
--import-rskj | none | Convert a synced rskj database — see below |
--metadata-in-memory | false | Rebuild chain metadata in memory at start-up |
--alerts-config | none | Path to an alerts TOML file — see below |
--rpc-admin | false | Enable the administrative rsk_ methods |
--dev-rpc | false | Enable the evm_ development namespace |
--trie-tool | none | Trie 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:
| rskj | rustock | |
|---|---|---|
| Container | One RocksDB per datasource directory (blocks/, unitrie/, receipts/) | One RocksDB, 7 column families |
| Blocks | hash → RLP(header, txs, uncles) in a single value | Split across headers and block_bodies |
| Index | A MapDB file of Java-serialised BlockInfo | block_numbers + total_difficulty |
| State | unitrie/: hash → node message | trie_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
| Alarm | Fires when |
|---|---|
| stalled | The executed head has not moved for for_secs |
| behind | The best peer is more than block_gap blocks ahead, for for_secs |
| ahead | This 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
| Watched | Default threshold | Source |
|---|---|---|
| A single peg-out request | > 100 BTC | release_requested log |
| A single output awaiting signature | > 100 BTC | pegoutsWaitingForSignatures |
| Total value in transit | > 200 BTC | pegoutsWaitingForConfirmations |
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.locka compromised maintainer cannot alter a version already depended on — only publish a new one, which the lockfile will not pick up until someone deliberately runscargo 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.tomlalongside 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.