rskj compatibility

Nine years of RSK mainnet depend on behaviour no specification records. This is the catalogue of what Rustock had to reproduce — and how each entry was found.

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.

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 TreeMap that was mistaken for a HashMap because 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 bytes to a full zero word, which canonical ABI encoding does not.
  • BlockHeaderContract.getGasLimit returns 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:

BeforeAfterGate
returnDataBuffer survives calls that execute nothing — no EIP-211 clearA CALL that runs no new frame clears the caller's bufferRSKIP171, iris300
Unbounded precompile output write: gas-free memory extensionBoundedRSKIP150

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, EXTCODEHASH and SSTORE keep 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/MCOPY from RSKIP446 and RSKIP445 (#7,338,135).
  • EIP-3529 zeroed the SELFDESTRUCT refund; RSK keeps 24,000 (#7,388,594).
  • EIP-2929's warm/cold model is not adopted, so warm_storage_read_cost must be zeroed for the cold SELFDESTRUCT beneficiary (#7,403,649), and EXTCODECOPY's flat base — which EIP-2929 dropped — has to be re-pinned to 700 (#7,515,160).
  • SELFBALANCE and CHAINID arrive 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.
  • getCompoundKey always Keccak256-hashes, and btcBlockHeight enters it as a decimal string.
  • COINBASE returns the real miner from the header, not the gas-fee recipient.
  • RSKIP197: a failing precompile CALL pushes 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's TX_EXECUTION_THREADS is 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 cumulativeGasUsed restarts 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.