Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Network & Protocol Guides14 min read

Tracking Base Cross-Chain Events: Deposit Logs and Withdrawal Proofs

Learn to parse L1->L2 deposit logs and L2->L1 withdrawal proofs on Base using RPC, with runnable examples and troubleshooting.

TL;DR

To track cross-chain activity on Base, you must read logs on both Ethereum (L1) and Base (L2). An L1->L2 deposit is initiated by a transaction to the OptimismPortal that emits a TransactionDeposited event; op-node derives a deposit transaction that appears in a Base block with its own L2 hash and receipt. For L2->L1 withdrawals, you start with a MessagePassed event on L2, then prove and finalize on L1 using output roots and dispute games. This guide explains the event structures, provides runnable RPC examples to parse and verify them, and includes a troubleshooting checklist.

Direct Answer: How to Track Base Cross-Chain Events

To track cross-chain activity on Base, you must read logs on both Ethereum (L1) and Base (L2). An L1->L2 deposit is initiated by a transaction to the OptimismPortal that emits a TransactionDeposited event; op-node derives a deposit transaction that appears in a Base block with its own L2 hash and receipt. For L2->L1 withdrawals, you start with a MessagePassed event on L2, then prove and finalize on L1 using output roots and dispute games. This guide explains the event structures, provides runnable RPC examples to parse and verify them, and includes a troubleshooting checklist.

The key is to never confuse the L1 deposit transaction hash with the resulting L2 transaction hash. The L1 hash identifies the portal call; the L2 hash is derived from the deposit and appears in a subsequent Base block. You can confirm inclusion by querying the L2 receipt and checking its status. For withdrawals, the process involves three phases: initiate on L2, prove on L1, and finalize on L1. Each phase emits distinct events that you can monitor with RPC logs.

This guide is part of the OnFinality Learn hub and complements our Base network RPC endpoints (RPC Assistant) and OP-Stack finality and safe/finalized blocks on Base articles.

Mechanism: L1 to L2 Deposits on OP-Stack

On OP-Stack chains like Base, an L1->L2 deposit is a two-step process. First, a user calls the depositTransaction function on the OptimismPortal contract on Ethereum. This emits a TransactionDeposited event with fields that encode the deposit. Second, op-node (the consensus client) derives a deposit transaction from that event and includes it in a Base block. The resulting L2 transaction has its own hash and receipt, distinct from the L1 transaction.

The TransactionDeposited event is defined in the OptimismPortal interface. Its indexed fields include from, to, version, and opaqueData (the latter is not indexed in newer versions). The opaqueData contains the mint, value, gasLimit, and calldata for the L2 transaction. To parse it, you need the correct ABI and must know how many indexed fields to expect—older versions had different indexing.

For a detailed protocol specification, refer to the OP Stack specs on deposits. The Base documentation also describes chain-specific parameters. When integrating, always verify against the current contract ABIs, as the protocol evolves (e.g., the Ecotone upgrade changed some event structures).

  • The L1 transaction hash is not the L2 transaction hash.
  • The deposit appears in a Base block after the L1 block is processed by op-node.
  • You can track deposits by scanning TransactionDeposited logs on the portal address.
  • The L2 receipt's status field confirms successful execution.

Mechanism: L2 to L1 Withdrawals and Proofs

Withdrawals from Base to Ethereum are more complex. On L2, a user calls withdrawTransaction (or initiateWithdrawal in newer interfaces) on the L2CrossDomainMessenger, which emits a MessagePassed event. This event contains the withdrawal hash, nonce, sender, target, and data. The withdrawal is not final until it is proven and finalized on L1.

After the withdrawal is initiated, the L2 output root that includes the withdrawal must be proposed on L1. In the current Superchain mode, this is done via a DisputeGame contract. A relayer submits a proof that the withdrawal is included in a proposed output root, and after a dispute window passes, the withdrawal can be finalized. In the legacy system, an output proposer submitted output roots directly to the OptimismPortal.

The proving step calls proveWithdrawalTransaction on the OptimismPortal, passing the withdrawal hash, proof, and output root proof. Finalization calls finalizeWithdrawalTransaction. Both steps emit events: WithdrawalProven and WithdrawalFinalized on L1, and RelayedMessage on L2 when the message is executed.

For authoritative details, see the OP Stack specs on withdrawals. The phase names and contract addresses change with upgrades, so always check the latest deployment artifacts.

Runnable Example: Parsing Deposit Logs and Verifying L2 Receipts

The following Node.js script uses ethers.js to connect to both an L1 and an L2 endpoint. It scans for TransactionDeposited events on the OptimismPortal for a specific from address, then queries the L2 receipt for the derived transaction. You must replace the endpoint URLs and contract addresses with your own (see Base network RPC endpoints (RPC Assistant) for Base endpoints).

The script demonstrates how to filter logs by address and topic, decode the event data, and then use the depositCount or block number to find the corresponding L2 transaction. In practice, you might use a relayer or indexer that listens to real-time logs via WebSocket, but this example uses eth_getLogs for simplicity.

const { ethers } = require('ethers');

// Replace with your endpoints and addresses
const L1_RPC = 'https://eth-mainnet.example.com';
const L2_RPC = 'https://base-mainnet.example.com';
const PORTAL_ADDRESS = '0x...'; // OptimismPortal on L1

const portalAbi = [
  'event TransactionDeposited(address indexed from, address indexed to, uint256 indexed version, bytes opaqueData)'
];

async function main() {
  const l1Provider = new ethers.JsonRpcProvider(L1_RPC);
  const l2Provider = new ethers.JsonRpcProvider(L2_RPC);
  const portal = new ethers.Contract(PORTAL_ADDRESS, portalAbi, l1Provider);

  // Example: scan last 1000 blocks for deposits from a specific address
  const fromAddress = '0x...'; // depositor address
  const latestBlock = await l1Provider.getBlockNumber();
  const fromBlock = latestBlock - 1000;

  const filter = portal.filters.TransactionDeposited(fromAddress);
  const logs = await l1Provider.getLogs({
    ...filter,
    fromBlock,
    toBlock: latestBlock
  });

  for (const log of logs) {
    const parsed = portal.interface.parseLog(log);
    console.log('L1 deposit log:', log.transactionHash);
    console.log('Depositor:', parsed.args.from);
    console.log('Target:', parsed.args.to);
    console.log('Version:', parsed.args.version);
    // opaqueData is bytes; you need to decode further to get mint, value, gasLimit, data
    // For simplicity, we just log the raw data
    console.log('OpaqueData:', parsed.args.opaqueData);

    // Wait for the deposit to be included in an L2 block
    // In practice, you would poll or use a relayer's depositCount
    // Here we just wait a few seconds and then query the L2 receipt
    // You need to derive the L2 tx hash from the deposit event; this is non-trivial
    // For demonstration, we assume you have a mapping from depositCount to L2 tx hash
    // Instead, we show how to check the L2 receipt for a known L2 hash
    const l2TxHash = '0x...'; // derived from deposit
    const receipt = await l2Provider.getTransactionReceipt(l2TxHash);
    if (receipt) {
      console.log('L2 receipt status:', receipt.status); // 1 = success
    } else {
      console.log('L2 transaction not found yet');
    }
  }
}

main().catch(console.error);

Runnable Example: Reading Withdrawal Proofs and Finalization

For withdrawals, you need to monitor MessagePassed events on L2 and then track the proving and finalization on L1. The following script demonstrates how to query MessagePassed logs from the L2CrossDomainMessenger and then check for WithdrawalProven and WithdrawalFinalized events on the OptimismPortal.

This example is simplified; in a real integration you would use the withdrawal hash to correlate events across chains. The MessagePassed event includes the withdrawal hash as an indexed field, which you can use to filter L1 logs.

const { ethers } = require('ethers');

const L1_RPC = 'https://eth-mainnet.example.com';
const L2_RPC = 'https://base-mainnet.example.com';
const L2_MESSENGER = '0x...'; // L2CrossDomainMessenger on Base
const PORTAL = '0x...'; // OptimismPortal on L1

const l2MessengerAbi = [
  'event MessagePassed(uint256 indexed nonce, address indexed sender, address indexed target, uint256 value, uint256 gasLimit, bytes data, bytes32 withdrawalHash)'
];
const portalAbi = [
  'event WithdrawalProven(bytes32 indexed withdrawalHash, address indexed from, address indexed to, uint256 timestamp)',
  'event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success)'
];

async function main() {
  const l2Provider = new ethers.JsonRpcProvider(L2_RPC);
  const l1Provider = new ethers.JsonRpcProvider(L1_RPC);
  const l2Messenger = new ethers.Contract(L2_MESSENGER, l2MessengerAbi, l2Provider);
  const portal = new ethers.Contract(PORTAL, portalAbi, l1Provider);

  // Get recent MessagePassed events
  const latestL2Block = await l2Provider.getBlockNumber();
  const filter = l2Messenger.filters.MessagePassed();
  const logs = await l2Provider.getLogs({
    ...filter,
    fromBlock: latestL2Block - 1000,
    toBlock: latestL2Block
  });

  for (const log of logs) {
    const parsed = l2Messenger.interface.parseLog(log);
    const withdrawalHash = parsed.args.withdrawalHash;
    console.log('MessagePassed on L2:', log.transactionHash);
    console.log('Withdrawal hash:', withdrawalHash);

    // Check L1 for proof and finalization events
    const proofFilter = portal.filters.WithdrawalProven(withdrawalHash);
    const proofLogs = await l1Provider.getLogs({
      ...proofFilter,
      fromBlock: 0,
      toBlock: 'latest'
    });
    console.log('Proof events found:', proofLogs.length);

    const finalFilter = portal.filters.WithdrawalFinalized(withdrawalHash);
    const finalLogs = await l1Provider.getLogs({
      ...finalFilter,
      fromBlock: 0,
      toBlock: 'latest'
    });
    console.log('Finalization events found:', finalLogs.length);
  }
}

main().catch(console.error);

Troubleshooting Checklist for Cross-Chain Event Tracking

When integrating cross-chain event tracking, you may encounter several common issues. Use this checklist to diagnose problems.

First, verify that you are using the correct portal address for the chain version. Base has undergone upgrades, and the portal address may change. Always fetch the latest deployment from the official Base documentation or the OP Stack superchain registry.

Second, ensure your event decoding matches the current ABI. The number of indexed fields in TransactionDeposited changed over time. If you see garbled data, check the ABI version.

Third, if you see an L1 deposit event but no corresponding L2 transaction, wait for the next derived block. Deposits are processed asynchronously; the L2 block may not be produced immediately. You can poll the L2 endpoint for the expected transaction hash.

Fourth, when scanning historical logs, be aware of block range limits. Many providers restrict eth_getLogs to a certain range (e.g., 10,000 blocks). If you need older events, chunk your scan into smaller ranges. This is a documented method; see Monitoring RPC endpoints and node health for more on rate limits.

Finally, for withdrawals, remember that 'proved' is not 'finalized'. The dispute window must pass before finalization. If you see a WithdrawalProven event but no WithdrawalFinalized, it may simply be waiting for the window to elapse.

  • Wrong portal address for the chain version.
  • Incorrect ABI or indexed field count.
  • Deposit not yet included in an L2 block.
  • Block range too large for eth_getLogs.
  • Confusing 'proved' with 'finalized'.

Limitations and Protocol Upgrades

Cross-chain event tracking is subject to protocol limitations and upgrades. Deposit finality on L2 follows the safe layer header, which may lag behind the latest block. Withdrawal outputs mature on L1 only after the dispute window passes, which can take days. These timing constraints are defined by the protocol and can change with upgrades.

For example, the transition from the output-root system to dispute games (fault proofs) changed how withdrawals are proven. Always refer to the current OP Stack specifications and the latest contract ABIs. Do not rely on hardcoded addresses or event signatures without verification.

When using RPC providers, be aware that rate limits and block range caps vary by provider. OnFinality's RPC pricing page documents our service, but for other providers, check their documentation. For production systems, consider using a dedicated API service to handle high throughput.

Next Steps and Further Reading

Now that you understand how to track cross-chain events, you can build a relayer, indexer, or wallet integration. Start by setting up reliable RPC endpoints for both Ethereum and Base. OnFinality provides Base network RPC endpoints (RPC Assistant) and Base network RPC endpoints (RPC Assistant) for production use.

To deepen your understanding of Base's finality model, read OP-Stack finality and safe/finalized blocks on Base. For handling RPC errors, see Base RPC timeouts and retries. If you need historical data, consult Base archive nodes and historical state.

For general RPC best practices, explore the OnFinality Learn hub and Monitoring RPC endpoints and node health.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started