diff --git a/.changeset/four-mugs-taste.md b/.changeset/four-mugs-taste.md new file mode 100644 index 0000000000000..eb891eda8e4f7 --- /dev/null +++ b/.changeset/four-mugs-taste.md @@ -0,0 +1,6 @@ +--- +'@eth-optimism/core-utils': minor +'@eth-optimism/sdk': minor +--- + +Add suppory for finalizing legacy withdrawals after the Bedrock migration diff --git a/packages/contracts-bedrock/scripts/differential-testing.ts b/packages/contracts-bedrock/scripts/differential-testing.ts index 468be1562112d..360bec2ba075f 100644 --- a/packages/contracts-bedrock/scripts/differential-testing.ts +++ b/packages/contracts-bedrock/scripts/differential-testing.ts @@ -22,7 +22,7 @@ const command = args[0] switch (command) { case 'decodeVersionedNonce': { const input = BigNumber.from(args[1]) - const [nonce, version] = decodeVersionedNonce(input) + const { nonce, version } = decodeVersionedNonce(input) const output = utils.defaultAbiCoder.encode( ['uint256', 'uint256'], diff --git a/packages/core-utils/src/optimism/encoding.ts b/packages/core-utils/src/optimism/encoding.ts index f1f48281d9ba3..665eea605508f 100644 --- a/packages/core-utils/src/optimism/encoding.ts +++ b/packages/core-utils/src/optimism/encoding.ts @@ -10,9 +10,6 @@ const nonceMask = BigNumber.from( '0x0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' ) -export const big0 = BigNumber.from(0) -export const big1 = BigNumber.from(1) - /** * Encodes the version into the nonce. * @@ -34,8 +31,16 @@ export const encodeVersionedNonce = ( * * @param nonce */ -export const decodeVersionedNonce = (nonce: BigNumber): BigNumber[] => { - return [nonce.and(nonceMask), nonce.shr(240)] +export const decodeVersionedNonce = ( + nonce: BigNumber +): { + version: BigNumber + nonce: BigNumber +} => { + return { + version: nonce.shr(240), + nonce: nonce.and(nonceMask), + } } /** @@ -104,10 +109,10 @@ export const encodeCrossDomainMessage = ( gasLimit: BigNumber, data: string ) => { - const [, version] = decodeVersionedNonce(nonce) - if (version.eq(big0)) { + const { version } = decodeVersionedNonce(nonce) + if (version.eq(0)) { return encodeCrossDomainMessageV0(target, sender, data, nonce) - } else if (version.eq(big1)) { + } else if (version.eq(1)) { return encodeCrossDomainMessageV1( nonce, sender, diff --git a/packages/core-utils/src/optimism/hashing.ts b/packages/core-utils/src/optimism/hashing.ts index fb792c362813d..9f3b65af308b4 100644 --- a/packages/core-utils/src/optimism/hashing.ts +++ b/packages/core-utils/src/optimism/hashing.ts @@ -6,8 +6,6 @@ import { decodeVersionedNonce, encodeCrossDomainMessageV0, encodeCrossDomainMessageV1, - big0, - big1, } from './encoding' /** @@ -34,6 +32,7 @@ export interface OutputRootProof { * Bedrock proof data required to finalize an L2 to L1 message. */ export interface BedrockCrossChainMessageProof { + l2OutputIndex: number outputRootProof: OutputRootProof withdrawalProof: string[] } @@ -65,10 +64,10 @@ export const hashCrossDomainMessage = ( gasLimit: BigNumber, data: string ) => { - const [, version] = decodeVersionedNonce(nonce) - if (version.eq(big0)) { + const { version } = decodeVersionedNonce(nonce) + if (version.eq(0)) { return hashCrossDomainMessagev0(target, sender, data, nonce) - } else if (version.eq(big1)) { + } else if (version.eq(1)) { return hashCrossDomainMessagev1( nonce, sender, diff --git a/packages/sdk/src/cross-chain-messenger.ts b/packages/sdk/src/cross-chain-messenger.ts index 6c94fb6468247..045979f575d14 100644 --- a/packages/sdk/src/cross-chain-messenger.ts +++ b/packages/sdk/src/cross-chain-messenger.ts @@ -19,18 +19,19 @@ import { remove0x, toHexString, toRpcHexString, - hashWithdrawal, - encodeCrossDomainMessageV0, hashCrossDomainMessage, + encodeCrossDomainMessageV0, + encodeCrossDomainMessageV1, L2OutputOracleParameters, BedrockOutputData, BedrockCrossChainMessageProof, + decodeVersionedNonce, + encodeVersionedNonce, } from '@eth-optimism/core-utils' import { getContractInterface, predeploys } from '@eth-optimism/contracts' import * as rlp from 'rlp' import { - CoreCrossChainMessage, OEContracts, OEContractsLike, MessageLike, @@ -53,7 +54,7 @@ import { StateRootBatch, IBridgeAdapter, ProvenWithdrawal, - WithdrawalEntry, + LowLevelMessage, } from './interfaces' import { toSignerOrProvider, @@ -64,6 +65,7 @@ import { getBridgeAdapters, makeMerkleTreeProof, makeStateTrieProof, + hashLowLevelMessage, DEPOSIT_CONFIRMATION_BLOCKS, CHAIN_BLOCK_TIMES, } from './utils' @@ -356,6 +358,131 @@ export class CrossChainMessenger { }) } + /** + * Transforms a legacy message into its corresponding Bedrock representation. + * + * @param message Legacy message to transform. + * @returns Bedrock representation of the message. + */ + public async toBedrockCrossChainMessage( + message: MessageLike + ): Promise { + const resolved = await this.toCrossChainMessage(message) + + // Bedrock messages are already in the correct format. + const { version } = decodeVersionedNonce(resolved.messageNonce) + if (version.eq(1)) { + return resolved + } + + let value = BigNumber.from(0) + if ( + resolved.direction === MessageDirection.L2_TO_L1 && + resolved.sender === this.contracts.l2.L2StandardBridge.address && + resolved.target === this.contracts.l1.L1StandardBridge.address + ) { + try { + ;[, , value] = + this.contracts.l1.L1StandardBridge.interface.decodeFunctionData( + 'finalizeETHWithdrawal', + resolved.message + ) + } catch (err) { + // No problem, not a message with value. + } + } + + return { + ...resolved, + value, + minGasLimit: BigNumber.from(0), + messageNonce: encodeVersionedNonce( + BigNumber.from(1), + resolved.messageNonce + ), + } + } + + /** + * Transforms a CrossChainMessenger message into its low-level representation inside the + * L2ToL1MessagePasser contract on L2. + * + * @param message Message to transform. + * @return Transformed message. + */ + public async toLowLevelMessage( + message: MessageLike + ): Promise { + const resolved = await this.toCrossChainMessage(message) + if (resolved.direction === MessageDirection.L1_TO_L2) { + throw new Error(`can only convert L2 to L1 messages to low level`) + } + + // We may have to update the message if it's a legacy message. + const { version } = decodeVersionedNonce(resolved.messageNonce) + let updated: CrossChainMessage + if (version.eq(0)) { + updated = await this.toBedrockCrossChainMessage(resolved) + } else { + updated = resolved + } + + // We need to figure out the final withdrawal data that was used to compute the withdrawal hash + // inside the L2ToL1Message passer contract. Exact mechanism here depends on whether or not + // this is a legacy message or a new Bedrock message. + let gasLimit: BigNumber + let messageNonce: BigNumber + if (version.eq(0)) { + gasLimit = BigNumber.from(0) + messageNonce = resolved.messageNonce + } else { + const receipt = await this.l2Provider.getTransactionReceipt( + resolved.transactionHash + ) + + const withdrawals: any[] = [] + for (const log of receipt.logs) { + if (log.address === this.contracts.l2.BedrockMessagePasser.address) { + const decoded = + this.contracts.l2.L2ToL1MessagePasser.interface.parseLog(log) + if (decoded.name === 'MessagePassed') { + withdrawals.push(decoded.args) + } + } + } + + // Should not happen. + if (withdrawals.length === 0) { + throw new Error(`no withdrawals found in receipt`) + } + + // TODO: Add support for multiple withdrawals. + if (withdrawals.length > 1) { + throw new Error(`multiple withdrawals found in receipt`) + } + + const withdrawal = withdrawals[0] + messageNonce = withdrawal.nonce + gasLimit = withdrawal.gasLimit + } + + return { + messageNonce, + sender: this.contracts.l2.L2CrossDomainMessenger.address, + target: this.contracts.l1.L1CrossDomainMessenger.address, + value: updated.value, + minGasLimit: gasLimit, + message: encodeCrossDomainMessageV1( + updated.messageNonce, + updated.sender, + updated.target, + updated.value, + updated.minGasLimit, + updated.message + ), + } + } + // public async getMessagesByAddress( // address: AddressLike, // opts?: { @@ -563,21 +690,14 @@ export class CrossChainMessenger { return MessageStatus.STATE_ROOT_NOT_PUBLISHED } - // Fetch the receipt for the resolved CrossChainMessage - const _receipt = await this.l2Provider.getTransactionReceipt( - resolved.transactionHash - ) + // Convert the message to the low level message that was proven. + const withdrawal = await this.toLowLevelMessage(resolved) - // Get the withdrawal hash for the receipt - const [_, withdrawalHash] = this.getWithdrawalFromReceipt( - _receipt, - resolved - ) - - // Attempt to fetch the proven withdrawal - const provenWithdrawal = await this.getProvenWithdrawal( - withdrawalHash - ) + // Attempt to fetch the proven withdrawal. + const provenWithdrawal = + await this.contracts.l1.OptimismPortal.provenWithdrawals( + hashLowLevelMessage(withdrawal) + ) // If the withdrawal hash has not been proven on L1, // return `READY_TO_PROVE` @@ -1248,9 +1368,7 @@ export class CrossChainMessenger { */ public async getBedrockMessageProof( message: MessageLike - ): Promise< - [BedrockCrossChainMessageProof, BedrockOutputData, CoreCrossChainMessage] - > { + ): Promise { const resolved = await this.toCrossChainMessage(message) if (resolved.direction === MessageDirection.L1_TO_L2) { throw new Error(`can only generate proofs for L2 to L1 messages`) @@ -1261,33 +1379,13 @@ export class CrossChainMessenger { throw new Error(`state root for message not yet published`) } - const receipt = await this.l2Provider.getTransactionReceipt( - resolved.transactionHash - ) - - const [withdrawal, withdrawalHash] = this.getWithdrawalFromReceipt( - receipt, - resolved - ) - - // Sanity check - if (withdrawal.MessagePassed.withdrawalHash !== withdrawalHash) { - throw new Error(`Mismatched withdrawal hashes`) - } - - // TODO: turn into util - const preimage = ethers.utils.defaultAbiCoder.encode( - ['bytes32', 'uint256'], - [withdrawalHash, ethers.constants.HashZero] + const withdrawal = await this.toLowLevelMessage(resolved) + const messageSlot = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'uint256'], + [hashLowLevelMessage(withdrawal)] + ) ) - const isMessageSent = - await this.contracts.l2.BedrockMessagePasser.sentMessages(withdrawalHash) - - if (!isMessageSent) { - throw new Error(`Withdrawal not initiated on L2`) - } - - const messageSlot = ethers.utils.keccak256(preimage) const stateTrieProof = await makeStateTrieProof( this.l2Provider as ethers.providers.JsonRpcProvider, @@ -1296,11 +1394,6 @@ export class CrossChainMessenger { messageSlot ) - // Sanity check that the value is set to 1 in the state - if (!stateTrieProof.storageValue.eq(1)) { - throw new Error(`Withdrawal hash ${withdrawalHash} is not set in state`) - } - const block = await ( this.l2Provider as ethers.providers.JsonRpcProvider ).send('eth_getBlockByNumber', [ @@ -1308,80 +1401,16 @@ export class CrossChainMessenger { false, ]) - return [ - { - outputRootProof: { - // TODO: Handle multiple versions in the future - version: ethers.constants.HashZero, - stateRoot: block.stateRoot, - messagePasserStorageRoot: stateTrieProof.storageRoot, - latestBlockhash: block.hash, - }, - withdrawalProof: stateTrieProof.storageProof, - }, - output, - // TODO(tynes): use better type, typechain? - { - messageNonce: withdrawal.MessagePassed.nonce, - sender: withdrawal.MessagePassed.sender, - target: withdrawal.MessagePassed.target, - value: withdrawal.MessagePassed.value, - minGasLimit: withdrawal.MessagePassed.gasLimit, - message: withdrawal.MessagePassed.data, + return { + outputRootProof: { + version: ethers.constants.HashZero, + stateRoot: block.stateRoot, + messagePasserStorageRoot: stateTrieProof.storageRoot, + latestBlockhash: block.hash, }, - ] - } - - /** - * Helper function that gets a withdrawal and a withdrawal hash from the logs - * of a L2 to L2 CrossChainMessage and its transaction receipt. - * - * TODO: Process multiple withdrawals in a single transaction. - */ - public getWithdrawalFromReceipt( - receipt: TransactionReceipt, - message: CrossChainMessage - ): [WithdrawalEntry, string] { - // Handle multiple withdrawals in the same tx - const logs: Partial<{ number: WithdrawalEntry }> = {} - for (const [_, log] of Object.entries(receipt.logs)) { - if (log.address === this.contracts.l2.BedrockMessagePasser.address) { - const decoded = - this.contracts.l2.L2ToL1MessagePasser.interface.parseLog(log) - // Find the withdrawal initiated events - if (decoded.name === 'MessagePassed') { - logs[log.logIndex] = { - MessagePassed: decoded.args, - } - } - } - } - - // TODO(tynes): be able to handle transactions that do multiple withdrawals - // in a single transaction. Right now just go for the first one. - const withdrawal = Object.values(logs)[0] - if (!withdrawal) { - throw new Error( - `Cannot find withdrawal logs for ${message.transactionHash}` - ) + withdrawalProof: stateTrieProof.storageProof, + l2OutputIndex: output.l2OutputIndex, } - - const withdrawalHash = hashWithdrawal( - withdrawal.MessagePassed.nonce, - withdrawal.MessagePassed.sender, - withdrawal.MessagePassed.target, - withdrawal.MessagePassed.value, - withdrawal.MessagePassed.gasLimit, - withdrawal.MessagePassed.data - ) - - if (withdrawalHash !== withdrawal.MessagePassed.withdrawalHash) { - throw new Error( - 'Locally computed withdrawal hash is not equal to the withdrawal hash computed on-chain!' - ) - } - - return [withdrawal, withdrawalHash] } /** @@ -1760,20 +1789,18 @@ export class CrossChainMessenger { ) } - const [proof, output, withdrawalTx] = await this.getBedrockMessageProof( - message - ) - + const withdrawal = await this.toLowLevelMessage(resolved) + const proof = await this.getBedrockMessageProof(resolved) return this.contracts.l1.OptimismPortal.populateTransaction.proveWithdrawalTransaction( [ - withdrawalTx.messageNonce, - withdrawalTx.sender, - withdrawalTx.target, - withdrawalTx.value, - withdrawalTx.minGasLimit, - withdrawalTx.message, + withdrawal.messageNonce, + withdrawal.sender, + withdrawal.target, + withdrawal.value, + withdrawal.minGasLimit, + withdrawal.message, ], - output.l2OutputIndex, + proof.l2OutputIndex, [ proof.outputRootProof.version, proof.outputRootProof.stateRoot, @@ -1807,16 +1834,15 @@ export class CrossChainMessenger { } if (this.bedrock) { - const [, , withdrawalTx] = await this.getBedrockMessageProof(message) - + const withdrawal = await this.toLowLevelMessage(resolved) return this.contracts.l1.OptimismPortal.populateTransaction.finalizeWithdrawalTransaction( [ - withdrawalTx.messageNonce, - withdrawalTx.sender, - withdrawalTx.target, - withdrawalTx.value, - withdrawalTx.minGasLimit, - withdrawalTx.message, + withdrawal.messageNonce, + withdrawal.sender, + withdrawal.target, + withdrawal.value, + withdrawal.minGasLimit, + withdrawal.message, ], opts?.overrides || {} ) diff --git a/packages/sdk/src/interfaces/types.ts b/packages/sdk/src/interfaces/types.ts index bfec4ef86648c..fbdc674bc8308 100644 --- a/packages/sdk/src/interfaces/types.ts +++ b/packages/sdk/src/interfaces/types.ts @@ -203,6 +203,12 @@ export interface CrossChainMessage extends CoreCrossChainMessage { transactionHash: string } +/** + * Describes messages sent inside the L2ToL1MessagePasser on L2. Happens to be the same structure + * as the CoreCrossChainMessage so we'll reuse the type for now. + */ +export type LowLevelMessage = CoreCrossChainMessage + /** * Describes a token withdrawal or deposit, along with the underlying raw cross chain message * behind the deposit or withdrawal. diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index eed51741f042d..0e166b9f6c82a 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './type-utils' export * from './misc-utils' export * from './merkle-utils' export * from './chain-constants' +export * from './message-utils' diff --git a/packages/sdk/src/utils/message-utils.ts b/packages/sdk/src/utils/message-utils.ts new file mode 100644 index 0000000000000..210bf88296e8e --- /dev/null +++ b/packages/sdk/src/utils/message-utils.ts @@ -0,0 +1,20 @@ +import { hashWithdrawal } from '@eth-optimism/core-utils' + +import { LowLevelMessage } from '../interfaces' + +/** + * Utility for hashing a LowLevelMessage object. + * + * @param message LowLevelMessage object to hash. + * @returns Hash of the given LowLevelMessage. + */ +export const hashLowLevelMessage = (message: LowLevelMessage): string => { + return hashWithdrawal( + message.messageNonce, + message.sender, + message.target, + message.value, + message.minGasLimit, + message.message + ) +}