Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Automate withdrawing ETH from OpStack chains #1866

Merged
merged 10 commits into from
Oct 22, 2024
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;
}
pxrl marked this conversation as resolved.
Show resolved Hide resolved
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`,
});
pxrl marked this conversation as resolved.
Show resolved Hide resolved
} 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