Forced withdrawal from an OP Stack blockchain


# What is this?

Any assets you own on an OP Stack blockchain are backed by equivalent assets on the underlying L1, locked in a bridge. In this article you learn how to withdraw these assets directly from L1.

Note that the steps here do require access to an L2 endpoint. However, that L2 endpoint can be a read-only replica.

# Setup

The code to go along with this article is available in our tutorials repository (opens new window).

  1. Clone the repository, move to the correct directory, and install the required dependencies.

    git clone https://github.com/ethereum-optimism/optimism-tutorial.git
    cd optimism-tutorial/op-stack/forced-withdrawal
    npm install
    
    1
    2
    3
  2. Copy the environment setup variables.

    cp .env.example .env
    
    1
  3. Edit .env to set these variables:

    Variable Meaning
    L1URL URL to L1 (Goerli if you followed the directions on this site)
    L2URL URL to the L2 from which you are withdrawing
    PRIV_KEY Private key for an account that has ETH on L2. It also needs ETH on L1 to submit transactions
    OPTIMISM_PORTAL_ADDR Address of the OptimismPortalProxy on L1.

# Withdrawal

# ETH withdrawals

The easiest way to withdraw ETH is to send it to the bridge, or the cross domain messenger, on L2.

  1. Enter the Hardhat console.

    npx hardhat console --network l1
    
    1
  2. Specify the amount of ETH you want to transfer. This code transfers one hundred'th of an ETH.

    transferAmt = BigInt(0.01 * 1e18)
    
    1
  3. Create a contract object for the OptimismPortal (opens new window) contract.

    optimismContracts = require("@eth-optimism/contracts-bedrock")
    optimismPortalData = optimismContracts.getContractDefinition("OptimismPortal")
    optimismPortal = new ethers.Contract(process.env.OPTIMISM_PORTAL_ADDR, optimismPortalData.abi, await ethers.getSigner())
    
    1
    2
    3
  4. Send the transaction.

    txn = await optimismPortal.depositTransaction(
       optimismContracts.predeploys.L2StandardBridge,
       transferAmt,
       1e6, false, []
    )
    rcpt = await txn.wait()
    
    1
    2
    3
    4
    5
    6
  5. To prove (opens new window) and finalize (opens new window) the message we need the hash. Optimism's core-utils package (opens new window) has the necessary function.

    optimismCoreUtils = require("@eth-optimism/core-utils")
    withdrawalData = new optimismCoreUtils.DepositTx({
       from: (await ethers.getSigner()).address,
       to: optimismContracts.predeploys.L2StandardBridge,
       mint: 0,
       value: ethers.BigNumber.from(transferAmt),
       gas: 1e6,
       isSystemTransaction: false,
       data: "",
       domain: optimismCoreUtils.SourceHashDomain.UserDeposit,
       l1BlockHash: rcpt.blockHash,
       logIndex: rcpt.logs[0].logIndex,
    })
    withdrawalHash = withdrawalData.hash()
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
  6. Create the object for the L1 contracts, as explained in the documentation. You will create an object similar to this one:

    L1Contracts = {
       StateCommitmentChain: '0x0000000000000000000000000000000000000000',
       CanonicalTransactionChain: '0x0000000000000000000000000000000000000000',
       BondManager: '0x0000000000000000000000000000000000000000',
       AddressManager: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',
       L1CrossDomainMessenger: '0x27E8cBC25C0Aa2C831a356bbCcc91f4e7c48EeeE',
       L1StandardBridge: '0x154EaA56f8cB658bcD5d4b9701e1483A414A14Df',
       OptimismPortal: '0x4AD19e14C1FD57986dae669BE4ee9C904431572C',
       L2OutputOracle: '0xE6Dfba0953616Bacab0c9A8ecb3a9BBa77FC15c0'
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
  7. Create the data structure for the standard bridge.

     bridges = { 
       Standard: { 
          l1Bridge: l1Contracts.L1StandardBridge, 
          l2Bridge: "0x4200000000000000000000000000000000000010", 
          Adapter: optimismSDK.StandardBridgeAdapter
       },
       ETH: {
          l1Bridge: l1Contracts.L1StandardBridge, 
          l2Bridge: "0x4200000000000000000000000000000000000010", 
          Adapter: optimismSDK.ETHBridgeAdapter
       }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
  8. Create a cross domain messenger (opens new window). This step, and subsequent ETH withdrawal steps, are explained in this tutorial (opens new window).

    optimismSDK = require("@eth-optimism/sdk")
    l2Provider = new ethers.providers.JsonRpcProvider(process.env.L2URL)
    await l2Provider._networkPromise
    crossChainMessenger = new optimismSDK.CrossChainMessenger({
       l1ChainId: ethers.provider.network.chainId,
       l2ChainId: l2Provider.network.chainId,
       l1SignerOrProvider: await ethers.getSigner(),
       l2SignerOrProvider: l2Provider,
       bedrock: true,
       contracts: {
          l1: l1Contracts
       },
       bridges: bridges
    })   
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
  9. Wait for the message status for the withdrawal to become READY_TO_PROVE. By default, the state root is written every four minutes, so you're likely to need to wait.

    await crossChainMessenger.waitForMessageStatus(withdrawalHash, 
        optimismSDK.MessageStatus.READY_TO_PROVE)
    
    1
    2
  10. Submit the withdrawal proof.

    await crossChainMessenger.proveMessage(withdrawalHash)
    
    1
  11. Wait for the message status for the withdrawal to become READY_FOR_RELAY. This waits the challenge period (7 days in production, but a lot less on test networks).

    await crossChainMessenger.waitForMessageStatus(withdrawalHash, 
       optimismSDK.MessageStatus.READY_FOR_RELAY)
    
    1
    2
  12. Finalize the withdrawal. See that your balance changes by the withdrawal amount.

    myAddr = (await ethers.getSigner()).address
    balance0 = await ethers.provider.getBalance(myAddr)
    finalTxn = await crossChainMessenger.finalizeMessage(withdrawalHash)
    finalRcpt = await finalTxn.wait()
    balance1 = await ethers.provider.getBalance(myAddr)
    withdrawnAmt = BigInt(balance1)-BigInt(balance0)
    
    1
    2
    3
    4
    5
    6

transferAmt > withdrawnAmt

Your L1 balance doesn't increase by the entire transferAmt because of the cost of crossChainMessenger.finalizeMessage, which submits a transaction.