Deploy OPStack Layer2 with Meeda


date: 2024-11-5

op-contracts: op-contracts/v2.0.0-beta.3

op-node: op-stack(v1.9.4);

op-geth: v1.101411.0


Refer to the official docs: Creating Your Own L2 Rollup Testnet | Optimism Docs. The following is a step-by-step guide.

Get the Source

cd ~/op-layer2
git clone https://github.com/memoio/optimism.git
cd optimism
# Switch to the correct branch (latest contracts; matches current OP Sepolia)
# git checkout op-contracts/v2.0.0-beta.2
git checkout op-contracts/v2.0.0-beta.3

Install Dependencies

# Check required versions (install versions listed now; adjust as needed later)
./packages/contracts-bedrock/scripts/getting-started/versions.sh

# Install Go
wget -c https://golang.google.cn/dl/go1.21.1.linux-amd64.tar.gz
tar zxvf go1.21.1.linux-amd64.tar.gz -C /usr/local
cd ~
mkdir go
vim ~/.profile
# Append to the end of .profile:
export GOPATH=$HOME/go
export GOROOT=/usr/local/go
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin
# Verify
source .profile
go version
# Optional: switch Go proxy
go env -w GOPROXY=https://goproxy.cn,direct

# Install Node.js
wget https://nodejs.org/dist/v20.16.0/node-v20.16.0-linux-x64.tar.xz
tar -xvf node-v20.16.0-linux-x64.tar.xz
mkdir /usr/local/nodejs
mv node-v20.16.0-linux-x64/* /usr/local/nodejs/
# Append to the end of .profile:
export NODE_HOME=/usr/local/nodejs
export PATH=$NODE_HOME/bin:$PATH
# Verify
source .profile
node --version

# Install pnpm
npm install -g pnpm
pnpm --version

# Install Foundry
curl -L https://foundry.paradigm.xyz | bash
# Follow the instructions printed by the command above
source .profile
foundryup
# Verify
forge --version

# Install jq
sudo apt-get install jq
jq --version

# Install direnv
curl -sfL https://direnv.net/install.sh | bash
# Append to the last line of .bashrc:
eval "$(direnv hook bash)"
# Verify
direnv --version

# Install make
sudo apt-get install make

# Install just
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/
vim ~/.profile
# Append to the end of .profile:
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin:/usr/local/
# Verify
source .profile
just version

Then install the remaining dependencies and build the contracts:

The following is outdated because OP no longer uses pnpm and has switched to just:

Running pnpm install may encounter errors:

 ERR_SOCKET_TIMEOUT  request to https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.73.3.tgz failed, reason: Socket timeout

Suggested solutions:

Start op-batcher

Ensure the op-batcher address has at least 1 Sepolia ETH. Only start op-batcher after op-node has fully synced. Otherwise, stale batcher timestamps can cause frequent batch submissions due to partial sync. How to determine op-node is basically synced (i.e., safe to start op-batcher): Check the sync timestamp printed in op-node logs, or in op-geth inspect eth.getBlockByNumber(eth.blockNumber) for the latest L2 block and read the timestamp. If the difference between that timestamp and current time is within 5000 seconds, consider it basically synced.

I spent a lot of time on this and had to retry many times. Eventually pnpm install succeeded and all dependencies were installed. (I only used Option 1.)

Set Environment Variables

Generate Addresses

For testing, you can generate via script:

In production, use a combination of an HSM and a hardware wallet.

Here we continue using the legacy memo-L2 parameters:

Save these four account addresses and private keys into .envrc.

Fund these accounts. Suggested amounts: admin - 0.5 Sepolia ETH, proposer - 0.2 Sepolia ETH, batcher - 0.1 Sepolia ETH.

Load Environment Variables

Configure the Network

In production, l2ChainID should be unique and added to ethereum-lists/chains.

When useAltDA is true, the DataAvailabilityChallenge contract is deployed, enabling challenges against AltDA commitments. This is sometimes called Plasma, but current Plasma only supports keccak256 commitments and is incompatible with Meeda. Meeda natively supports challenge and proof, so skipping Plasma does not reduce security.

When useFaultProofs is true, fault proofs are enabled and OptimismPortal2.sol is used instead of OptimismPortal.sol. Without fault proofs, a single op-proposer periodically submits outputRoot to L1 (centralized and trust-heavy). Fault proofs decentralize submission by allowing anyone to propose outputs and by introducing op-challenger to verify/challenge.

With the above settings, frequent L2 reorgs were observed. op-node log: t=2024-08-21T02:27:27+0000 lvl=warn msg="L2 reorg: existing unsafe block does not match derived attributes from L1" err="random field does not match. expected: 0x6cc733d89ec1fb3314d3b0ddbb1ccdb1d0f1ff8a7125b68a7c1395bd9a30d753. got: 0xe88014e2c77b43a9f0c9ac092eb01a8f366a305b1367af08e3a67b711a6fc7eb" ...

Root cause: mismatch between L1-derived attributes and locally cached unsafe block. Two inputs go to L1: proposer output roots and batcher commitments to Meeda. Logs indicate the sequencer used a stale origin block random when producing unsafe blocks. No fix yet; redeploy with latest versions and observe.

Deploy L1 Contracts

Note: Check the base fee on Etherscan and deploy when lower to save ETH.

Deploying all L1 contracts consumes about 72,525,043 gas. At ~3.18 gwei this costs ~0.231 ETH.

After deployment, addresses are saved in deployments/artifact.json. Example:

If fault proofs are enabled, while running Deploy.s.sol:

  1. For AnchorStateRegistry, deploy it (deployAnchorStateRegistry()) but do not initialize it (initializeAnchorStateRegistry()).

  2. Build genesis, start op-geth and op-node, then run cast rpc optimism_outputAtBlock <hex_block_number> --rpc-url http://localhost:9545 to get the genesis outputRoot.

  3. Set faultGameGenesisOutputRoot in getting-started.json to the value from step 2, then run initializeAnchorStateRegistry().

Generate l2-allocs File

The resulting l2-allocs file is ~9.1MB and includes code bytecode and addresses for predeployed L2 contracts.

Build op-geth

Build op Components

If fault proofs are enabled, also build:

make op-challenger op-program cannon cannon-prestate

Generate L2 Configuration Files

Example rollup.json:

Field explanations:

In simple terms: seq_window_size means that data for epoch n (L1 block n) must be uploaded to L1 within seq_window_size L1 blocks. For example, at epoch 66100, L2 produced blocks 908111–908116; if seq_window_size is 300, the blob containing those transactions must be submitted before L1 block 66400.

genesis.l1.number comes from the System contract startBlock set during deployment.

When op-node starts, it syncs from that L1 block (~1s per block). If much time has passed since deployment, sync will take longer.

Optional/advanced: You can manually set l1.hash and l1.number in rollup.json to a recent finalized block to reduce sync time. Find a finalized block (≈20 minutes old) on Etherscan and run:

Use the output to update l1.number, l1.hash, and l2_time (the L1 block timestamp). Also update genesis.json timestamp to l2_time in hex (cast to-hex 1723619988).

Initialize op-geth

Start op-geth

When shutting down the Rollup, stop components in reverse of startup order.

To safely stop op-geth: use CTRL-C in the foreground, or kill -INT <pid> (signal 2) if running in the background. Abrupt stops risk database corruption.

Important: Always run op-geth and op-node in a 1:1 mapping. Do not attach multiple op-geth to one op-node or vice versa.

Start op-node

altda.da-server should be the Meeda light node HTTP endpoint.

Use tail out.log | grep 'Advancing bq origin' to view latest L2 sync progress.

Or check sync status:

View chain state:

On startup, op-node syncs from L1. Using an Infura URL hit rate limits and produced errors like Engine temporary error ... 429 Too Many Requests ... batch item count exceeded. Lowering l1.rpc-max-batch-size (e.g., 10 from default 20) still failed.

Switching to an unrestricted L1 RPC (from chainlist) fixed it:

To stop op-node, first run cast rpc admin_stopSequencer --rpc-url http://localhost:8547, then stop the process.

Start op-batcher

Ensure the op-batcher address has at least 1 Sepolia ETH.

Only start op-batcher after op-node has fully synced. Otherwise, partial sync can cause stale batcher timestamps and frequent submissions.

How to tell op-node is basically synced (OK to start op-batcher):

Check the sync timestamp printed in op-node logs, or in op-geth run eth.getBlockByNumber(eth.blockNumber) to inspect the latest L2 block timestamp. If it’s within 5000 seconds of current time, consider it basically synced.

max-channel-duration specifies how many L1 blocks between submissions to L1. If the channel fills earlier, it submits immediately; otherwise it submits again after 1500 blocks (max-channel-duration=1500, 1500 * 12s = 5h). If set to 0, it falls back to sequencerWindowSize in getting-started.json (also measured in L1 blocks), default 3600 (≈12h). This is the validity window for the op-batcher channel. If within 5 hours the batch size reaches --max-l1-tx-size-bytes=1040000 (~0.99MB), it will also submit. We use Meeda as DA here, so data is uploaded to Meeda.

However, after running we observed frequent L2 reorgs. We suspected the batcher’s L1 submission interval was too long, so we changed --max-l1-tx-size-bytes=256000.

To stop op-batcher:

Wait until Batch Submitter stopped appears in logs, then stop the process.

If you sent the stop command but the process hasn’t exited yet, you can restart op-batcher with:

The metrics port defaults to 7300 and conflicts with op-node; change it to 7301.

Currently, Meeda supports a maximum data size of 127B * (32768 / 4) = 1016KB. Therefore, set max-l1-tx-size-bytes to 1040384 or lower; otherwise uploads will fail.

Start op-proposer

op-proposer polls L2 blocks every --poll-interval=12s, and every "l2OutputOracleSubmissionInterval": 120 (i.e., 120 L2 blocks ≈ 4 minutes) sends a proposeL2Output transaction to L2OutputOracleProxy.

See the L2OutputOracleProxy contract on Etherscan to view the latest submitted L2 block and the next target block: L2OutputOracleProxy on Etherscan.

If fault proofs are enabled, remove --l2oo-address in start.sh and add:

To stop op-proposer, simply kill the op-proposer process. Shut down components in the reverse order of startup.

If fault proofs are enabled, also start op-challenger. Example script:

Tools

Query L1 balance

Query contract address

Transfer

Check account L2 balance

Deposit (Stake)

ETH on L2 is moved from L1 via deposits. Sending a small amount from L1 to L2 is a deposit.

The balance is ultimately held by OptimismPortalProxy.

Your L1 balance decreases immediately; after about 1 minute, the L2 balance increases.

Check L2 balance:

Check L1 balance:

Withdraw

Moving ETH from an L2 account back to L1 is a withdrawal.

Withdrawals involve three transaction phases:

Withdrawal Initiating Transaction

Call L2CrossDomainMessenger.sendMessage on L2 with: target (L1 destination), message (L1 calldata), minGasLimit (minimum gas for finalizing).

Building these parameters is non-trivial; use the Optimism SDK or the bridge UI.

Then wait for op-proposer to include the initiating transaction in an output root submitted to L1. Each new output proposal commits the sendMessage mapping state, proving a pending withdrawal.

If useFaultProofs=false, the proposer submits to L2OutputOracle every l2OutputOracleSubmissionInterval (default 120 L2 blocks ≈ 4 minutes), stored on-chain as submissionInterval.

If useFaultProofs=true, the proposer submits to DisputeGameFactory every --proposal-interval.

Withdrawal Proving Transaction

Once an output root containing the MessagePassed event is on L1, you must prove the withdrawal exists.

Off-chain steps:

  1. Fetch withdrawal calldata from L2 (target, nonce, value, etc.).

  2. Find the outputRoot index that includes the initiating event.

  3. Get proofs from L2: latest state root and block hash, account state root, and storage proof for the message. Tip: eth_getProof returns state proofs given account, storage slot (message hash), and block.

  4. Call OptimismPortal.proveWithdrawalTransaction() on L1 with the proof.

On-chain steps:

  1. OptimismPortal.proveWithdrawalTransaction() runs sanity checks.

  2. Verifies the message hash exists in L2ToL1MessagePasser.sentMessages and has not been proven.

  3. Records outputRoot, timestamp, and index in provenWithdrawals and emits an event.

Then wait for the finalization period (finalizationPeriodSeconds) before finalizing.

If fault proofs are enabled (OptimismPortal2), proofs are recorded in faultDisputeGame and may be challenged; after maturity, op-challenger resolves via resolveClaim()/resolve().

Withdrawal Finalizing Transaction

After the challenge period, finalize the withdrawal on L1.

Using the Optimism SDK

The Optimism SDK makes deposits, withdrawals, and sending transactions straightforward. A withdrawal moves ETH from an L2 account back to L1.

Create a demo project to use the Optimism SDK:

Set the private key environment variable

Now create a CrossChainMessenger via the OP SDK to easily handle cross-domain messages between L1 and L2.

Check Balances:

Deposit ETH:

Withdraw ETH:

Initiate the withdrawal on L2

At this point, your L2 balance has decreased but the L1 wallet balance is unchanged. Wait until the withdrawal is ready to be proved to the L1 bridge contract.

Send an L1 transaction to prove the L2 withdrawal.

The final step is to relay the withdrawal on L1, which only occurs after the fault-proof window. In this test chain it is set to 12 seconds.

Relay.

Full example:

Last updated