Why this exists
A second implementation of a live chain does not get to be correct. It has to be identical. Every block ever mined is a test vector, and a client that disagrees with any one of them cannot follow the chain at all — it does not produce a slightly different answer, it stops.
The gap between "correct" and "identical" is where this catalogue lives. It has four parts, split by the nature of each finding, because the four demand entirely different things from a client author.
JVM & library artifacts 9
Behaviour that is not visible in rskj's source at all, because it lives inside the JDK, bitcoinj or Guava. You can read every line of rskj and still fork.
Frozen bugs 11
Visible in the source, clearly unintended, and load-bearing anyway. Some later corrected by an RSKIP — so a client needs both behaviours, gated by height.
Deliberate divergences 18
Where RSK intentionally departs from canonical Ethereum. Implementable from the RSKIPs, if you know to look for them.
Wire protocol 7
Framing and sync messages. Getting these wrong stalls sync instead of forking state — a much kinder failure, and much harder to diagnose.
Plus 62 RSKIP feature slices in the consensus port log: behaviour an RSKIP actually specifies, where a from-scratch client reading the document would arrive at the same implementation — and 14 places where the RSKIP and the code say different things, which is a fifth and nastier category, because there the document is not silent. It is wrong.
How they are found
Not by reading. By replaying.
Rustock executes mainnet from genesis and compares its recomputed state root against the header at
every block. A mismatch names the exact block where two implementations first disagreed, and from
there the search is bounded: one block, a known set of transactions, and two clients that can both be
made to print what they did. The ground truth is usually rskj itself, dumping its own state
(co.rsk.cli.tools.ExportState) against a synced database.
That is why nearly every entry carries a block number. It is not decoration — it is the entry's provenance, and the regression test that pins it.
The diagnostic that makes early history visible. Pre-RSKIP126
state roots are not validated during normal sync, so a divergence in the first 1.59M blocks stays
invisible until the first Unitrie state-root check at wasabi100 (#1,591,000) — by which point the
cause is a million blocks behind you. A per-block Orchid-format converter check
(RUSTOCK_ORCHID_CHECK_INTERVAL) catches those at their origin instead.
JVM and library artifacts
The most dangerous category. These are consensus rules that exist nowhere in rskj's source, because they are properties of the language it is written in.
Java's BigInteger sign byte
Java's BigInteger is two's-complement, and toByteArray() prepends a
0x00 byte to any positive value whose most significant bit is set, to preserve the sign.
When rskj RLP-encodes an integer through that path, the leading zero goes on the wire and into the
hash. Canonical RLP forbids leading zeros. A client that encodes canonically computes a different
header hash for the affected blocks — and therefore a different chain.
Keccak256.compareTo reads backwards
The peg-outs-waiting-for-signatures map is a Java SortedMap<Keccak256, BtcTransaction>,
serialized in iteration order. rskj's Keccak256.compareTo compares the 32 bytes unsigned
from index 31 down to index 0 — reverse order:
// co.rsk.crypto.Keccak256
public int compareTo(Keccak256 o) {
for (int i = HASH_LEN - 1; i >= 0; i--) {
final int thisByte = this.bytes[i] & 0xff;
final int otherByte = o.bytes[i] & 0xff;
if (thisByte > otherByte) return 1;
if (thisByte < otherByte) return -1;
}
return 0;
}
Two hashes that order one way big-endian can order the other way here. A client holding that map in
any forward-ordered structure — a BTreeMap<[u8; 32], _>, or "I'll just sort the keys" —
produces a different RLP.encodeList, a different storage cell and a different state root,
from identical entries in a swapped position, the moment two peg-outs wait at once.
HashSet iteration order selects peg-outs
Confirmed-peg-out selection follows Java HashSet iteration order: not insertion order,
not sorted, but the order entries happen to sit in the hash table's buckets — a function of each key's
hashCode(), the table capacity and the JDK's resize algorithm. Reproducing it means
reproducing Java 8's bucketing, in Rust, exactly.
And the rest
- Bridge UTXO and lock-whitelist serialization order — including a
TreeMapthat was mistaken for aHashMapbecause a two-entry ground truth at #3,304 happened to match both orderings. The correction is kept in the catalogue, because being wrong in a way the data could not distinguish is itself the lesson. Sha256Hash.toString()does not reverse — while almost everything else in bitcoinj does. The coinbase-information storage key depends on which convention applies.- Election spec ordering uses signed byte comparison of
getEncoded(), so any byte ≥ 0x80 sorts below every byte < 0x80. - ethereumj's ABI pads an empty
bytesto a full zero word, which canonical ABI encoding does not. BlockHeaderContract.getGasLimitreturns raw header bytes rather than a decoded integer.
Frozen bugs
Visible in rskj's source, clearly never a design decision — and permanent, because mainnet history depends on them.
A zero-value REMASC payment creates the recipient
RemascFeesPayer.transferPayment pays a miner with two addBalance calls, and
MutableRepository.addBalance always calls getAccountStateOrCreateNew. So a
payment of zero still creates the recipient account — and under RSK's frontier rules that empty
account (nonce 0, balance 0, no code, no marker) persists in the Unitrie forever. There is no zero-guard.
Rustock originally short-circuited on a zero amount and returned without touching anything, which is the obviously correct thing to do. Ground truth: two beneficiaries — among them the miner of block #142,347 — exist as empty accounts in rskj and were missing from Rustock. The fix is to load and touch the recipient so REVM materialises the empty account exactly as rskj does.
REMASC's siblings cell
RemascStorageProvider.saveSiblings() runs on every REMASC save with no guard, writing a
single 0xc0 byte. The cell was created at block #1, has never changed value, and has been
part of the state root ever since. It corresponds to no EVM operation, which is why Rustock carries a
raw-storage overlay alongside REVM's journal — an entire
mechanism that exists for writes the EVM cannot express.
A throwing precompile reports success
When a transaction calls a precompile directly and the precompile throws,
TransactionExecutor records the exception for fee purposes but not for
receipt purposes. The transaction reports status = 0x1, reports
gasUsed = requiredGas + basicTxCost — and charges the sender the entire gas limit,
crediting the difference to REMASC. The receipt understates the fee actually paid and gives the sender
no indication that their call failed. Rustock reproduces it, and
reported it upstream.
Self-destructs are deleted per transaction
rskj deletes self-destructed accounts at the end of each transaction, not each block, so a later
transaction in the same block can re-create the address as a fresh (0, 0) account just by
calling it. REVM has no per-transaction boundary to hook, so Rustock manufactures one. Found by mainnet
#3,173,807.
A bug and its correction, both required
Some of these were eventually fixed by an RSKIP, which means a client needs both behaviours, switched at the activation height:
| Before | After | Gate |
|---|---|---|
returnDataBuffer survives calls that execute nothing — no EIP-211 clear | A CALL that runs no new frame clears the caller's buffer | RSKIP171, iris300 |
| Unbounded precompile output write: gas-free memory extension | Bounded | RSKIP150 |
Also here: the storage-prefix marker written only for genuine contract creations; a nested
CREATE/CREATE2 constructor's gas refund being discarded (#6,362,186); and
registerBtcCoinbaseTransaction returning rather than throwing when the transaction is not
in the PMT.
Deliberate divergences
Intentional, non-canonical RSK design. A from-scratch client could get all of these right from the RSKIPs — provided it knows not to assume Ethereum.
RSK does not take Ethereum's hardforks wholesale
This is the single most important thing to internalise. RSK adopts individual RSKIPs, so an RSK fork that "maps to" an Ethereum fork does not imply that fork's other changes. Concretely:
- arrowhead600 takes only EIP-2028 from Istanbul.
SLOAD,BALANCE,EXTCODEHASHandSSTOREkeep their Petersburg prices. - lovell700 maps to SHANGHAI, but RSK adopted none of Berlin's or London's EVM gas changes
(#7,338,024) — while still installing
TLOAD/TSTORE/MCOPYfrom RSKIP446 and RSKIP445 (#7,338,135). - EIP-3529 zeroed the
SELFDESTRUCTrefund; RSK keeps 24,000 (#7,388,594). - EIP-2929's warm/cold model is not adopted, so
warm_storage_read_costmust be zeroed for the coldSELFDESTRUCTbeneficiary (#7,403,649), andEXTCODECOPY's flat base — which EIP-2929 dropped — has to be re-pinned to 700 (#7,515,160). SELFBALANCEandCHAINIDarrive at papyrus200 with no Istanbul repricing alongside them.- The EVM call-stack limit is 400, not 1024 (RSKIP150).
Frontier forever
RSK account semantics never adopted EIP-161 emptiness. An account exists if it is in the trie, full
stop. NEW_ACCT_CALL and NEW_ACCT_SUICIDE charge on trie existence, and
EXTCODEHASH keys on it too. An empty account is a real account — which is exactly why the
zero-value REMASC payment above is consensus-visible rather than a harmless no-op.
Peg and Bridge semantics
- Peg-out dusty-change surplus is burned to
0xff…ff. - SegWit peg-ins use the wtxid for the PMT and merkle proof but the txid — no witness — for the processed map, and the witness root is read in the opposite byte order.
- Flyover response codes are full 256-bit two's-complement
int256, behind a 21-byte address gate. getCompoundKeyalways Keccak256-hashes, andbtcBlockHeightenters it as a decimal string.COINBASEreturns the real miner from the header, not the gas-fee recipient.- RSKIP197: a failing precompile
CALLpushes 0 and refunds the surplus instead of aborting the caller.
Wire protocol
Interop-load-bearing, mostly not consensus. Getting these wrong stalls sync rather than forking state.
- Multi-frame RLPx messages. geth and reth send each message as a single frame regardless of
size. rskj splits anything over 32,768 bytes into frames sharing a
contextId, each with its own header and MAC. Without reassembly, large body responses simply never arrive. - Compressed block headers (RSKIP-351).
- RSK sub-protocol message wrapping around the devp2p envelope.
- Descending header delivery — headers come back newest-first.
- Skeleton-based forward sync: connection point, skeleton of every Nth header, then the in-between chunks.
- Discovery node-table eviction semantics, which govern liveness rather than correctness.
Where the spec and the code disagree
The four categories above are about behaviour no document records. This one is worse: a client author reads the RSKIP, implements exactly what it says, and forks — because rskj does something else. Fourteen entries so far, almost all of them from the RSKIP-144/351/535 header work, and almost all found by replaying testnet rather than by reading.
- RSKIP-144 allows four parallel sublists; rskj allows two. The spec sets
maxTransactionExecutionThreads = 4; rskj'sTX_EXECUTION_THREADSis 2. At three edges the document's reading accepts a block rskj rejects. Rustock follows the code. - RSKIP-144 never mentions receipts. It covers sublist gas limits in detail; the
rule that
cumulativeGasUsedrestarts at each sublist boundary is in no RSKIP. Two different quantities carry similar names and only one is written down. - RSKIP-351 says the header version must be "exactly 1". Testnet runs a version ladder — 0 below #7,139,600, 1 to #7,604,199, 2 after — so a client enforcing the document rejects every testnet block past #7,604,200.
- The edges' byte order is unspecified, and the code packs them little-endian, where RLP is big-endian everywhere else.
- RSKIP-351's edges element is mandatory in the spec and optional in the code, and RSKIP-535 reverses the V2 field order relative to what the spec describes.
- Which trailing fields a header carries is decided by height, not by the bytes. rskj asks the activation config at the block's height. Edges are emitted even when empty, so their presence proves nothing; the base event is the field that can actually be absent.
- The wire layout of a versioned header is in no RSKIP at all, and neither is testnet's base-event recovery window — a bare constant marking where a testnet mishap stopped.
- Testnet's federation UTXOs live under a different storage cell, which is consensus-bearing chain repair that no RSKIP describes.
One divergence runs the other way. Every
entry above is Rustock reproducing something rskj does. eth_getProof
is the exception: rskj does not implement it, and Rustock answers it anyway, with EIP-1186's envelope
over unitrie node messages. That is a deliberate choice rather than a compatibility gap — the question
is worth answering and there is no standard shape RSK could satisfy — but it is the one place a client
written against Rustock would find something a client written against rskj does not have.
RSKIP port log
Separately from the quirks, the project keeps a log of the 62 RSKIP feature slices ported to keep the mainnet sync advancing from genesis to tip: Bridge peg-in and peg-out handling, the federation lifecycle, the signer-validation protocol, SegWit support at reed800, Bridge event emissions and hardfork opcode installs. Each entry cites the rskj source, names where Rustock matches it, and records the mainnet block where it first mattered plus the replay that verified it.
If you are writing a Rootstock client — in any language — this catalogue is the most useful thing this project has produced. It is also incomplete by construction: it contains what replaying mainnet has surfaced so far. Corrections and additions are welcome as issues.