Skip to main content

How to verify child chain state on the parent chain

Arbitrum implements a fraud proof system that ensures that the state of any given child chain is safely maintained by its parent chain. In this system, a validator is responsible for periodically posting assertions about the child chain's state to its parent chain. See Inside Arbitrum Nitro to learn more about the Rollup protocol.

Each assertion is a claim about the impact that a series of child chain blocks (containing transactions) ought to have on the chain's state. When posted to the parent chain, assertions are recorded by the Rollup contract; once confirmed, the resulting state commitment (block hash + send root) is relayed to the Outbox contract. The primary purpose of this commitment is to secure withdrawals: it provides the trusted state against which child chain -> parent chain messages (such as withdrawals) are proven before they can be executed on the parent chain. The same committed state can also be reused by off-chain tools — for example, the off-chain state verification described in this guide.

Before we begin, we will introduce the key components: the assertion, its global state, and send roots.

Assertions

The Rollup contract stores a chain of assertions. Each stored assertion is represented by an AssertionNode struct:

struct AssertionNode {
// Block when the first child of this assertion was created
uint64 firstChildBlock;
// Block when the second child was created (non-zero => assertion was challenged)
uint64 secondChildBlock;
// The block number when this assertion was created
uint64 createdAtBlock;
// True if this assertion is the first child of its prev
bool isFirstChild;
// Status of the assertion: NoAssertion / Pending / Confirmed
AssertionStatus status;
// Hash of the environment config at creation time (e.g. wasmModuleRoot)
bytes32 configHash;
}

An assertion's validity is bound to its assertion hash; the struct above does not store the claimed state directly.

An assertion is created together with its before and after execution states, packaged as AssertionInputs:

struct AssertionInputs {
BeforeStateData beforeStateData;
AssertionState beforeState;
AssertionState afterState;
}

struct AssertionState {
GlobalState globalState;
MachineStatus machineStatus;
bytes32 endHistoryRoot;
}

The key field is afterState.globalState: it contains the child chain block hash and send root that this assertion claims. We can extract them with the GlobalStateLib helpers getBlockHash() and getSendRoot().

The assertion hash itself authenticates these values — it is computed as:

assertionHash = keccak256(abi.encodePacked(
parentAssertionHash,
afterState.hash(), // keccak256(abi.encode(afterState))
inboxAcc
))

Send roots

The send root mapping is stored in the Outbox contract. It maps the Merkle root of each batch of child chain -> parent chain messages (the send root) to its corresponding child chain block hash.

When an assertion is confirmed, the Rollup contract records the send root to the outbox so that, when a user later triggers a child chain -> parent chain message on the parent chain, the request can be verified.

mapping(bytes32 => bytes32) public roots; // maps root hashes => child chain block hash

Because this mapping stores the block hash, you can also recover the child chain block hash directly from the outbox.

Verify child chain state on parent chain

Assume there is a contract called foo on the child chain at address fooAddress, and we want to prove its storage value at slot.

To verify the state, we need a Merkle Trie verifier contract — for example, a Lib_MerkleTrie-style library that exposes a get(key, proof, root) function.

Why we can trust an untrusted child chain RPC

The steps below query a child chain RPC several times (eth_getBlockByHash, eth_getProof). You do not have to trust that RPC. Every value it returns is checked against the trust anchor established in step 1 — the block hash and send root taken from the assertion confirmed on the parent chain:

  • The block header is only accepted if its RLP hash reproduces the confirmed block hash.
  • The account and storage values are only accepted if their Merkle proofs verify against the state root inside that header.

Because each link is bound to the confirmed parent chain commitment by a cryptographic hash, a malicious or buggy RPC cannot forge data that passes verification. The worst it can do is return wrong or missing data, which makes verification fail (a denial of service) — it can never produce a false positive. This one-way guarantee is what makes the whole procedure sound.

1. Get a confirmed child chain block hash

For security, we use the latest confirmed assertion rather than the latest proposed one. The AssertionConfirmed event emits the block hash and send root directly:

  • Get the latest confirmed assertion hash: assertionHash = rollup.latestConfirmed(), which returns a bytes32 assertion hash.
  • Query the confirmation event for that hash: AssertionConfirmed(bytes32 indexed assertionHash, bytes32 blockHash, bytes32 sendRoot). The event gives you blockHash and sendRoot directly.
  • (Optional) You can cross-check by reading the global state from the AssertionCreated event for the same hash and extracting blockHash = GlobalStateLib.getBlockHash(assertion.afterState.globalState) and sendRoot = GlobalStateLib.getSendRoot(assertion.afterState.globalState).
  • (Optional) You can also look the block hash up in the outbox: roots[sendRoot].

2. Prove the state root belongs to the block hash, using the block header

With the block hash, fetch the corresponding block from the child chain provider: l2blockRaw = eth_getBlockByHash(blockHash).

Then re-derive the block hash by RLP-encoding and hashing the header fields:

blockarray = [
l2blockRaw.parentHash,
l2blockRaw.sha3Uncles,
l2blockRaw.miner,
l2blockRaw.stateRoot,
l2blockRaw.transactionsRoot,
l2blockRaw.receiptsRoot,
l2blockRaw.logsBloom,
BigNumber.from(l2blockRaw.difficulty).toHexString(),
BigNumber.from(l2blockRaw.number).toHexString(),
BigNumber.from(l2blockRaw.gasLimit).toHexString(),
BigNumber.from(l2blockRaw.gasUsed).toHexString(),
BigNumber.from(l2blockRaw.timestamp).toHexString(),
l2blockRaw.extraData,
l2blockRaw.mixHash,
l2blockRaw.nonce,
BigNumber.from(l2blockRaw.baseFeePerGas).toHexString(),
];
  • Compute calculated_blockhash = keccak256(RLP.encode(blockarray)).
  • Check that it matches the value from step 1: calculated_blockhash === blockHash.

If they match, the header — and in particular the stateRoot — is proven correct.

Watch the header field set and zero-value encoding

The exact list of header fields (and their ordering) must match the block header schema of the chain you are proving against. The 16 fields above match current Arbitrum Nitro headers, but a chain running a different configuration may include additional fields. Additionally, RLP requires minimal integer encoding: a numeric field whose value is 0 must encode to an empty byte string, not 0x00. Confirm your encoding reproduces the expected hash before relying on it.

3. Prove the account in the state root

With a trusted state root, verify the account:

proof = l2provider.send('eth_getProof', [fooAddress, [slot], { blockHash }]);
  • Get account proof: accountProof = RLP.encode(proof.accountProof)
  • Get proofKey: proofKey = ethers.utils.keccak256(fooAddress)
  • Call the verifier contract to verify:
[acctExists, acctEncoded] = verifier.get(proofKey, accountProof, stateRoot);
  • Check for equality: acctExists == true

4. Prove the storage slot is in the account root

  • Get storage root: storageRoot = RLP.decode(acctEncoded)[2]
  • Get storage slot key: slotKey = ethers.utils.keccak256(slot)
  • Get storageProof: storageProof = ethers.utils.RLP.encode(proof.storageProof.filter((x) => x.key === slot)[0].proof)
  • Call the Merkle verifier contract to verify:
const [storageExists, storageEncoded] = await verifier.get(slotKey, storageProof, storageRoot);
  • Check for equality: storageExists == true
  • Obtain the value of the storage at slot: storageValue = ethers.utils.RLP.decode(storageEncoded)

You have now proven a specific storage value at a specific block height on the child chain, entirely through the parent chain.

Cross-check the value directly on the child chain

  • Call the child chain RPC provider to get the value at the corresponding block number: actualValue = l2provider.getStorageAt(fooAddress, slot, l2blockRaw.number)
  • Check for equality: storageValue === BigNumber.from(actualValue).toHexString()

Security considerations and limitations

The procedure above is cryptographically sound — its trust is anchored in an assertion confirmed on the parent chain, and every value fetched from a child chain RPC is verified against that anchor. Keep the following caveats in mind:

  • You can only prove state at a confirmed block. The block hash in an assertion's global state is for one specific block (the end of the assertion). To prove state at an arbitrary or more recent block, you must additionally link that block back to a confirmed block through the parentHash chain.
  • Confirmation depends on the parent chain's finality. latestConfirmed() can change if the parent chain reorganizes. For maximum safety, read it at a finalized parent chain block.
  • "Confirmed" reflects the Rollup's trust model. A confirmed assertion is one that survived its challenge period; its correctness rests on the Rollup's fraud-proof / BoLD security assumptions (at least one honest validator).
  • Zero / non-existent values need explicit handling. The steps above check acctExists == true and storageExists == true. Proving that a slot's value is zero requires handling the corresponding exclusion proof.
  • The Merkle Trie verifier must be correct. The overall guarantee depends on the correctness of the verifier library you use; review and test it before relying on it in production.