Warning
Rustock is a prototype implemented in an AI-assisted manner. It should never be used in production. The code has not been reviewed in depth and has not been security audited. It may — and will — break and contain bugs.
Use at your own risk.
A Rootstock (RSK) full node implementation in Rust. Rustock syncs and validates blocks from the RSK network using Bitcoin merged mining proofs, executes every transaction against an RSK-compatible Unitrie world state, follows the chain tip in real time, and exposes an rskj-compatible JSON-RPC interface with full state, call, and log support.
Coming from rskj? docs/rskj-to-rustock-map.md maps the two codebases package by package — the rskj class you already know on one side, the rustock file that answers to it on the other — and lists what each side has that the other does not.
- Full block sync: Skeleton-based bulk header sync with parallel chunk downloads across multiple peers, followed by block body downloads and real-time tip following via
NewBlockHashes. - EVM execution: REVM-based block execution pipeline that reproduces RSK semantics — transaction validation, gas accounting, state transitions, receipt generation, and per-block REMASC reward distribution.
- Unitrie world state: A from-scratch implementation of RSK's Unitrie (radix-2 binary trie with shared paths and embedded values), including
eth_getProof-friendly node access, key mapping for accounts/code/storage, and RocksDB-backed persistence with a write cache. - All RSK precompiles: secp256k1 recovery, block header introspection, HD wallet utilities, the BTC–RSK Bridge (two-way peg, federation management, BTC SPV header chain, governance), and REMASC.
- Consensus validation: Full verification of Bitcoin merged mining (AuxPow) proofs, difficulty adjustment, gas limits, timestamps, and all RSK consensus rules including activation-height-gated RSKIPs.
- Chain reorganization: Detects competing forks, compares total difficulty, and rewrites canonical chain pointers when a heavier fork is found.
- Transaction pool: Validates pending transactions against the live state (nonce, balance, intrinsic gas, chain ID), tracks pending nonces per sender, and exposes status to the RPC layer.
- P2P networking: Full RLPx encryption (inbound and outbound), Kademlia-based UDP discovery, peer exchange, and persistent node tables.
- Transaction relay: Receives transaction messages from peers, validates and admits them to the pool, and rebroadcasts to all other connected peers.
- Serving peers: Responds to
BlockHeadersRequest,BlockHashRequest,SkeletonRequestandBodyRequest, and — for peers that negotiated them — the six snapshot messages and thersk/63extensions. - JSON-RPC server: rskj-compatible HTTP API with
eth,net,web3,rpc, andrskmodules — including state queries,eth_call/eth_estimateGas, transaction and receipt lookups, log filtering, and persistent filters. - Snapshot sync: Joins the chain by downloading a recent state trie instead of executing from genesis — as a client and as a server. The header chain to the checkpoint is verified first under the same proof-of-work rules as a full sync, and every chunk of state is checked against that chain's state root. Interoperates with rskj in both directions (docs/cross-client-snap-sync.md).
- Header freezer: Settled headers, and the uncle headers they reference, move out of RocksDB into append-only files with a fixed-width index, so a run of consecutive blocks is a run of adjacent bytes (docs/freezer.md).
- Block pruning: Deletes history below a configurable depth, automatically or on request, clamped to a floor the block-info precompiles and reorg re-execution require (docs/block-pruning.md).
- Protocol extensions (
rsk/63): Announces the range of blocks this node can serve, and can carry the uncle headers a trunk header references — which is what lets a node compute cumulative work from a header walk, since in RSK uncle difficulty counts toward total difficulty. - Storage: RocksDB-backed persistence for headers, bodies, receipts, total difficulty, canonical chain mappings, Bridge events, and Unitrie nodes.
-
Rust (edition 2021)
-
RocksDB system libraries (usually handled automatically by
rust-rocksdb) -
A C/C++ toolchain plus
libclang—librocksdb-syscompiles vendored C++, andzstd-sysgenerates its bindings withbindgen, which needslibclang.soand 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
bindgenstill reportsUnable to find libclang, point it at the library explicitly, e.g.export LIBCLANG_PATH=/usr/lib/llvm-21/lib.
cargo build --workspace --releaseThe Dockerfile builds rustock-cli on Ubuntu 22.04 (glibc 2.35) with the
toolchain rust-toolchain.toml pins, for linux/amd64 and linux/arm64:
docker build -t rustock .
docker run --rm rustock rustock-cli --helpThe image has no entrypoint; the binary is /usr/local/bin/rustock-cli.
Start the full node on RSK Mainnet (default):
cargo run -p rustock-cli --release -- --port 30303 --data-dir ./data --log-to-stdoutLogs are written to <data-dir>/rustock.log by default (with daily rotation). Use --log-to-stdout for console output.
| Flag | Default | Description |
|---|---|---|
--port |
30303 |
P2P listen port |
--data-dir |
./data |
Data directory for RocksDB and logs |
--network-id |
30 |
Network ID (30 = mainnet, 31 = testnet, anything else = regtest) |
--secret-key |
auto-generated | Hex-encoded secp256k1 private key |
--log-level |
info |
trace, debug, info, warn, error, or RUST_LOG-style directives |
--log-to-stdout |
false |
Log to console instead of 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 |
--external-ip |
none | External IP to advertise in discovery (e.g. 203.0.113.42) |
Commonly used beyond the basics:
| Flag | Default | Description |
|---|---|---|
--snap-sync |
false |
Join by downloading a recent state instead of executing from genesis |
--snap-server |
false |
Serve snapshots of this node's state to peers that ask |
--prune-blocks |
false |
Delete block history below --prune-keep-depth as the node runs |
--prune-keep-depth |
100000 |
Blocks of history to keep; clamped up to 8,000 |
--freezer |
true |
false keeps settled headers in RocksDB instead of the freezer |
--read-only |
false |
Open both databases read-only and write nothing |
--follow-up |
true |
false serves what is held and does not sync, execute or follow |
--mine |
false |
Build blocks and serve the mnr_* merged-mining namespace |
--rpc-admin |
false |
Enable the administrative RPC methods |
--bootnodes |
network default | Peers to dial; with --closed-network, the only ones |
That is a selection. The node has ~96 flags, every one of them documented with
its default in node.example.toml, which is the reference
rather than this table. node-light.example.toml is
a smaller starting point, and alerts.example.toml
configures peg-out and node-health alerting.
cargo test --workspace786 tests covering 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.
A synced rskj database can be imported instead of syncing from peers — about an hour against days. The two schemas differ structurally (rskj uses one RocksDB per datasource plus a MapDB index; rustock uses one RocksDB with column families), so this converts rather than copies:
rustock --import-rskj /path/to/database/mainnet --data-dir ./data
rustock --metadata-in-memory --data-dir ./dataSee docs/rskj-import.md for the schema comparison, all
options, and measured performance. tools/dump-index is a
companion Java tool for reading rskj's MapDB index directly, used as an
independent cross-check.
rustock's regtest is its own chain: its own genesis, no Bridge federation, and gas limits of its own. To run the chain an rskj regtest node runs, so that blocks rskj mined connect here, start from rskj's genesis file and its rules:
rustock --network-id 33 \
--genesis genesis.json \
--consensus-rule rskip97=0 \
--min-gas-limit 3000000 --max-gas-limit 18446744073709551615 \
--skip-pow-validation--genesis takes an rskj genesis JSON file and gives the genesis rskj gives
it, hash included, along with rskj's regtest federation. --consensus-rule is
rskj's blockchain.config.consensusRules (today rskip97), and
--skip-pow-validation is its miner.server.skipPowValidation, for chains
mined without real merged mining. --hardfork name=height is rskj's
blockchain.config.hardforkActivationHeights, by rskj's names. The same
settings go in a [chain] section of the --config file. All of them are
refused on mainnet and testnet.
--allow-chain-overrides lifts that refusal for everything but --genesis,
for a copy of mainnet or testnet run under rules the chain has not activated
(blocks mined on a snapshot of mainnet's state with reed810 scheduled, say):
rustock --network-id 30 --allow-chain-overrides \
--hardfork reed810=9002504 --hardfork tbd1000=9002504 \
--consensus-rule rskip351=9002504 --consensus-rule rskip535=9002504 \
--skip-pow-validation ...The overrides reach everything that reads the schedule: header decoding and
hashing, difficulty, execution, REMASC and the Bridge. Names the node reads at
one fixed height per chain (bahamas, afterBridgeSync, hop401) are
accepted at that height only; orchid060 and tbd1000 are accepted and
change nothing, and the node says so (tbd1000 only turns on RSKIP-559,
pegout sorting in the Bridge, which it does not implement). A node started
this way must not follow the real network.
Blocks rskj exported with its ExportBlocks tool (number,hash,td,rlp per
line, plain or gzipped) can then be imported from an empty data directory:
rustock --network-id 33 --genesis genesis.json ... \
--trie-backend external --trie-dir ./trie --data-dir ./data \
--import-blocks blocks.txt --import-flush-every 10Each block goes through the validation and execution follow mode uses, and
its state root, receipts root, logs bloom, gas used and paid fees are checked
against its header. The import stops at the first block that fails, naming
its number, hash and the check, and exits non-zero. The trie is flushed every
--import-flush-every blocks (10, rskj's blockchain.flushNumberOfBlocks
default).
--breakdown-log <file> adds one line per block in the shape of rskj's
block connect breakdown best: debug line, so the two clients' timings can be
compared phase by phase:
block connect breakdown best: block=15 hash=c06f… totalMs=41.207 preambleMs=0.002
initialValidationMs=0.311 executeMs=38.950 postExecuteValidationMs=0.004
switchChainMs=0.402 saveReceiptsMs=0.517 onBestBlockMs=1.021 txCount=21 gasUsed=…
(one line in the file). totalMs covers the whole import of the block, the
periodic flush included, as rskj's covers its onBestBlock flush. Without the
flag no clock is read.
Supply chain 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. To run the same checks locally:
cargo install cargo-deny --locked
cargo deny check # advisories, bans, licenses, sourcesThe policy lives in deny.toml. What it 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 any 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, and 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.
Prefer [workspace.dependencies] when adding a dependency used by more than one
crate, so two crates cannot silently pin different versions of it.
The RPC server is compatible with rskj's JSON-RPC 2.0 interface. Supported methods:
Chain and node info:
eth_blockNumber,eth_chainId,eth_syncing,eth_protocolVersioneth_gasPrice(a percentile over recent transactions, not the block minimum — see docs/gas-price.md),eth_mining,eth_hashrate,eth_accounts,eth_coinbaseeth_getBlockByHash,eth_getBlockByNumbereth_getBlockTransactionCountByHash,eth_getBlockTransactionCountByNumbereth_getUncleCountByBlockHash,eth_getUncleCountByBlockNumbereth_getUncleByBlockHashAndIndex,eth_getUncleByBlockNumberAndIndexnet_version,net_peerCount,net_listening,net_peerListweb3_clientVersion,web3_sha3rpc_modulesrsk_protocolVersion,rsk_getRawBlockHeaderByHash,rsk_getRawBlockHeaderByNumber
State queries (served from the local Unitrie at any historical block):
eth_getBalance,eth_getTransactionCount,eth_getCode,eth_getStorageAt
Execution:
eth_call,eth_estimateGas(run against a forked state with full precompile and Bridge support)
Transactions and receipts:
eth_sendRawTransaction(validates against the pool and broadcasts to peers)eth_getTransactionByHash,eth_getTransactionByBlockHashAndIndex,eth_getTransactionByBlockNumberAndIndexeth_getTransactionReceipt
Logs and filters:
eth_getLogseth_newFilter,eth_newBlockFilter,eth_newPendingTransactionFiltereth_getFilterChanges,eth_getFilterLogs,eth_uninstallFilter
Pool:
-
txpool_status,txpool_content,txpool_inspectThese follow rskj's
TxPoolModuleImpl, not go-ethereum's txpool namespace: sender keys are bare hex with no0x, each nonce maps to an array of transactions, andtxpool_statuscounts are JSON numbers rather than hex strings. See docs/rskj-vs-geth.md.
Debug:
-
debug_wireProtocolQueueSize,debug_accountTransactionQuota -
debug_traceTransaction,debug_traceBlockByHash,debug_traceBlockByNumber(within the state-retention window)rskj's
debug_*, not go-ethereum's — the two namespaces barely overlap. See docs/debug-namespace.md.
Trace:
-
trace_transaction,trace_block,trace_get,trace_filter(within the state-retention window)Call trees, in rskj's shape rather than OpenEthereum's — the differences are not cosmetic, and precompile calls (the Bridge included) never appear. See docs/trace-namespace.md.
Peer scoring:
-
sco_banAddress,sco_unbanAddress,sco_bannedAddresses,sco_peerList,sco_clearPeerScoring,sco_reputationSummary, plussco_isWelcomeA port of rskj's
co.rsk.scoring. Bans persist across restarts, which rskj's do not. See docs/peer-scoring.md.
WebSocket + subscriptions:
eth_subscribe/eth_unsubscribeover a WebSocket on its own port (--ws, default port 4445; off by default, as rskj's is).newHeads,logs(withremovedon a reorg) andnewPendingTransactions. Every HTTP method works over the socket too. See docs/websocket-subscriptions.md.
Unsupported (returns error): mining (eth_sendTransaction, eth_sign, compilers), and the personal_*, evm_*, db_* namespaces.
crates/
cli/ Main entry point, CLI argument parsing, genesis bootstrap
core/ Base types (Header, Block, Transaction, Receipt), consensus rules, chain config
trie/ RSK Unitrie: nodes, paths, account encoding, key mapping
execution/ EVM executor, block processor, all RSK precompiles, BTC–RSK Bridge, REMASC
storage/ RocksDB persistence for headers, bodies, receipts, and trie nodes (with write cache)
networking/ P2P protocol (RLPx, discovery, sessions, peer management)
sync/ Sync state machine, header + body pipeline, transaction pool, transaction relay
rpc/ JSON-RPC HTTP server (axum-based) with state, call, log, and filter support
alerts/ Peg-out and node-health alerting; an observer with no hook in execution or sync
crates/cli/examples/ holds sixty small diagnostic and benchmark programs --
"what did this block write", "where is RSKIP-144 actually used", "did a PowPeg
functionary ever sign badly". They are cargo examples rather than node
subcommands so they stay out of the binary's argument surface.
docs/tools.md indexes all of them, and says which ones write.
Log timestamps are UTC by default and RFC 3339 with one subsecond digit, so the offset travels with every line rather than being something a reader has to know.
--log-timezone utc # default: 2026-09-25T21:13:45.9+00:00
--log-timezone -03:00 # 2026-09-25T18:13:45.9-03:00
--log-timezone local # read from the machine, once, at start-up
or timezone under [log] in the config file. The offset is also stated once
at start-up (Log timestamps are GMT-03:00), so a log opened later says what
it is in words as well as in every timestamp.
local is resolved once, before the runtime starts: the time crate will
not determine a local offset in a multithreaded process, because another
thread changing TZ concurrently is a data race. A consequence is that
local does not follow a daylight-saving transition mid-run. A fixed offset
avoids the question, and is exactly right for zones without DST.
Note this is rustock's own timestamp. Under systemd, journalctl prints its
own timestamp first, in the viewer's timezone — TZ=America/Argentina/Buenos_Aires journalctl -u rustock
shifts that one, with no node configuration at all.
Rustock executes blocks and maintains full state, but it is not yet feature-complete relative to rskj. Notable gaps:
- Mining is single-node only. Rustock builds blocks, serves the
mnr_*merged-mining namespace and imports solutions (--mine, see docs/merged-mining.md), but it has no outbound block announcement, so a mined block reaches peers only when they ask for it. - No archive mode. The trie store keeps every node it writes, so historical state is queryable as long as the underlying nodes have not been collected, but there is no explicit archive-vs-pruning policy.
- Bridge methods are complete but unevenly exercised. All 70 methods in the Bridge table are dispatched, transaction-callable and local-only alike. The transaction-callable ones are proven by whole-chain replay against mainnet; the local-only getters are proven only by unit tests, because mainnet history does not call them.
- Tracing needs recent state.
debug_trace*andtrace_*re-execute a transaction's block from its parent's state, so they answer only within the GC burial depth (4,000 blocks by default) and return an error past it.trace_filterrecomputes rather than reading an index, as rskj does. - Peer scoring credits blocks on execution, not on arrival. A body is
checked against its header the moment it arrives — the only moment the
sender is unambiguous — and a mismatch is charged as
INVALID_BLOCKand re-fetched from somebody else.VALID_BLOCKwaits until the block executes to the state root its header claims, which is stricter than rskj, whose credit comes from validation. See docs/peer-scoring.md. - No wallet / account management.
eth_sendTransaction,eth_sign, and thepersonal_*namespace are intentionally not supported — sign transactions externally and submit them viaeth_sendRawTransaction.
Licensed under the MIT License.