Escape Hatch
Overview
This page describes the necessary steps to withdraw funds from the Zircuit network in the event blocks cease to be produced for a long period of time. These steps bypass the conventional withdrawal mechanisms of Zircuit and allow for a forced withdrawal of assets.
These steps utilise functionally called an escape hatch, which allows users to remove Ether and tokens from a Zircuit network in case a state update has not occurred in the past 30 days. Assets that are withdrawn this way are said to be escaped. For the purpose of this page, the L2 (layer 2) network is the Zircuit Legacy Testnet and the L1 (layer 1) is Sepolia.
Eligible Assets for Escape
The Zircuit Legacy Testnet escape hatch allows the withdrawal of Legacy Testnet ETH and WETH from OptimismPortal
, and any ERC20 tokens that were bridged using Zircuit L1StandardBridge
on Sepolia. These funds account for the majority of assets present on the Legacy Testnet.
Throughout this page, several assumptions are made in order to simplify the instructions to withdraw the assets described above. Future versions of this functionality will support additional assets; be sure to use the instructions for the right escape hatch.
Escape Hatch Tooling
Scripts that implement the functionality necessary to escape ETH and ERC20 tokens can be found here, which implement the functionality described below. Users who are not interested in understanding how the escape hatch works can simply use the scripts.
Escaping with EOAs
These instructions can be used once the escape hatch functionality is enabled, due to a lack of state root update.
Escaping ETH
Users can initiate ETH withdrawals via the escapeETH()
function on the OptimismPortalProxy
contract. This process requires generating and verifying inclusion proofs to reconstruct the user's balance on L2 at the last known valid state.
/// @notice Function to withdraw ETH that user had on L2 if more than 30 days passed since output root was
/// published.
/// @param _outputRootProof Inclusion proof of the L2ToL1MessagePasser contract's storage root.
/// @param _accountState State of user account on L2.
/// @param _proof Proof of account state on L2.
function escapeETH(
Types.OutputRootProof calldata _outputRootProof,
Types.AccountState calldata _accountState,
bytes[] calldata _proof
)
external
Here is a TypeScript/ethers.js example showing how to prepare and submit an escapeETH()
transaction.
import * as ethers from "ethers";
import L2OutputOracle from "./L2OutputOracle.json";
import OptimismPortal from "./OptimismPortal.json";
const accountForEscapeAddress = "<EOA ADDRESS>"
const l1Provider = new ethers.providers.JsonRpcProvider("<L1 PROVIDER>")
const l2Provider = new ethers.providers.JsonRpcProvider("<L2 PROVIDER>")
const l2tol1MessagePasserAddress = "0x4200000000000000000000000000000000000016"
const L2OutputOracleAddress = "<L2OuputOracle ADDRESS>"
const OptimismPortalAddress = "<OptimismPortal ADDRESS>"
const L2OutputOracleContract = new ethers.Contract(L2OutputOracleAddress, L2OutputOracle.abi, l1Provider);
const OptimismPortalContract = new ethers.Contract(OptimismPortalAddress, OptimismPortal.abi, l1Provider);
// Fetch the most recent output root published
const latestOutputIndex = await L2OutputOracleContract.latestOutputIndex();
const latestOutput = await L2OutputOracleContract.getL2Output(latestOutputIndex);
let l2OutputBlockNumber = latestOutput.l2BlockNumber
// Obtain the arguments required to verify the state root of the L2 agains the output root that was published in the L2OutputOracle
const l2Block = await l2Provider.send('eth_getBlockByNumber', [l2OutputBlockNumber.toHexString(), false]);
let messagePasserStorageRoot = (await l2Provider.send('eth_getProof', [
l2tol1MessagePasserAddress,
[],
l2OutputBlockNumber.toHexString()
])).storageHash
let outputRoot = ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode([ "uint", "uint","uint","uint"],["0x0",l2Block.stateRoot,messagePasserStorageRoot,l2Block.hash]))
if (outputRoot !== latestOutput.outputRoot) {
throw new Error("Roots do not match!")
}
// Obtain the merkle proof of the account state that we are trying to escape
let accountProof = await l2Provider.send('eth_getProof', [
accountForEscapeAddress,
[],
l2OutputBlockNumber.toHexString()
])
let escapeTx = await OptimismPortalContract.populateTransaction.escapeETH(
{
version: ethers.constants.HashZero,
stateRoot: l2Block.stateRoot,
messagePasserStorageRoot: messagePasserStorageRoot,
latestBlockhash: l2Block.hash
},
{
nonce: accountProof.nonce,
balance: accountProof.balance,
storageRoot: accountProof.storageHash,
codeHash: accountProof.keccakCodeHash,
},
accountProof.accountProof
)
Escaping ERC20s
Unlike ETH, which is tracked via account balances, ERC20 token balances are stored in smart contract storage slots. Escaping ERC20s therefore requires not only proving the state of the ERC20 contract itself, but also providing a Merkle proof of the user’s token balance stored in the contract's storage trie.
The escape mechanism for ERC20s is available via the escapeERC20()
function on the L1StandardBridgeProxy
contract on Ethereum.
/// @notice Allows users to escape ERC20 tokens if no output root has been published for over 30 days.
/// @param _localToken Address of the token on L1.
/// @param _remoteToken Addres of the corresponding token on L2.
/// @param _outputRootProof Inclusion proof of the L2ToL1MessagePasser contract's storage root.
/// @param _accountState State of the ERC20 token contract on L2.
/// @param _stateProof Proof of the ERC20 contract state.
/// @param _tokenBalance Balance the user had of the ERC20 on L2.
/// @param _storageProof Proof of value on the storage slot with the user balance.
function escapeERC20(
address _localToken,
address _remoteToken,
Types.OutputRootProof calldata _outputRootProof,
Types.AccountState calldata _accountState,
bytes[] calldata _stateProof,
uint256 _tokenBalance,
bytes[] calldata _storageProof
)
Here’s a TypeScript/ethers.js script that shows how to gather the necessary proofs and invoke the escapeERC20()
function.
import * as ethers from "ethers";
import L2OutputOracle from "./L2OutputOracle.json";
import L1StandardBridge from "./L1StandardBridge.json";
const accountForEscapeAddress = "<EOA ADDRESS>"
const l2ERC20ForEscape = "<ADDRESS OF ERC20 ON L2>"
const l1ERC20ForEscape = "<ADDRESS OF ERC20 ON L1>"
const l1Provider = new ethers.providers.JsonRpcProvider("<L1 PROVIDER>")
const l2Provider = new ethers.providers.JsonRpcProvider("<L2 PROVIDER>")
const l2tol1MessagePasserAddress = "0x4200000000000000000000000000000000000016"
const L2OutputOracleAddress = "<L2OuputOracle ADDRESS>"
const L1StandardBridgeAddress = "<L1StandarBridge ADDRESS>"
const L2OutputOracleContract = new ethers.Contract(L2OutputOracleAddress, L2OutputOracle.abi, l1Provider);
const L1StandardBridgeContract = new ethers.Contract(L1StandardBridgeAddress, L1StandardBridge.abi, l1Provider);
// Fetch the most recent output root published
const latestOutputIndex = await L2OutputOracleContract.latestOutputIndex();
const latestOutput = await L2OutputOracleContract.getL2Output(latestOutputIndex);
let l2OutputBlockNumber = latestOutput.l2BlockNumber
// Obtain the arguments required to verify the state root of the L2 agains the output root that was published in the L2OutputOracle
const l2Block = await l2Provider.send('eth_getBlockByNumber', [l2OutputBlockNumber.toHexString(), false]);
let messagePasserStorageRoot = (await l2Provider.send('eth_getProof', [
l2tol1MessagePasserAddress,
[],
l2OutputBlockNumber.toHexString()
])).storageHash
let outputRoot = ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode([ "uint", "uint","uint","uint"],["0x0",l2Block.stateRoot,messagePasserStorageRoot,l2Block.hash]))
if (outputRoot !== latestOutput.outputRoot) {
throw new Error("Roots do not match!")
}
// Determine the storage slot were the user balance was stored in the L2 ERC20
const UserERC20BalanceStorageSlot = ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["uint","uint"],[accountForEscapeAddress,0]))
// Get merkle proof of the ERC20 L2 account state and merkle proof of user balance
let ERC20AccountProof = await l2Provider.send('eth_getProof', [
l2ERC20ForEscape,
[UserERC20BalanceStorageSlot],
l2OutputBlockNumber.toHexString()
])
let escapeTx = await L1StandardBridgeContract.populateTransaction.escapeERC20(
l1ERC20ForEscape,
l2ERC20ForEscape,
{
version: ethers.constants.HashZero,
stateRoot: l2Block.stateRoot,
messagePasserStorageRoot: messagePasserStorageRoot,
latestBlockhash: l2Block.hash
},
{
nonce: ERC20AccountProof.nonce,
balance: ERC20AccountProof.balance,
storageRoot: ERC20AccountProof.storageHash,
codeHash: ERC20AccountProof.keccakCodeHash,
},
ERC20AccountProof.accountProof,
ERC20AccountProof.storageProof[0].value,
ERC20AccountProof.storageProof[0].proof
)
In this example, it is assumed that ERC20 balances are stored in a mapping
at slot 0
as this is the slot of the default mintable ERC20 used by the StandardBridge
.
Escaping Assets Held in Smart Contracts
When users deposit assets into smart contracts on L2 (e.g., staking pools or token vaults), those assets are often pooled. This makes escape more complex than with EOAs, since:
The contract may hold tokens on behalf of many users.
Each user's entitlement must be calculated from contract state, not just simple balances.
To solve this, Zircuit introduces resolver contracts on L1, which encapsulate the logic needed to read the L2 contract’s storage and compute how much a user can rightfully claim. Resolver contracts are explained in this paper.
Further Reading
Additional details can be found in the paper describing the escape hatch mechanism.
Last updated