Antler by Autoflux

Developer SDK

The developer-facing surface: @eth-optimism/sdk for cross-domain messaging, chain config, and the CLI/chain-launching tooling.

Path: sdk

Third-party documentation. This is independently authored analysis of the public Optimism (OP Stack) codebase — not the official docs, and not reviewed or endorsed by the Optimism (OP Stack) team.

Developer SDK

Most application developers never run a node — they interact with an OP Stack chain through its public RPC and through the @eth-optimism/sdk, which wraps the cross-domain messaging and bridging contracts in a typed, ergonomic TypeScript API. This module covers the SDK and the adjacent developer-facing tooling.

The CrossChainMessenger

The core class. It binds an L1 and an L2 provider/signer, knows the standard bridge and messenger contract addresses for known chain IDs, and exposes the three families of operations:

  • Deposits: depositETH, depositERC20 — L1 → L2.
  • Withdrawals: withdrawETH, withdrawERC20 — L2 → L1, including the prove/finalize phases.
  • Cross-chain messages: sendMessage, getMessageStatus, approveERC20.

A nice property of the SDK is that the returned message objects carry status transitions, so the three-phase withdrawal flow is exposed as waitForStatus(...) calls rather than raw contract calls.

Contract address registry

For standard chains (OP Mainnet, and Superchain chains that follow the standard deployment), the SDK derives addresses from a chain-ID registry. For a custom OP Stack chain, you must either register your chain ID with the deployment-standard tooling or pass the contract addresses explicitly — the SDK does not guess.

Chain configuration & launching tooling

Beyond messaging, the OP Stack's developer experience is defined by its configuration and deployment machinery:

  • op-chain / op-deployer — tooling to launch an entire OP Stack chain (L1 contracts, genesis, rollup config, and service configs) from a single declarative config, rather than by hand-wiring each service.
  • Superchain Registry — a canonical registry of chain metadata (RPC URLs, explorer, token addresses, L1/L2 contract deployments) that tools like the SDK and block explorers read from.
  • ops-bedrock — the docker-compose-based devnet for running the full stack locally.

Where the SDK fits with plain RPC

For transactions that stay on the L2 (transfers, contract calls), plain ethers/web3 against the L2 RPC is the right tool — identical to L1. The SDK is only needed at the boundary: whenever value or messages cross between L1 and L2, or when you need to reason about a cross-chain message's status.

Edge cases

  • ERC-20 deposits require two approvals on L1 — the token must approve the bridge, not the messenger; the SDK's approveERC20 handles the correct target.
  • Custom tokens that don't follow standard ERC-20 semantics (fee-on-transfer, non-standard decimals) can break the bridge's accounting — the standard bridge assumes a well-behaved token.
  • Message status polling can be slow on withdrawals because the prove phase depends on an output root being posted; expect minutes, not seconds.

Examples

Deposit ETH L1 → L2

Deposit ETH L1 → L2
typescript
import { CrossChainMessenger } from "@eth-optimism/sdk";
import { ethers } from "ethers";
 
const l1 = new ethers.JsonRpcProvider(process.env.L1_RPC!);
const l2 = new ethers.JsonRpcProvider(process.env.L2_RPC!);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, l1);
 
const messenger = new CrossChainMessenger({
l1SignerOrProvider: signer,
l2SignerOrProvider: l2,
l1ChainId: 1,
l2ChainId: 10,
bedrock: true,
});
 
// Move 0.1 ETH to L2. Returns a CrossChainMessage you can await twice:
// .wait() for the L1 receipt, .waitL2() for the L2 receipt.
const msg = await messenger.depositETH(ethers.parseEther("0.1"));
await msg.wait();
await msg.waitL2();
 
const balance = await messenger.getL2BridgeContracts()
.then(() => l2.getBalance(signer.address));
console.log("L2 balance:", ethers.formatEther(balance));
STATUSexample

The canonical way to move ETH into OP Mainnet. waitL2() resolves once the deposit transaction is included in an L2 block — typically a few minutes.

Withdraw ERC-20 L2 → L1

Withdraw ERC-20 L2 → L1
typescript
import { CrossChainMessenger } from "@eth-optimism/sdk";
import { ethers } from "ethers";
 
const l1 = new ethers.JsonRpcProvider(process.env.L1_RPC!);
const l2 = new ethers.JsonRpcProvider(process.env.L2_RPC!);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, l2);
 
const messenger = new CrossChainMessenger({
l1SignerOrProvider: signer,
l2SignerOrProvider: l1,
l1ChainId: 1,
l2ChainId: 10,
bedrock: true,
});
 
// Initiate the withdrawal on L2, then wait through the challenge window
// (7 days on OP Mainnet) before proving + finalizing on L1.
const msg = await messenger.withdrawERC20(TOKEN_ADDRESS, TOKEN_ADDRESS_L1, amount);
await msg.wait(); // L2 side: withdrawal initiated
await msg.waitForStatus(CrossChainMessageStatus.READY_TO_PROVE);
await msg.prove(); // submit the output-root proof to the portal
await msg.finalize(); // finalize once the dispute window passes
STATUSexample

Withdrawals require three phases — initiate, prove, finalize — separated by the challenge window. The SDK wraps all three.

Edge Cases

  • WaitForStatus can hang if the output root your withdrawal references isn't posted yet — poll with a timeout instead of awaiting blindly.
  • The SDK derives bridge contract addresses from the chain ID registry; for a custom OP Stack chain you must register its chain ID or pass contracts explicitly.
  • A deposit that runs out of gas on L2 still succeeds on L1 — the message just sits in the inbox undelivered. Fund the L2 side or re-send with a higher gas limit.

Interface

Interface
typescript
import { CrossChainMessenger, ETH_ERC20_BRIDGE } from "@eth-optimism/sdk";
import { ethers } from "ethers";
 
const l1Provider = new ethers.JsonRpcProvider(L1_URL);
const l2Provider = new ethers.JsonRpcProvider(L2_URL);
const signer = new ethers.Wallet(PRIVATE_KEY, l1Provider);
 
// depositETH / withdrawETH / bridgeERC20 are the three big helpers;
// all of them sit on top of the CrossDomainMessenger contract pair.
const messenger = new CrossChainMessenger({
l1SignerOrProvider: signer,
l2SignerOrProvider: l2Provider,
l1ChainId: 1,
l2ChainId: 10,
bedrock: true,
});
 
const tx = await messenger.depositETH(ethers.parseEther("0.1"));
await tx.wait(); // L1 tx (portal deposit)
await tx.waitL2(); // L2 tx (deposit message executed)
 
STATUSinterface