diff --git a/deploy/consts.ts b/deploy/consts.ts index c943bbd7e..1bec23085 100644 --- a/deploy/consts.ts +++ b/deploy/consts.ts @@ -1,6 +1,7 @@ import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../utils"; export const WETH = TOKEN_SYMBOLS_MAP.WETH.addresses; +export const MOCK_ADMIN = "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D"; export const L1_ADDRESS_MAP: { [key: number]: { [contractName: string]: string } } = { 1: { diff --git a/hardhat.config.ts b/hardhat.config.ts index ba64baab8..a76416fb2 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -22,6 +22,8 @@ require("./tasks/enableL1TokenAcrossEcosystem"); require("./tasks/finalizeScrollClaims"); // eslint-disable-next-line node/no-missing-require require("./tasks/rescueStuckScrollTxn"); +// eslint-disable-next-line node/no-missing-require +require("./tasks/tokenTraversal"); dotenv.config(); diff --git a/tasks/tokenTraversal.ts b/tasks/tokenTraversal.ts new file mode 100644 index 000000000..91a2dff34 --- /dev/null +++ b/tasks/tokenTraversal.ts @@ -0,0 +1,132 @@ +import { getNodeUrl, EMPTY_MERKLE_ROOT } from "@uma/common"; +import { TOKEN_SYMBOLS_MAP, CHAIN_IDs } from "@across-protocol/constants"; +import { task } from "hardhat/config"; +import { minimalSpokePoolInterface, minimalAdapterInterface } from "./utils"; +import { Event, Wallet, providers, Contract, ethers, bnZero } from "ethers"; + +/** + * ``` + * yarn hardhat token-traversal \ + * --network base --adapter [adapter_address] --spokePool [spoke_pool_address] --value 0.02 + * ``` + * This REQUIRES a spoke pool to be deployed to the specified network AND for the + * spoke pool to have the signer as the `crossDomainAdmin`. + */ + +task("token-traversal", "Test L1 <-> L2 communication between a deployed L1 adapter and a L2 spoke pool") + .addParam("adapter", "address of the L1 adapter to use") + .addParam("spoke", "address of the L1 spoke pool to use") + .addOptionalParam( + "value", + "amount of ETH to send with transaction. This should only be used in special cases since improper use could nuke funds" + ) + .addOptionalParam("l2token", "The l2 token address to send over via the adapter. Defaults to weth.") + .addOptionalParam("amountToSend", "amount of token to send over via the adapter. Defaults to 1000") + .setAction(async function (taskArguments, hre_) { + const hre = hre_ as any; + const msgValue = ethers.utils.parseEther(taskArguments.value === undefined ? "0" : value); + + /* + * Setup: Need to obtain all contract addresses involved and instantiate L1/L2 providers and contracts. + */ + // Instantiate providers/signers/chainIds + const l2ChainId = parseInt(await hre.getChainId()); + const l1ChainId = parseInt(await hre.companionNetworks.l1.getChainId()); + + const l2ProviderUrl = hre.network.config.url; + + const baseNetwork = l1ChainId === CHAIN_IDs.MAINNET ? "mainnet" : "sepolia"; + const l1ProviderUrl = hre.config.networks[`${baseNetwork}`].url; + + const l2Provider = new ethers.providers.JsonRpcProvider(l2ProviderUrl); + const l2Signer = ethers.Wallet.fromMnemonic((hre.network.config.accounts as any).mnemonic).connect(l2Provider); + const l1Provider = new ethers.providers.JsonRpcProvider(l1ProviderUrl); + const l1Signer = ethers.Wallet.fromMnemonic((hre.network.config.accounts as any).mnemonic).connect(l1Provider); + + const tokenAddress = taskArguments.token ?? TOKEN_SYMBOLS_MAP.WETH.addresses[l2ChainId]; + if (!tokenAddress) { + throw new Error( + "[-] No token address specified and cannot default to WETH. Please set the `l2token` parameter to the l2 token address to send back to the hub pool" + ); + } + const amountToReturn = taskArguments.amountToSend ?? 1000; + + // Construct the contracts + const spokePool = new Contract(taskArguments.spokePool, minimalSpokePoolInterface, l2Signer); + const adapter = new Contract(taskArguments.adapter, minimalAdapterInterface, l1Signer); + + console.log("[+] Successfully constructed all contracts. Determining root bundle Id to use."); + let rootBundleId = 0; + try { + while (1) { + await spokePool.rootBundles(rootBundleId); + rootBundleId++; + } + } catch (e) { + console.log(`[+] Obtained latest root bundle Id ${rootBundleId}`); + } + /* + * Step 1: Craft and send a message to be sent to the provided L1 chain adapter contract. This message should be used to call `relayRootBundle` on the + * associated L2 contract + */ + const rootBundleType = "tuple(uint256,uint256,uint256[],uint32,address,address[])"; + // Construct the root bundle + const encodedRootBundle = ethers.utils.defaultAbiCoder.encode( + [rootBundleType], + [[amountToReturn, l2ChainId, [], 0, tokenAddress, []]] + ); + const rootBundleHash = ethers.utils.keccak256(encodedRootBundle); + // Submit the root bundle to chain. + const relayRootBundleTxnData = spokePool.interface.encodeFunctionData("relayRootBundle", [ + rootBundleHash, + EMPTY_MERKLE_ROOT, + ]); + const adapterTxn = await adapter.relayMessage(spokePool.address, relayRootBundleTxnData, { value }); + const txn = await adapterTxn.wait(); + console.log( + `[+] Called L1 adapter (${adapter.address}) to relay refund leaf message to mock spoke pool at ${spokePool.address}. Txn: ${txn.transactionHash}` + ); + /* + * Step 2: Spin until we observe the message to be executed on the L2. This should take ~3 minutes. + */ + // It would be nice to just have a websocket listening for the `RelayedRootBundle` event, but I don't want to assume a websocket provider. + console.log( + "[i] Optimistically waiting 5 minutes for L1 message to propagate. If root bundle is not observed, will check spoke every minute thereafter." + ); + const fiveMins = 1000 * 60 * 5; + await delay(fiveMins); + + // We should be able to query the canonical messenger to see if our L1 message was propagated, but this requires us to instantiate a unique L2 messenger contract + // for each new chain we make, which is not scalable. Instead, we query whether our root bundle is in the spoke pool contract, as this is generalizable and does not + // require us to instantiate any new contract. + while (1) { + try { + // Check the root bundle + await spokePool.rootBundles(rootBundleId); + break; + } catch (e) { + // No root bundle made it yet. Continue to spin + console.log("[-] Root bundle not found on L2. Waiting another 60 seconds."); + await delay(1000 * 60); + } + } + console.log("[+] Root bundle observed on L2 spoke pool. Attempting to execute."); + /* + * Step 3: Call `executeRelayerRefund` on the target spoke pool to send funds back to the hub pool (or, whatever was initialized as the `hubPool` in the deploy + * script, which is likely the dev EOA). + */ + const executeRelayerRefundLeaf = await spokePool.executeRelayerRefundLeaf( + rootBundleId, + [amountToReturn, l2ChainId, [], 0, tokenAddress, []], + [] + ); + const l2Txn = await executeRelayerRefundLeaf.wait(); + console.log( + `[+] Executed root bundle with transaction hash ${l2Txn.transactionHash}. You can now test the finalizer in the relayer repository.` + ); + }); + +// Sleep +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tasks/utils.ts b/tasks/utils.ts index 81c279f84..ec61e4264 100644 --- a/tasks/utils.ts +++ b/tasks/utils.ts @@ -23,6 +23,124 @@ export const minimalSpokePoolInterface = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { + internalType: "uint32", + name: "rootBundleId", + type: "uint32", + }, + { + components: [ + { + internalType: "uint256", + name: "amountToReturn", + type: "uint256", + }, + { + internalType: "uint256", + name: "chainId", + type: "uint256", + }, + { + internalType: "uint256[]", + name: "refundAmounts", + type: "uint256[]", + }, + { + internalType: "uint32", + name: "leafId", + type: "uint32", + }, + { + internalType: "address", + name: "l2TokenAddress", + type: "address", + }, + { + internalType: "address[]", + name: "refundAddresses", + type: "address[]", + }, + ], + internalType: "struct SpokePoolInterface.RelayerRefundLeaf", + name: "relayerRefundLeaf", + type: "tuple", + }, + { + internalType: "bytes32[]", + name: "proof", + type: "bytes32[]", + }, + ], + name: "executeRelayerRefundLeaf", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "relayerRefundRoot", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "slowRelayRoot", + type: "bytes32", + }, + ], + name: "relayRootBundle", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + name: "rootBundles", + outputs: [ + { + internalType: "bytes32", + name: "slowRelayRoot", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "relayerRefundRoot", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, +]; + +export const minimalAdapterInterface = [ + { + inputs: [ + { + internalType: "address", + name: "target", + type: "address", + }, + { + internalType: "bytes", + name: "message", + type: "bytes", + }, + ], + name: "relayMessage", + outputs: [], + stateMutability: "payable", + type: "function", + }, ]; async function askQuestion(query: string) {