Skip to content

Commit

Permalink
feat: Automate withdrawing ETH from OpStack chains (#1866)
Browse files Browse the repository at this point in the history
* feat: Automate withdrawing ETH from OpStack chains

- Adds a new script to withdraw ETH via the OVM standard L2 bridge to the signer's address on L1
- Adds a feature to the OP stack finalizer that lets the user specify addresses that they want to finalize OVM withdrawals for, in addition to any existing SpokePool withdrawals. This pairs with the above script to allow us to one-step manually move ETH from OP stack chains back to the same EOA on Ethereum
- To use this new feature, we need to add new chains to the finalizer config (i.e. 7777777 and 1135) and also set `WITHDRAWAL_TO_ADDRESSES=[x]` where `x` are addresses that we plan to execute manual withdrawals from

* Update opStack.ts

* Update OpStackStandardBridgeL2.json

* Update opStack.ts

* Update Constants.ts

* Update scripts/withdrawFromOpStack.ts

Co-authored-by: Paul <[email protected]>

* Add safety checks

* Update opStack.ts

* Update opStack.ts

---------

Co-authored-by: Paul <[email protected]>
  • Loading branch information
nicholaspai and pxrl authored Oct 22, 2024
1 parent 3de3d54 commit 119ca6b
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 1 deletion.
16 changes: 15 additions & 1 deletion scripts/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "assert";
import { Contract, ethers, utils as ethersUtils } from "ethers";
import { Contract, ethers, utils as ethersUtils, Signer } from "ethers";
import readline from "readline";
import * as contracts from "@across-protocol/contracts";
import { utils as sdkUtils } from "@across-protocol/sdk";
Expand Down Expand Up @@ -133,3 +133,17 @@ export async function getSpokePoolContract(chainId: number): Promise<Contract> {
const contract = new Contract(spokePoolAddr, contracts.SpokePool__factory.abi);
return contract;
}

/**
* @description Instantiate an Across OVM SpokePool contract instance.
* @param chainId Chain ID for the SpokePool deployment.
* @returns SpokePool contract instance.
*/
export async function getOvmSpokePoolContract(chainId: number, signer?: Signer): Promise<Contract> {
const hubChainId = resolveHubChainId(chainId);
const hubPool = await getContract(hubChainId, "HubPool");
const spokePoolAddr = (await hubPool.crossChainContracts(chainId))[1];

const contract = new Contract(spokePoolAddr, contracts.Ovm_SpokePool__factory.abi, signer);
return contract;
}
121 changes: 121 additions & 0 deletions scripts/withdrawFromOpStack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Submits a bridge from OpStack L2 to L1.
// For now, this script only supports ETH withdrawals.

import {
ethers,
retrieveSignerFromCLIArgs,
getProvider,
ERC20,
WETH9,
TOKEN_SYMBOLS_MAP,
assert,
getL1TokenInfo,
Contract,
fromWei,
blockExplorerLink,
CHAIN_IDs,
} from "../src/utils";
import { CONTRACT_ADDRESSES } from "../src/common";
import { askYesNoQuestion, getOvmSpokePoolContract } from "./utils";

import minimist from "minimist";

const cliArgs = ["amount", "chainId"];
const args = minimist(process.argv.slice(2), {
string: cliArgs,
});

// Example run:
// ts-node ./scripts/withdrawFromOpStack.ts
// \ --amount 3000000000000000000
// \ --chainId 1135
// \ --wallet gckms
// \ --keys bot1

export async function run(): Promise<void> {
assert(
cliArgs.every((cliArg) => Object.keys(args).includes(cliArg)),
`Missing cliArg, expected: ${cliArgs}`
);
const baseSigner = await retrieveSignerFromCLIArgs();
const signerAddr = await baseSigner.getAddress();
const chainId = parseInt(args.chainId);
const connectedSigner = baseSigner.connect(await getProvider(chainId));
const l2Token = TOKEN_SYMBOLS_MAP.WETH?.addresses[chainId];
assert(l2Token, `WETH not found on chain ${chainId} in TOKEN_SYMBOLS_MAP`);
const l1TokenInfo = getL1TokenInfo(l2Token, chainId);
console.log("Fetched L1 token info:", l1TokenInfo);
assert(l1TokenInfo.symbol === "ETH", "Only WETH withdrawals are supported for now.");
const amount = args.amount;
const amountFromWei = ethers.utils.formatUnits(amount, l1TokenInfo.decimals);
console.log(`Amount to bridge from chain ${chainId}: ${amountFromWei} ${l2Token}`);

const erc20 = new Contract(l2Token, ERC20.abi, connectedSigner);
const currentBalance = await erc20.balanceOf(signerAddr);
const currentEthBalance = await connectedSigner.getBalance();
console.log(
`Current WETH balance for account ${signerAddr}: ${fromWei(currentBalance, l1TokenInfo.decimals)} ${l2Token}`
);
console.log(`Current ETH balance for account ${signerAddr}: ${fromWei(currentEthBalance, l1TokenInfo.decimals)}`);

// First offer user option to unwrap WETH into ETH.
const weth = new Contract(l2Token, WETH9.abi, connectedSigner);
if (await askYesNoQuestion(`\nUnwrap ${amount} of WETH @ ${weth.address}?`)) {
const unwrap = await weth.withdraw(amount);
console.log(`Submitted transaction: ${blockExplorerLink(unwrap.hash, chainId)}.`);
const receipt = await unwrap.wait();
console.log("Unwrap complete...", receipt);
}

// Now, submit a withdrawal:
const ovmStandardBridgeObj = CONTRACT_ADDRESSES[chainId].ovmStandardBridge;
assert(CONTRACT_ADDRESSES[chainId].ovmStandardBridge, "ovmStandardBridge for chain not found in CONTRACT_ADDRESSES");
const ovmStandardBridge = new Contract(ovmStandardBridgeObj.address, ovmStandardBridgeObj.abi, connectedSigner);
const bridgeETHToArgs = [
signerAddr, // to
200_000, // minGasLimit
"0x", // extraData
{ value: amount }, // msg.value
];

console.log(
`Submitting bridgeETHTo on the OVM standard bridge @ ${ovmStandardBridge.address} with the following args: `,
...bridgeETHToArgs
);

// Sanity check that the ovmStandardBridge contract is the one we expect by comparing its stored addresses
// with the ones we have recorded.
const spokePool = await getOvmSpokePoolContract(chainId, connectedSigner);
const expectedL2Messenger = await spokePool.MESSENGER();
const l2Messenger = await ovmStandardBridge.MESSENGER();
assert(
l2Messenger === expectedL2Messenger,
`Unexpected L2 messenger address in ovmStandardBridge contract, expected: ${expectedL2Messenger}, got: ${l2Messenger}`
);
const l1StandardBridge = await ovmStandardBridge.l1TokenBridge();
const expectedL1StandardBridge = CONTRACT_ADDRESSES[CHAIN_IDs.MAINNET][`ovmStandardBridge_${chainId}`].address;
assert(
l1StandardBridge === expectedL1StandardBridge,
`Unexpected L1 standard bridge address in ovmStandardBridge contract, expected: ${expectedL1StandardBridge}, got: ${l1StandardBridge}`
);
if (!(await askYesNoQuestion("\nDo you want to proceed?"))) {
return;
}
const withdrawal = await ovmStandardBridge.bridgeETHTo(...bridgeETHToArgs);
console.log(`Submitted withdrawal: ${blockExplorerLink(withdrawal.hash, chainId)}.`);
const receipt = await withdrawal.wait();
console.log("Receipt", receipt);
}

if (require.main === module) {
run()
.then(async () => {
// eslint-disable-next-line no-process-exit
process.exit(0);
})
.catch(async (error) => {
console.error("Process exited with", error);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
}
1 change: 1 addition & 0 deletions src/common/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export const multicall3Addresses = {
[CHAIN_IDs.MODE]: "0xcA11bde05977b3631167028862bE2a173976CA11",
[CHAIN_IDs.OPTIMISM]: "0xcA11bde05977b3631167028862bE2a173976CA11",
[CHAIN_IDs.POLYGON]: "0xcA11bde05977b3631167028862bE2a173976CA11",
[CHAIN_IDs.REDSTONE]: "0xcA11bde05977b3631167028862bE2a173976CA11",
[CHAIN_IDs.SCROLL]: "0xcA11bde05977b3631167028862bE2a173976CA11",
[CHAIN_IDs.WORLD_CHAIN]: "0xcA11bde05977b3631167028862bE2a173976CA11",
[CHAIN_IDs.ZK_SYNC]: "0xF9cda624FBC7e059355ce98a31693d299FACd963",
Expand Down
48 changes: 48 additions & 0 deletions src/common/abi/OpStackStandardBridgeL2.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,53 @@
],
"name": "DepositFinalized",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "internalType": "address", "name": "from", "type": "address" },
{ "indexed": true, "internalType": "address", "name": "to", "type": "address" },
{ "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" },
{ "indexed": false, "internalType": "bytes", "name": "extraData", "type": "bytes" }
],
"name": "ETHBridgeInitiated",
"type": "event"
},
{
"inputs": [
{ "internalType": "address", "name": "_to", "type": "address" },
{ "internalType": "uint32", "name": "_minGasLimit", "type": "uint32" },
{ "internalType": "bytes", "name": "_extraData", "type": "bytes" }
],
"name": "bridgeETHTo",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "MESSENGER",
"outputs": [
{
"internalType": "contract CrossDomainMessenger",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "l1TokenBridge",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
}
]
8 changes: 8 additions & 0 deletions src/finalizer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ const chainFinalizers: { [chainId: number]: { finalizeOnL2: ChainFinalizer[]; fi
finalizeOnL1: [opStackFinalizer],
finalizeOnL2: [],
},
[CHAIN_IDs.ZORA]: {
finalizeOnL1: [opStackFinalizer],
finalizeOnL2: [],
},
[CHAIN_IDs.REDSTONE]: {
finalizeOnL1: [opStackFinalizer],
finalizeOnL2: [],
},
[CHAIN_IDs.BLAST]: {
finalizeOnL1: [opStackFinalizer],
finalizeOnL2: [],
Expand Down
48 changes: 48 additions & 0 deletions src/finalizer/utils/opStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
chainIsProd,
Contract,
ethers,
paginatedEventQuery,
} from "../../utils";
import { CONTRACT_ADDRESSES, Multicall2Call, OPSTACK_CONTRACT_OVERRIDES } from "../../common";
import { FinalizerPromise, CrossChainMessage } from "../types";
Expand Down Expand Up @@ -94,6 +95,53 @@ export async function opStackFinalizer(
latestBlockToProve,
});

// Experimental feature: Add in all ETH withdrawals from OPStack chain to the finalizer. This will help us
// in the short term to automate ETH withdrawals from Lite chains, which can build up ETH balances over time
// and because they are lite chains, our only way to withdraw them is to initiate a slow bridge of ETH from the
// the lite chain to Ethereum.
const withdrawalToAddresses: string[] = process.env.FINALIZER_WITHDRAWAL_TO_ADDRESSES
? JSON.parse(process.env.FINALIZER_WITHDRAWAL_TO_ADDRESSES).map((address) => ethers.utils.getAddress(address))
: [];
if (!CONTRACT_ADDRESSES[chainId].ovmStandardBridge) {
logger.warn({
at: "opStackFinalizer",
message: `No OVM standard bridge contract found for chain ${networkName} in CONTRACT_ADDRESSES`,
});
} else if (withdrawalToAddresses.length > 0) {
const ovmStandardBridge = new Contract(
CONTRACT_ADDRESSES[chainId].ovmStandardBridge.address,
CONTRACT_ADDRESSES[chainId].ovmStandardBridge.abi,
spokePoolClient.spokePool.provider
);
const withdrawalEvents = await paginatedEventQuery(
ovmStandardBridge,
ovmStandardBridge.filters.ETHBridgeInitiated(
null, // from
withdrawalToAddresses // to
),
{
...spokePoolClient.eventSearchConfig,
toBlock: spokePoolClient.latestBlockSearched,
}
);
// If there are any found withdrawal initiated events, then add them to the list of TokenBridged events we'll
// submit proofs and finalizations for.
withdrawalEvents.forEach((event) => {
const tokenBridgedEvent: TokensBridged = {
...event,
amountToReturn: event.args.amount,
chainId,
leafId: 0,
l2TokenAddress: TOKEN_SYMBOLS_MAP.WETH.addresses[chainId],
};
if (event.blockNumber >= latestBlockToProve) {
recentTokensBridgedEvents.push(tokenBridgedEvent);
} else {
olderTokensBridgedEvents.push(tokenBridgedEvent);
}
});
}

const proofs = await multicallOptimismL1Proofs(
chainId,
recentTokensBridgedEvents,
Expand Down

0 comments on commit 119ca6b

Please sign in to comment.