An OP Stack L2-to-L1 withdrawal on Base is a two-phase process: first you initiate the withdrawal on L2 by calling initiateWithdrawal on the L2ToL1MessagePasser predeploy, which records a withdrawal hash in the sentMessages mapping; then, after the fault-proof challenge window has elapsed, you prove and finalize the withdrawal on L1 through the OptimismPortal contract. Proving requires an output root proof anchored to a finalized L2 output and a storage proof (eth_getProof) against the L2ToL1MessagePasser storage root, which means the L2 node you query must still retain the state for the block that contains your withdrawal. Finalization is gated by the challenge window, a protocol parameter that has changed across OP Stack releases and varies by chain and upgrade. This guide walks through each stage over JSON-RPC, shows how to read sentMessages and portal state, and provides a reproducible status table so you can verify every step independently.
The OP Stack L2-to-L1 withdrawal lifecycle on Base
Base is an OP Stack L2, and its withdrawal mechanism follows the OP Stack specification: a user initiates a withdrawal on L2, waits for the fault-proof challenge window to pass, then proves and finalizes the withdrawal on L1. The OP Stack Withdrawals specification defines the contracts and message format, while the Optimism Docs withdrawal guide describes the operational flow. Unlike deposits, which are driven by L1 events and can be replayed on L2, withdrawals require an explicit L1 proof that the L2 state transition is final.
The lifecycle has four observable stages: initiation on L2, output root proposal on L1, proving on L1, and finalization on L1. Each stage can be verified independently over RPC, which is useful when you are reconciling a withdrawal in an indexer or support workflow. If you are new to Base RPC access, the Base network page and the Base RPC endpoint guide cover endpoint selection and chain IDs.
- Initiation: call initiateWithdrawal on L2ToL1MessagePasser; the withdrawal hash is appended to sentMessages.
- Output root proposal: a permissionless proposer posts an L2 output root to L1; index availability can lag.
- Proving: call proveWithdrawalTransaction on OptimismPortal with an output root proof and a storage proof.
- Finalization: after the challenge window, call finalizeWithdrawalTransaction to release funds on L1.
The L2ToL1MessagePasser predeploy and initiateWithdrawal
The L2ToL1MessagePasser is a predeploy contract at 0x4200000000000000000000000000000000000016 on every OP Stack chain, including Base. Its initiateWithdrawal function accepts the withdrawal parameters (target, gasLimit, data) and records the withdrawal by computing a hash and setting sentMessages[hash] = true. The value sent with the call is escrowed in the contract on L2; it is not burned in the strict sense, but it is locked until the corresponding L1 finalization releases it from the OptimismPortal.
The withdrawal hash is computed over the tuple (nonce, sender, target, value, gasLimit, data). In the OP Stack reference implementation this is Hashing.hashWithdrawal, which hashes the ABI-encoded withdrawal struct. The nonce is the L2ToL1MessagePasser's nonce at the time of the call, and the sender is the L2 account that called initiateWithdrawal. Because the hash depends on all of these fields, you must capture them exactly to reproduce it later.
- Predeploy address: 0x4200000000000000000000000000000000000016
- Function: initiateWithdrawal(address _target, uint256 _gasLimit, bytes _data) payable
- Storage mapping: sentMessages[bytes32 withdrawalHash] => bool
- Hash inputs: nonce, sender, target, value, gasLimit, data
Computing the withdrawal hash and reading sentMessages over L2 RPC
To confirm that a withdrawal was initiated, you compute the withdrawal hash locally and then read sentMessages[hash] from the L2ToL1MessagePasser storage. The mapping is at a known storage slot; the slot for a mapping value is keccak256(abi.encode(key, slot)). The L2ToL1MessagePasser stores sentMessages at slot 0 in the reference implementation, but you should verify the slot against the deployed contract for your chain and upgrade.
The following Node.js example uses viem to compute the withdrawal hash and read the storage slot over a Base RPC endpoint. It assumes you have the withdrawal parameters from the L2 transaction receipt or from your own call data. Replace the RPC URL with your provider endpoint; the API service page describes how OnFinality exposes Base RPC.
import { createPublicClient, http, keccak256, encodeAbiParameters, parseAbiParameters } from 'viem';
import { base } from 'viem/chains';
const client = createPublicClient({ chain: base, transport: http('https://base.api.onfinality.io/public') });
const MESSAGE_PASSER = '0x4200000000000000000000000000000000000016';
const SENT_MESSAGES_SLOT = 0n;
// Withdrawal parameters captured from the L2 transaction
const withdrawal = {
nonce: 0n,
sender: '0xYourL2SenderAddress',
target: '0xYourL1TargetAddress',
value: 1000000000000000n,
gasLimit: 100000n,
data: '0x'
};
// Compute the withdrawal hash (matches Hashing.hashWithdrawal)
const encoded = encodeAbiParameters(
parseAbiParameters('uint256, address, address, uint256, uint256, bytes'),
[withdrawal.nonce, withdrawal.sender, withdrawal.target, withdrawal.value, withdrawal.gasLimit, withdrawal.data]
);
const withdrawalHash = keccak256(encoded);
console.log('withdrawalHash:', withdrawalHash);
// Compute the storage slot for sentMessages[withdrawalHash]
const slot = keccak256(
encodeAbiParameters(parseAbiParameters('bytes32, uint256'), [withdrawalHash, SENT_MESSAGES_SLOT])
);
// Read the storage value over L2 RPC
const storage = await client.getStorageAt({
address: MESSAGE_PASSER,
slot,
blockNumber: 'latest'
});
console.log('sentMessages[hash]:', storage);
// A non-zero value confirms the withdrawal was initiated.Why finalization is gated by the fault-proof challenge window
A withdrawal cannot be finalized on L1 immediately after initiation because the L2 state transition that includes the withdrawal must first be finalized through the fault-proof system. In OP Stack chains, this is the challenge window: a period during which anyone can dispute the proposed L2 output root. The window is a protocol parameter that has changed across OP Stack releases and varies by chain and upgrade; the commonly cited figure is approximately seven days, but you should treat it as documented / varies by chain and upgrade rather than a fixed constant.
The practical consequence is that the release timing of your L1 funds is determined by when the output root that includes your withdrawal becomes finalized, not by when you initiated the withdrawal. Output roots are proposed by permissionless proposers, so the index of the output root containing your withdrawal can lag behind L2 head. If you are tracking finality semantics more broadly, see Base OP Stack finality, safe and finalized blocks.
- Challenge window: a dispute period for proposed L2 output roots; approximately seven days in many deployments.
- Output root proposal: permissionless; index availability can lag L2 head.
- Finalization gate: the output root containing your withdrawal must be finalized on L1.
- Parameter drift: window length and contract interfaces have changed across OP Stack releases.
The OptimismPortal and the proveWithdrawalTransaction call
The OptimismPortal is the L1 contract that holds escrowed funds and processes withdrawals. Its proveWithdrawalTransaction function takes the withdrawal parameters, an output root proof, and a storage proof. The output root proof anchors your withdrawal to a specific L2 output root that has been proposed to L1; the storage proof demonstrates that sentMessages[withdrawalHash] is set in the L2ToL1MessagePasser storage at that output root's L2 block.
The output root proof includes the L2 state root, the message passer storage root, the L2 block hash, and the L2 block number. The storage proof is generated by eth_getProof against the L2ToL1MessagePasser address at the same L2 block. Because the proof is anchored to a specific L2 block, the L2 node you query must still retain the state for that block. A node that has pruned the needed L2 block cannot produce the proof, which is why archive access is often required for withdrawals that are proved well after initiation.
- OptimismPortal: L1 contract that escrows funds and processes prove/finalize calls.
- proveWithdrawalTransaction: submits the withdrawal, output root proof, and storage proof.
- Output root proof: L2 state root, message passer storage root, L2 block hash, L2 block number.
- Storage proof: eth_getProof against L2ToL1MessagePasser at the anchored L2 block.
Building the L1 proof from L2 archive state with eth_getProof
The storage proof for proveWithdrawalTransaction is produced by eth_getProof on the L2 node. The call takes the L2ToL1MessagePasser address, an array of storage keys (the sentMessages slot for your withdrawal hash), and the L2 block number that corresponds to the output root you are proving against. The response includes the account proof, the storage proof, and the storage root, which you then assemble into the output root proof and storage proof arguments.
The following example shows the eth_getProof call shape and how to read the portal state to determine whether finalization is unlocked. It uses raw JSON-RPC for the proof call so the request shape is explicit, and viem for the portal read. The portal exposes a params or dispute game interface that varies by OP Stack version; you should read the deployed ABI for your chain and upgrade.
import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet } from 'viem/chains';
const l1 = createPublicClient({ chain: mainnet, transport: http('https://eth.api.onfinality.io/public') });
const l2Rpc = 'https://base.api.onfinality.io/public';
const MESSAGE_PASSER = '0x4200000000000000000000000000000000000016';
const withdrawalHash = '0xYourWithdrawalHash';
const l2BlockNumber = '0xYourL2BlockNumberHex';
// Compute the storage slot for sentMessages[withdrawalHash]
// (slot 0 in the reference implementation; verify against the deployed contract)
const slot = '0xYourComputedStorageSlot';
// eth_getProof against the L2ToL1MessagePasser at the anchored L2 block
const proof = await fetch(l2Rpc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_getProof',
params: [MESSAGE_PASSER, [slot], l2BlockNumber]
})
}).then(r => r.json());
console.log('accountProof:', proof.result.accountProof.length, 'nodes');
console.log('storageProof:', JSON.stringify(proof.result.storageProof));
// Read the portal to check whether finalization is unlocked
const PORTAL = '0xYourOptimismPortalAddress';
const portalAbi = parseAbi([
'function finalizedWithdrawals(bytes32) view returns (bool)',
'function proveWithdrawalTransaction((uint256,address,address,uint256,uint256,bytes),uint256,(bytes32,bytes32,bytes32,bytes32),bytes[])'
]);
const isFinalized = await l1.readContract({
address: PORTAL,
abi: portalAbi,
functionName: 'finalizedWithdrawals',
args: [withdrawalHash]
});
console.log('finalizedWithdrawals[hash]:', isFinalized);Reading the challenge window and polling for finalization eligibility
To know when finalization is allowed, you need to determine how much of the challenge window remains for the output root that anchors your withdrawal. The OptimismPortal and the dispute game contracts expose state that lets you compute this, but the exact interface varies by OP Stack version. In older deployments, the L2OutputOracle exposes the timestamp of the output and the finalization period; in newer deployments, the dispute game exposes the game's creation time and resolution status. You should read the deployed ABI for your chain and upgrade rather than assuming a single interface.
A practical polling strategy is to read the portal's finalizedWithdrawals mapping and the relevant output or game state on each poll, and to treat finalization as unlocked only when the output root is finalized and the challenge window has elapsed. Because output roots are proposed by permissionless proposers, you may need to poll for the output root index that contains your withdrawal before you can prove it. The Base OP Stack L1 derivation and timestamp page covers how L1 timestamps relate to L2 blocks, which is useful when reasoning about window expiry.
- Read the portal's finalizedWithdrawals mapping to check whether finalization already occurred.
- Read the output oracle or dispute game state to determine window expiry.
- Poll for the output root index that contains your withdrawal; proposer timing can lag.
- Treat finalization as unlocked only when the output root is finalized and the window has elapsed.
Reproducible withdrawal status table and independent verification
Because withdrawal timing depends on protocol parameters and proposer behavior, the most reliable approach is to record each stage in a status table and verify it independently. The table below is a template you fill in per withdrawal; each column corresponds to an RPC call or transaction you can re-run to confirm the stage. This makes the workflow auditable and helps you isolate which stage is lagging when a withdrawal appears stuck.
When reconciling withdrawals in an indexer, the same discipline applies: verify the L2 initiation, the output root proposal, the L1 prove transaction, and the L1 finalize transaction as separate events. The Block-by-block EVM indexer reconciliation page describes a general reconciliation approach that maps well to withdrawal tracking.
- L2 tx hash: the transaction that called initiateWithdrawal; verify status and logs.
- Withdrawal hash: computed from nonce, sender, target, value, gasLimit, data; verify against sentMessages.
- Output root index: the L1 output proposal that includes your withdrawal; verify it is finalized.
- Prove tx: the L1 transaction calling proveWithdrawalTransaction; verify the proof was accepted.
- Finalize tx: the L1 transaction calling finalizeWithdrawalTransaction; verify finalizedWithdrawals is true.
- Window expiry: the timestamp after which finalization is allowed; verify against the deployed contract.
Troubleshooting common withdrawal proof and finalization failures
Most withdrawal failures fall into a few categories: the proof cannot be generated because the L2 node has pruned the needed block, the output root index is not yet available, the challenge window has not elapsed, or the proof arguments do not match the anchored output root. Each of these produces a distinct error or revert reason, and each can be diagnosed with a specific RPC call. Start by confirming that sentMessages[withdrawalHash] is set on L2 at the block you intend to prove against.
If eth_getProof fails or returns an error about missing state, the node you are querying has likely pruned the L2 block. You need an archive node or a provider that retains historical state for the relevant block. If proveWithdrawalTransaction reverts, check that the output root proof matches the output root that was actually proposed, and that the L2 block number in the proof corresponds to the block that contains your withdrawal. If finalizeWithdrawalTransaction reverts, check the challenge window and the finalizedWithdrawals mapping.
- Missing state: the L2 node pruned the block; use an archive node or historical-state provider.
- Output root mismatch: the proof anchors to a different output root than the one proposed.
- Window not elapsed: finalization is gated by the challenge window; poll the portal state.
- Already finalized: finalizedWithdrawals[hash] is true; the withdrawal has been released.
- Wrong storage slot: verify the sentMessages slot against the deployed contract for your chain.
Limitations, tradeoffs, and protocol parameter drift
The challenge window is a protocol parameter that has changed over OP Stack releases and varies by chain and upgrade; you should not hard-code a single value. Output roots are proposed by permissionless proposers, so the index availability for your withdrawal can lag behind L2 head, and there is no guarantee that a proposer will post at a specific cadence. The L2 state needed for a proof must still be retained by the node you query, which means archive access is often required for withdrawals proved well after initiation.
A finalized withdrawal is final on L1, but the L2 burn is irreversible if the L1 proof is wrong. In other words, if you prove against the wrong output root or with malformed proof arguments, you may be unable to finalize, and the L2 escrow remains locked. This is why independent verification of each stage, as described in the status table above, is worth the effort. For endpoint and pricing considerations when running these queries at scale, see RPC pricing.
- Challenge window: documented / varies by chain and upgrade; do not hard-code.
- Output root proposal: permissionless; index availability can lag.
- L2 state retention: the node you query must still hold the needed block.
- Irreversibility: a wrong L1 proof can leave the L2 escrow locked.
- Interface drift: portal and oracle ABIs vary across OP Stack versions.
Next steps for production withdrawal tracking
For production systems, the most robust approach is to treat each withdrawal as a state machine with independently verifiable stages, and to persist the withdrawal hash, output root index, prove transaction, and finalize transaction as separate records. This lets you retry proving or finalization without re-deriving the entire history, and it makes it easier to surface which stage is lagging. If you are building on Base, the Base network page and the OnFinality Learn hub are good starting points for related RPC topics.
If you need to compare the deposit side of the flow, the Base OP Stack deposit events and withdrawal proofs page covers L1-to-L2 deposits and the deposit hash. For endpoint selection and tooling, the Base RPC endpoint guide and the API service page describe how to connect. As always, verify protocol parameters against the deployed contracts for your chain and upgrade before relying on them in automation.
- Model each withdrawal as a state machine with independently verifiable stages.
- Persist withdrawal hash, output root index, prove tx, and finalize tx separately.
- Retry proving or finalization without re-deriving the entire history.
- Verify protocol parameters against deployed contracts for your chain and upgrade.