How Ethereum scales with Arbitrum Nitro and how to use it

A blockchain on a blockchain deep dive

Have you heard of Arbitrum Nitro? The new WAVM enables Plasma but for smart contracts in a super efficient way! It enables having a side chain with guarantees of the Ethereum mainnet chain. Arbitrum has already been one of the most successful Layer 2s so far, and the new Nitro is a major upgrade for it.

Since the publishing of this post Arbitrum has further solidified his position as a leading L2. Take a look at this analysis from Coingecko:


But let's start at the beginning...

What are Merkle Trees?

Merkle Trees are at the foundation of how this scaling technology works. At the root of the Merkle tree is the root hash. It's created by hashing all its original values as leaf nodes. Now two leaf hashes are combined by creating a new hash just for those together. We do this all the way until we have one tree with a single root hash. A Merkle proof now is a way for you to prove to someone who only knows the root hash that any value is in fact part of this tree as one of the leafs.

I wrote a long guide on Merkle Trees in case you want to dive deeper into the topic.

State of a smart contract

In Ethereum one Merkle tree is the state tree which contains all state like user ETH balances, but it also contains the contract storage itself. This allows us to create Merkle proofs on smart contract state!

So it's possible to prove a smart contract has a certain state using the Merkle proof mechanism. Keep that in mind for later.

How does Plasma work?

Plasma uses a combination of smart contracts and Merkle proofs. Together, these enable fast and cheap transactions by offloading these transactions from the main Ethereum blockchain into a plasma chain. In contrast to regular sidechains, you cannot run any smart contract in here.

In Plasma users send transactions between each other in UTXO style where the results of new balances are continuously updated in the Ethereum smart contract as Merkle tree roots. Once a Merkle root is updated in the smart contract, it gives users the security over their funds even if the plasma chain operator is malicious. The root encapsulates the result from many sent funds transactions. Should a Plasma operator submit an invalid root, users can contest it and safely get their funds back. For more details have a look here.

But as said before, it cannot run smart contracts. So no Uniswap with Plasma is possible.

Arbitrum: How to run a blockchain on a blockchain

But this is where Arbitrum comes in. It's Plasma for smart contracts!

Yo Dawg Optimism

The core idea here is actually quite simple. Just like in Plasma you have a layer 2 chain which is running all transactions and you update only the Merkle root within layer 1 occasionally. The Merkle root in this case is not for UTXO transactions as for regular plasma, but for the full state of a smart contract. Or rather for the full state of all smart contracts being used.

Yes this means we can run arbitrary smart contracts on Arbitrum! In very short, this is how it works:

  • Represent smart contract states as Merkle tree
  • Run all transactions only on the Arbitrum chain
  • Continuously update the state roots on Ethereum layer 1
  • Arbitrum chain has low security, but through the state roots on Ethereum, they enable fraud proofs
    • When a validator from layer 2 submits a malicious state root and it's contested, they loose their bond.
    • Fraud proofs are gas expensive, but more efficient than Optimism through an interactive mechamism (see details below).
    • Run single execution step which is contested with prover submitting any required state.

Now you might realize, this is where the scaling comes from. You only run transactions on layer 1 that are contested with a fraud proof. That’s the gain. So the scaling advantage comes solely from the fact that you won’t run 99.9% of transactions on layer 1.

Arbitrum Detailed Overview

The big ideas behind Arbitrum Nitro are:

  1. Sequencing 
  2. Geth at the core
  3. Wasm for Proving
  4. Optimistic Rollups via Interactive Fraud Proofs
Arbitrum Sequencing

To actually run the transactions, we need native Geth, Geth with Wasm and Merkle Proofs. The architecture looks like this:

  • On the highest level you have blockchain node functionality.
  • ArbOS handling L2 functionlity like batch decompression and bridging.
  • Core Geth EVM contract execution either native or via WASM.
Geth at core

How are transactions included?

New transactions can be added in three ways:

  1. Normal Inclusion by Sequencer
  2. Message from L1 included by Sequencer
  3. Message from L1 force-included on L2


1. Normal Inclusion by Sequencer

In the normal case the currently still centralized sequencer will add new messages to the inbox. This is done by calling addSequencerL2Batch. Here we check that the sender is the stored sequencer, only he is allowed to call this function:

function addSequencerL2Batch(
    uint256 sequenceNumber,
    bytes calldata data,
    uint256 afterDelayedMessagesRead,
    IGasRefunder gasRefunder,
    uint256 prevMessageCount,
    uint256 newMessageCount
) external override refundsGas(gasRefunder) {
    if (
        !isBatchPoster[msg.sender]
        && msg.sender != address(rollup)
    ) revert NotBatchPoster();

    [...]
    addSequencerL2BatchImpl(
        dataHash_,
        afterDelayedMessagesRead_,
        0,
        prevMessageCount_,
        newMessageCount_
    );
    [...]
}

And then inside addSequencerL2BatchImpl the bridge is called to enqueue the message to the inbox:

bridge.enqueueSequencerMessage(
    dataHash,
    afterDelayedMessagesRead,
    prevMessageCount,
    newMessageCount
);

Which then calls enqueueSequencerMessage in the bridge which simply adds a new hash to the inbox array:

bytes32[] public sequencerInboxAccs;

function enqueueSequencerMessage(
    bytes32 dataHash,
    uint256 afterDelayedMessagesRead,
    uint256 prevMessageCount,
    uint256 newMessageCount
)
    external
    onlySequencerInbox
    returns (
        uint256 seqMessageIndex,
        bytes32 beforeAcc,
        bytes32 delayedAcc,
        bytes32 acc
    )
{
    [...]
    acc = keccak256(abi.encodePacked(beforeAcc, dataHash, delayedAcc));
    sequencerInboxAccs.push(acc);
}

2. Message from L1 by Sequencer

Messages can also be added by anyone directly using calls in L1. This is useful for example when making deposits from L1 to L2.

Eventually this will call enqueueDelayedMessage on the bridge inside deliverToBridge.

bytes32[] public delayedInboxAccs;

function enqueueDelayedMessage(
    uint8 kind,
    address sender,
    bytes32 messageDataHash
) external payable returns (uint256) {
    [...]
    delayedInboxAccs.push(
        Messages.accumulateInboxMessage(
            prevAcc,
            messageHash
        )
    );
    [...]
}
function deliverToBridge(
    uint8 kind,
    address sender,
    bytes32 messageDataHash
) internal returns (uint256) {
   return
       bridge.enqueueDelayedMessage{value: msg.value}(
           kind,
           AddressAliasHelper.applyL1ToL2Alias(sender),
           messageDataHash
       );
}

3. Message from L1 force-included on L2

There is one issue with the second case. A sequencer can take messages from the delayed inbox and process them, but he may also simply ignore them. In that case messages might not ever end up in L2. And since the sequencer is still centralized, there is a third backup option called forceInclusion.

Anyone can call this function and should the sequencer stop posting messages for a minimum amount of time, it allows others to continue posting messages.


So why is there a delay at all and why not allow users always  immediately to force include transactions? If the sequencer has priority, he can give soft-confirmations about transactions to users, leading to a better UX. If there would be constant force inclusions, the sequencer cannot pre-confirm to users what will happen. Why? Well, a force-included transaction may invalidate one that the sequencer was planning to post.

function forceInclusion(
    uint256 _totalDelayedMessagesRead,
    uint8 kind,
    uint64[2] calldata l1BlockAndTime,
    uint256 baseFeeL1,
    address sender,
    bytes32 messageDataHash
) external {
    [...]

    if (l1BlockAndTime[0] + maxTimeVariation.delayBlocks >= block.number)
        revert ForceIncludeBlockTooSoon();
    if (l1BlockAndTime[1] + maxTimeVariation.delaySeconds >= block.timestamp)
        revert ForceIncludeTimeTooSoon();

    [...]

    addSequencerL2BatchImpl(
            dataHash,
            __totalDelayedMessagesRead,
            0,
            prevSeqMsgCount,
            newSeqMsgCount
        );
    [...]
}

How do the Fraud Proofs work?

Let's explore how the frauf proofs of Arbitrum Nitro work in detail.

1. WAVM

New in Arbitrum Nitro is the WAVM. They basically re-use the Geth Ethereum Node code and compile it to Wasm (or rather a slightly modified version of Wasm). Wasm stands for Web Assembly and is an environment which allows running code regardless of the platform. So similar to the EVM, but without gas. It’s also a web-wide standard, so it has more support by other languages and better performance. So compiling the Geth code written in Go into Wasm is indeed possible.

How does this Wasm execution help us?

Math Proof Meme

We can run proofs for it! Because it’s a controlled execution environment, we can replicate its execution inside a Solidity smart contract. That is the requirement for running fraud proofs.

So are we just running everything within the WAVM? Well Wasm is still slower execution compared to native compiled code. But here’s the beauty of Nitro: The same Geth code will be compiled to Wasm for the proving, but to native code for execution. This way we can get the best of both worlds: Run the chain with native performance, but still be able to execute proofs.

2. Fraud Proofs

Fraud Meme

Now let’s take a look how these fraud proofs work in detail. What do we need?

  1. We need a mechanism to get pre- and post-state of an execution.
  2. We need to be able to run the WAVM execution in a Solidity contract.
  3. We need an interactive mechanism to determine which execution step to prove.


The last step is optional, but a performance improvement if we only require a proof for one single execution. It does however require a few additional interactive steps between challenger and challenged node. We won’t go into the details for that, but you can read more about it here. And of course in the source code directly.

But we will go into detail about the other two parts now.

3. Get Pre- and Post-state of an Execution

During the interactive challenge, eventually the challenger will point down to a single execution disagreement. This single execution has a known pre- and post-execution state Merkle root hash. The post-execution hash is challenged, so in the end we will compare it to what we got from executing it ourselves. The pre-execution hash is not challenged and thus trusted.

It will be used to initialize the WAVM machine:

struct Machine {
    MachineStatus status;
    ValueStack valueStack;
    ValueStack internalStack;
    StackFrameWindow frameStack;
    bytes32 globalStateHash;
    uint32 moduleIdx;
    uint32 functionIdx;
    uint32 functionPc;
    bytes32 modulesRoot;
}

The challenger will initialize this machine with all the data.

In the contract we then only need to double check that this data represents the stored Merkle root hash:

require(mach.hash() == beforeHash, "MACHINE_BEFORE_HASH")

Now we can also trust the modules root and use it to verify the modules data.

A module is defined as:

struct Module {
    bytes32 globalsMerkleRoot;
    ModuleMemory moduleMemory;
    bytes32 tablesMerkleRoot;
    bytes32 functionsMerkleRoot;
    uint32 internalsOffset;
}

This holds data in the form of further Merkle root hashes for WAVM machine data. And the challenger also initializes this data.

The contract again just verifies it with the previous modulesRoot:

(mod, offset) = Deserialize.module(proof, offset);
(modProof, offset) = Deserialize.merkleProof(proof, offset);
require(
    modProof.computeRootFromModule(mach.moduleIdx, mod) == mach.modulesRoot,
    "MODULES_ROOT"
);

And lastly we do the same again for the instruction data:

struct Instruction {
    uint16 opcode;
    uint256 argumentData;
}

And it will be verified via the functionsMerkleRoot:

MerkleProof memory instProof;
MerkleProof memory funcProof;
(inst, offset) = Deserialize.instruction(proof, offset);
(instProof, offset) = Deserialize.merkleProof(proof, offset);
(funcProof, offset) = Deserialize.merkleProof(proof, offset);
bytes32 codeHash = instProof.computeRootFromInstruction(mach.functionPc, inst);
bytes32 recomputedRoot = funcProof.computeRootFromFunction(
    mach.functionIdx,
    codeHash
);
require(recomputedRoot == mod.functionsMerkleRoot, "BAD_FUNCTIONS_ROOT");

So now we have an initialized WAVM machine and all that is left to do is execute this one single operation. This now depends on the exact instruction we need to run.

Take for example a simple addition. This is extremely simple:

uint32 b = mach.valueStack.pop().assumeI32();
uint32 a = mach.valueStack.pop().assumeI32();
[...]
return (a + b, false);
Stack

That’s basically it. Take the first two values from the machine stack and add them together.

Let’s look at another instruction. A local get instruction:

function executeLocalGet(
    Machine memory mach,
    Module memory,
    Instruction calldata inst,
    bytes calldata proof
) internal pure {
    StackFrame memory frame = mach.frameStack.peek();
    Value memory val = merkleProveGetValue(frame.localsMerkleRoot, inst.argumentData, proof);
    mach.valueStack.push(val);
}

The StackFrame comes from the WAVM initialization where we can find the localsMerkleRoot:

struct StackFrame {
    Value returnPc;
    bytes32 localsMerkleRoot;
    uint32 callerModule;
    uint32 callerModuleInternals;
}

And via Merkle Proof we can retrieve the value and push it to the stack.

Lastly we double check that the resulting final hash from this computational step equals the stored hash:

require(
    afterHash != selection.oldSegments[selection.challengePosition + 1],
    "SAME_OSP_END"
);

Only if it doesn't match, the proof is valid and we continue. Now the challenger has won and a new post-state will be accepted.

How to implement on Arbitrum yourself

Arbitrum fully supports Solidity, so you can take your contracts as they are with just a few caveats:

  • blockhash(x) returns a cryptographically insecure, pseudo-random hash.l return 0.
  • block.coinbase returns zero
  • block.difficulty returns the constant 2500000000000000
  • block.number / block.timestamp return an "estimate" of the L1 block
  • msg.sender works the same way it does on Ethereum for normal L2-to-L2 transactions; for L1-to-L2 "retryable ticket" transactions, it will return the L2 address alias of the L1 contract that triggered the message. See retryable ticket address aliasing for more.

How to use the Arbitrum networks

Those are the two important Aribtrum networks. You can use the wallet_addEthereumChain functionality from supported wallets like MetaMask or otherwise users will manually need to add the network.

For now the mainnet is still operating on the older architecture. But the Rinkeby testnet is fully upgraded to the new Arbitrum Nitro stack.


To get funds use the bridge available at https://bridge.arbitrum.io/.

const params = [{
  "chainId": "42161", // testnet: "421611"
  "chainName": "Arbitrum",
  "rpcUrls": [
    "https://arb1.arbitrum.io/rpc"
    // rinkeby: "https://rinkeby.arbitrum.io/rpc"
    // goerli: "https://goerli-rollup.arbitrum.io/rpc"
  ],
  "nativeCurrency": {
    "name": "Ether",
    "symbol": "ETH",
    "decimals": 18
  },
  "blockExplorerUrls": [
    "https://explorer.arbitrum.io"
    // rinkeby: "https://rinkeby-explorer.arbitrum.io"
    // goerli: "https://goerli-rollup-explorer.arbitrum.io"
  ]
}]

try {
    await ethereum.request({
        method: 'wallet_addEthereumChain',
        params,
    })
} catch (error) {
    // something failed, e.g., user denied request
}
{
    arbitrum_mainnet: {
        provider: function () {
          return new HDWalletProvider(
            mnemonic,
            "https://arbitrum-mainnet.infura.io/v3/"
                + infuraKey,
            0,
            1
          );
        },
    },
    arbitrum_rinkeby: {
        provider: function () {
          return new HDWalletProvider(
            mnemonic,
            "https://rinkeby.arbitrum.io/rpc",
            0,
            1
          );
        },
    },
    arbitrum_goerli: {
        provider: function () {
          return new HDWalletProvider(
            mnemonic,
            "https://goerli-rollup.arbitrum.io/rpc",
            0,
            1
          );
        }
    }
}

How to deploy to the Arbitrum networks

Now you can add the Arbitrum Mainnet into Truffle or Hardhat as shown left.

A good practice I would recommend is writing your tests with Hardhat with a regular config, so you can run the tests fast and with console.log/stacktraces. And only occasionally use Truffle to run tests against a testnet.

Lastly you will need to activate Arbitrum in the Infura settings: https://infura.io/payment.

Infura Optimism

Markus Waas

Solidity Developer

More great blog posts from Markus Waas

© 2024 Solidity Dev Studio. All rights reserved.

This website is powered by Scrivito, the next generation React CMS.