Developer SDK
The @arbitrum/sdk surface for cross-chain messaging, retryables, and bridging assets between L1 and L2.
Third-party documentation. This is independently authored analysis of the public Arbitrum Nitro codebase — not the official docs, and not reviewed or endorsed by the Arbitrum Nitro team.
Developer SDK
Application developers interact with Arbitrum through its Ethereum-compatible RPC for day-to-day transactions, and through the @arbitrum/sdk (TypeScript) whenever value or messages cross the L1↔L2 boundary. This module covers the SDK's core surface and the conventions around it.
Ethereum compatibility first
Before reaching for the SDK, remember that most of an Arbitrum app is ordinary Ethereum development: deploy contracts with the same tooling, call them with ethers/web3 against the L2 RPC, and read events with standard indexers. The SDK exists only for the parts that are genuinely L2-specific.
Core SDK objects
- EthBridger — deposits and withdrawals of ETH, wrapping the Inbox/Outbox + ArbSys path.
- Erc20Bridger — the same, for ERC-20 tokens, including the approval step and L2 token address derivation.
- getL2Network(chainId) — loads the canonical network metadata (bridge contract addresses, inbox, sequencer, etc.) for a known Arbitrum chain, removing the need to hardcode addresses.
- L1ToL2MessageWriter / L1ToL2MessageReader — inspect and wait for L1→L2 messages (retryables), including status (
NOT_YET_CREATED,FUNDS_DEPOSITED_ON_L1,REDEEMED,EXPIRED, ...). - L2ToL1MessageWriter — the withdrawal lifecycle: wait for the assertion, then redeem through the Outbox.
Common patterns
- Deposit ETH —
new EthBridger(l1, l2).deposit(), thenawait deposit.wait()(L1) andawait deposit.waitForL1ToL2Message()(L2). - Deposit ERC-20 —
approveToken(...)first, thendeposit(...); the SDK computes the L2 gateway from the L1 token. - Withdraw — initiate on L2, then after the challenge window, redeem on L1. The SDK's
waitUntilOutboxExecutedhandles the waiting. - Retryables — for custom L1→L2 messaging, construct the ticket directly (see the Examples module).
Chain configuration
The nitro genesis/config and node configuration are driven by env vars + TOML/YAML config files (chain ID, L1 URL, sequencer/validator toggles, feed ports). There is a small ecosystem of helper images (e.g. the nitro Docker image, nitro-local-dev for a single-node devnet) that make local development on the full stack practical.
Edge cases
- The SDK's network registry knows public Arbitrum chains; for a custom Nitro chain you must pass bridge addresses explicitly (the registry is not magic).
- Always bind the correct signer to the correct provider: an L1 signer can't execute an Outbox redemption, and an L2 signer can't create a retryable on L1.
- Retryable submission fees are in wei on L1 but the execution budget is in L2 gas — mixing the two units is the most common SDK mistake.
Examples
Deposit ETH with the EthBridger
import { EthBridger } from "@arbitrum/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 bridger = new EthBridger(l1, l2);const deposit = await bridger.deposit({amount: ethers.parseEther("1.0"),from: signer.address,overrides: { from: signer.address },});await deposit.wait();const receipt = await deposit.waitForL1ToL2Message();console.log("L2 status:", receipt.status);console.log("L2 txn:", receipt.l2TxnHash);
The deposit triggers an L1->L2 retryable ticket; waitForL1ToL2Message resolves when it executes on L2.
Create a retryable ticket directly
import { getL2Network, L1ToL2MessageWriter } from "@arbitrum/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);// A retryable is a self-funding L1->L2 message: even if it initially// runs out of gas, anyone can re-execute it later with more gas.const network = await getL2Network(42161);const inbox = new ethers.Contract(network.ethBridge.inbox, INBOX_ABI, signer);const tx = await inbox.createRetryableTicket(signer.address, // to0, // l2CallValueethers.parseEther("0.01"), // maxSubmissionCostsigner.address, // excessFeeRefundAddresssigner.address, // callValueRefundAddress100000, // gasLimitethers.parseUnits("0.1", 9), // maxFeePerGas (in gwei)"0x" // data);await tx.wait();console.log("Retryable created:", tx.hash);
The low-level retryable construction. Most builders use Erc20Bridger/EthBridger instead; direct retryables matter when you need custom re-execution semantics.
Withdraw ERC-20 to L1
import { Erc20Bridger, Erc20L1Transaction } from "@arbitrum/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 bridger = new Erc20Bridger(l1, l2);const withdrawal = await bridger.withdraw({erc20l1Address: TOKEN_L1,amount: ethers.parseEther("5"),destinationAddress: signer.address,from: signer.address,});await withdrawal.wait(); // L2 tx// After the dispute window (~8 days on One), redeem on L1:const l1Withdrawal = await withdrawal.waitUntilOutboxExecuted(1_800_000);console.log("Outbox tx:", l1Withdrawal.transactionHash);
Withdrawals finalize in two stages: an L2 message, then an Outbox redemption after the challenge window.
Edge Cases
- The EthBridger must be constructed with BOTH providers bound, otherwise auto-detection of chain ids fails and deposit() throws.
- Retryables that permanently run out of gas (their maxSubmissionCost covers only submission) can be redeemed only while the ticket is valid — always set gasLimit generously.
- Custom tokens using non-standard decimals or fee-on-transfer mechanics break Erc20Bridger's internal math — pin the standard token shape or pre-compute exact amounts.
Interface
import { Erc20Bridger, EthBridger, L1ToL2MessageStatus } from "@arbitrum/sdk";import { ethers } from "ethers";const l1 = new ethers.JsonRpcProvider(L1_RPC);const l2 = new ethers.JsonRpcProvider(L2_RPC);const signer = new ethers.Wallet(PRIVATE_KEY, l1);// ETH bridging is one object:const ethBridger = new EthBridger(l1, l2);const deposit = await ethBridger.deposit({amount: ethers.parseEther("0.5"),from: signer.address,overrides: { from: signer.address },});// ...and the returned message tracks the L1->L2 lifecycle:await deposit.wait(); // L1 deposit includedconst msg = await deposit.waitForL2ToL1Message(); // (withdrawals)// ERC-20s are symmetric but need approval first:const erc20Bridger = new Erc20Bridger(l1, l2);await erc20Bridger.approveToken({ erc20L1Address: TOKEN, signer });const d = await erc20Bridger.deposit({erc20L1Address: TOKEN,amount: ethers.parseEther("10"),signer,});
