UNPKG

@tokamak-network/thanos-sdk

Version:
1,453 lines (1,330 loc) 105 kB
/* eslint-disable @typescript-eslint/no-unused-vars */ import { Provider, BlockTag, TransactionReceipt, TransactionResponse, TransactionRequest, } from '@ethersproject/abstract-provider' import { Signer } from '@ethersproject/abstract-signer' import { ethers, BigNumber, Overrides, CallOverrides, PayableOverrides, } from 'ethers' import { sleep, remove0x, toHexString, toRpcHexString, encodeCrossDomainMessageV0, encodeCrossDomainMessageV1, BedrockOutputData, BedrockCrossChainMessageProof, decodeVersionedNonce, encodeVersionedNonce, getChainId, hashCrossDomainMessagev0, hashCrossDomainMessagev1, predeploys, } from '@tokamak-network/core-utils' import * as rlp from 'rlp' import l1CrossDomainMessagerArtifact from '@tokamak-network/thanos-contracts/forge-artifacts/L1CrossDomainMessenger.sol/L1CrossDomainMessenger.json' import semver from 'semver' import { OEContracts, OEContractsLike, MessageLike, MessageRequestLike, TransactionLike, AddressLike, NumberLike, SignerOrProviderLike, CrossChainMessage, CrossChainMessageRequest, CrossChainMessageProof, MessageDirection, MessageStatus, TokenBridgeMessage, MessageReceipt, MessageReceiptStatus, BridgeAdapterData, BridgeAdapters, StateRoot, StateRootBatch, IBridgeAdapter, ProvenWithdrawal, LowLevelMessage, } from './interfaces' import { toSignerOrProvider, toNumber, toTransactionHash, DeepPartial, getAllOEContracts, getBridgeAdapters, makeMerkleTreeProof, makeStateTrieProof, hashLowLevelMessage, migratedWithdrawalGasLimit, DEPOSIT_CONFIRMATION_BLOCKS, CHAIN_BLOCK_TIMES, hashMessageHash, getContractInterfaceBedrock, toJsonRpcProvider, toAddress, } from './utils' export class CrossChainMessenger { /** * Provider connected to the L1 chain. */ public l1SignerOrProvider: Signer | Provider /** * Provider connected to the L2 chain. */ public l2SignerOrProvider: Signer | Provider /** * Chain ID for the L1 network. */ public l1ChainId: number /** * Chain ID for the L2 network. */ public l2ChainId: number /** * Native Token Address */ public nativeTokenAddress: string /** * Contract objects attached to their respective providers and addresses. */ public contracts: OEContracts /** * List of custom bridges for the given network. */ public bridges: BridgeAdapters /** * Number of blocks before a deposit is considered confirmed. */ public depositConfirmationBlocks: number /** * Estimated average L1 block time in seconds. */ public l1BlockTimeSeconds: number /** * Whether or not Bedrock compatibility is enabled. */ public bedrock: boolean /** * Cache for output root validation. Output roots are expensive to verify, so we cache them. */ private _outputCache: Array<{ root: string; valid: boolean }> = [] /** * Creates a new CrossChainProvider instance. * * @param opts Options for the provider. * @param opts.l1SignerOrProvider Signer or Provider for the L1 chain, or a JSON-RPC url. * @param opts.l2SignerOrProvider Signer or Provider for the L2 chain, or a JSON-RPC url. * @param opts.l1ChainId Chain ID for the L1 chain. * @param opts.l2ChainId Chain ID for the L2 chain. * @param opts.depositConfirmationBlocks Optional number of blocks before a deposit is confirmed. * @param opts.l1BlockTimeSeconds Optional estimated block time in seconds for the L1 chain. * @param opts.contracts Optional contract address overrides. * @param opts.bridges Optional bridge address list. * @param opts.bedrock Whether or not to enable Bedrock compatibility. */ constructor(opts: { l1SignerOrProvider: SignerOrProviderLike l2SignerOrProvider: SignerOrProviderLike l1ChainId: NumberLike l2ChainId: NumberLike nativeTokenAddress?: AddressLike depositConfirmationBlocks?: NumberLike l1BlockTimeSeconds?: NumberLike contracts?: DeepPartial<OEContractsLike> bridges?: BridgeAdapterData bedrock?: boolean }) { this.bedrock = opts.bedrock ?? true this.l1SignerOrProvider = toSignerOrProvider(opts.l1SignerOrProvider) this.l2SignerOrProvider = toSignerOrProvider(opts.l2SignerOrProvider) try { this.l1ChainId = toNumber(opts.l1ChainId) } catch (err) { throw new Error(`L1 chain ID is missing or invalid: ${opts.l1ChainId}`) } try { this.l2ChainId = toNumber(opts.l2ChainId) } catch (err) { throw new Error(`L2 chain ID is missing or invalid: ${opts.l2ChainId}`) } if (opts.nativeTokenAddress) { this.nativeTokenAddress = toAddress(opts.nativeTokenAddress) } this.depositConfirmationBlocks = opts?.depositConfirmationBlocks !== undefined ? toNumber(opts.depositConfirmationBlocks) : DEPOSIT_CONFIRMATION_BLOCKS[this.l2ChainId] || 0 this.l1BlockTimeSeconds = opts?.l1BlockTimeSeconds !== undefined ? toNumber(opts.l1BlockTimeSeconds) : CHAIN_BLOCK_TIMES[this.l1ChainId] || 1 this.contracts = getAllOEContracts(this.l2ChainId, { l1SignerOrProvider: this.l1SignerOrProvider, l2SignerOrProvider: this.l2SignerOrProvider, overrides: opts.contracts, }) this.bridges = getBridgeAdapters(this.l2ChainId, this, { overrides: opts.bridges, contracts: opts.contracts, }) } /** * Provider connected to the L1 chain. */ get l1Provider(): Provider { if (Provider.isProvider(this.l1SignerOrProvider)) { return this.l1SignerOrProvider } else { return this.l1SignerOrProvider.provider } } /** * Provider connected to the L2 chain. */ get l2Provider(): Provider { if (Provider.isProvider(this.l2SignerOrProvider)) { return this.l2SignerOrProvider } else { return this.l2SignerOrProvider.provider } } /** * Signer connected to the L1 chain. */ get l1Signer(): Signer { if (Provider.isProvider(this.l1SignerOrProvider)) { throw new Error(`messenger has no L1 signer`) } else { return this.l1SignerOrProvider } } /** * Signer connected to the L2 chain. */ get l2Signer(): Signer { if (Provider.isProvider(this.l2SignerOrProvider)) { throw new Error(`messenger has no L2 signer`) } else { return this.l2SignerOrProvider } } /** * Uses portal version to determine if the messenger is using fpac contracts. Better not to cache * this value as it will change during the fpac upgrade and we want clients to automatically * begin using the new logic without throwing any errors. * * @returns Whether or not the messenger is using fpac contracts. */ public async fpac(): Promise<boolean> { if ( this.contracts.l1.OptimismPortal.address === ethers.constants.AddressZero ) { // Only really relevant for certain SDK tests where the portal is not deployed. We should // probably just update the tests so the portal gets deployed but feels like it's out of // scope for the FPAC changes. return false } else { return semver.gte( await this.contracts.l1.OptimismPortal.version(), '3.0.0' ) } } /** * Retrieves all cross chain messages sent within a given transaction. * * @param transaction Transaction hash or receipt to find messages from. * @param opts Options object. * @param opts.direction Direction to search for messages in. If not provided, will attempt to * automatically search both directions under the assumption that a transaction hash will only * exist on one chain. If the hash exists on both chains, will throw an error. * @returns All cross chain messages sent within the transaction. */ public async getMessagesByTransaction( transaction: TransactionLike, opts: { direction?: MessageDirection } = {} ): Promise<CrossChainMessage[]> { // Wait for the transaction receipt if the input is waitable. await (transaction as TransactionResponse).wait?.() // Convert the input to a transaction hash. const txHash = toTransactionHash(transaction) let receipt: TransactionReceipt if (opts.direction !== undefined) { // Get the receipt for the requested direction. if (opts.direction === MessageDirection.L1_TO_L2) { receipt = await this.l1Provider.getTransactionReceipt(txHash) } else { receipt = await this.l2Provider.getTransactionReceipt(txHash) } } else { // Try both directions, starting with L1 => L2. receipt = await this.l1Provider.getTransactionReceipt(txHash) if (receipt) { opts.direction = MessageDirection.L1_TO_L2 } else { receipt = await this.l2Provider.getTransactionReceipt(txHash) opts.direction = MessageDirection.L2_TO_L1 } } if (!receipt) { throw new Error(`unable to find transaction receipt for ${txHash}`) } // By this point opts.direction will always be defined. const messenger = opts.direction === MessageDirection.L1_TO_L2 ? this.contracts.l1.L1CrossDomainMessenger : this.contracts.l2.L2CrossDomainMessenger return receipt.logs .filter((log) => { // Only look at logs emitted by the messenger address return log.address === messenger.address }) .filter((log) => { // Only look at SentMessage logs specifically const parsed = messenger.interface.parseLog(log) return parsed.name === 'SentMessage' }) .map((log) => { // Try to pull out the value field, but only if the very next log is a SentMessageExtension1 // event which was introduced in the Bedrock upgrade. let value = ethers.BigNumber.from(0) const next = receipt.logs.find((l) => { return ( l.logIndex === log.logIndex + 1 && l.address === messenger.address ) }) if (next) { const nextParsed = messenger.interface.parseLog(next) if (nextParsed.name === 'SentMessageExtension1') { value = nextParsed.args.value } } // Convert each SentMessage log into a message object const parsed = messenger.interface.parseLog(log) return { direction: opts.direction, target: parsed.args.target, sender: parsed.args.sender, message: parsed.args.message, messageNonce: parsed.args.messageNonce, value, minGasLimit: parsed.args.gasLimit, logIndex: log.logIndex, blockNumber: log.blockNumber, transactionHash: log.transactionHash, } }) } /** * Transforms a legacy message into its corresponding Bedrock representation. * * @param message Legacy message to transform. * @param messageIndex The index of the message, if multiple exist from multicall * @returns Bedrock representation of the message. */ public async toBedrockCrossChainMessage( message: MessageLike, messageIndex = 0 ): Promise<CrossChainMessage> { const resolved = await this.toCrossChainMessage(message, messageIndex) // 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(0), resolved.messageNonce ), } } /** * Transforms a CrossChainMessenger message into its low-level representation inside the * L2ToL1MessagePasser contract on L2. * * @param message Message to transform. * @param messageIndex The index of the message, if multiple exist from multicall * @return Transformed message. */ public async toLowLevelMessage( message: MessageLike, messageIndex = 0 ): Promise<LowLevelMessage> { const resolved = await this.toCrossChainMessage(message, messageIndex) 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, messageIndex) } else { updated = resolved } // Encode the updated message, we need this for legacy messages. const encoded = encodeCrossDomainMessageV1( updated.messageNonce, updated.sender, updated.target, updated.value, updated.minGasLimit, updated.message ) // EVERYTHING following here is basically repeating the logic from getMessagesByTransaction // consider cleaning this up // 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)) { const chainID = await getChainId(this.l2Provider) gasLimit = migratedWithdrawalGasLimit(encoded, chainID) messageNonce = resolved.messageNonce } else { const receipt = await this.l2Provider.getTransactionReceipt( ( await this.toCrossChainMessage(message) ).transactionHash ) const withdrawals: ethers.utils.Result[] = [] 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`) } const withdrawal = withdrawals[messageIndex] if (!withdrawal) { throw new Error( `withdrawal index ${messageIndex} out of bounds there are ${withdrawals.length} withdrawals` ) } 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: encoded, } } // public async getMessagesByAddress( // address: AddressLike, // opts?: { // direction?: MessageDirection // fromBlock?: NumberLike // toBlock?: NumberLike // } // ): Promise<CrossChainMessage[]> { // throw new Error(` // The function getMessagesByAddress is currently not enabled because the sender parameter of // the SentMessage event is not indexed within the CrossChainMessenger contracts. // getMessagesByAddress will be enabled by plugging in an Optimism Indexer (coming soon). // See the following issue on GitHub for additional context: // https://github.com/ethereum-optimism/optimism/issues/2129 // `) // } /** * Finds the appropriate bridge adapter for a given L1<>L2 token pair. Will throw if no bridges * support the token pair or if more than one bridge supports the token pair. * * @param l1Token L1 token address. * @param l2Token L2 token address. * @returns The appropriate bridge adapter for the given token pair. */ public async getBridgeForTokenPair( l1Token: AddressLike, l2Token: AddressLike ): Promise<IBridgeAdapter> { const bridges: IBridgeAdapter[] = [] for (const bridge of Object.values(this.bridges)) { try { if (await bridge.supportsTokenPair(l1Token, l2Token)) { bridges.push(bridge) } } catch (err) { if ( !err?.message?.toString().includes('CALL_EXCEPTION') && !err?.stack?.toString().includes('execution reverted') ) { console.error('Unexpected error when checking bridge', err) } } } if (bridges.length === 0) { throw new Error(`no supported bridge for token pair`) } if (bridges.length > 1) { throw new Error(`found more than one bridge for token pair`) } return bridges[0] } /** * Gets all deposits for a given address. * * @param address Address to search for messages from. * @param opts Options object. * @param opts.fromBlock Block to start searching for messages from. If not provided, will start * from the first block (block #0). * @param opts.toBlock Block to stop searching for messages at. If not provided, will stop at the * latest known block ("latest"). * @returns All deposit token bridge messages sent by the given address. */ public async getDepositsByAddress( address: AddressLike, opts: { fromBlock?: BlockTag toBlock?: BlockTag } = {} ): Promise<TokenBridgeMessage[]> { return ( await Promise.all( Object.values(this.bridges).map(async (bridge) => { return bridge.getDepositsByAddress(address, opts) }) ) ) .reduce((acc, val) => { return acc.concat(val) }, []) .sort((a, b) => { // Sort descending by block number return b.blockNumber - a.blockNumber }) } /** * Gets all withdrawals for a given address. * * @param address Address to search for messages from. * @param opts Options object. * @param opts.fromBlock Block to start searching for messages from. If not provided, will start * from the first block (block #0). * @param opts.toBlock Block to stop searching for messages at. If not provided, will stop at the * latest known block ("latest"). * @returns All withdrawal token bridge messages sent by the given address. */ public async getWithdrawalsByAddress( address: AddressLike, opts: { fromBlock?: BlockTag toBlock?: BlockTag } = {} ): Promise<TokenBridgeMessage[]> { return ( await Promise.all( Object.values(this.bridges).map(async (bridge) => { return bridge.getWithdrawalsByAddress(address, opts) }) ) ) .reduce((acc, val) => { return acc.concat(val) }, []) .sort((a, b) => { // Sort descending by block number return b.blockNumber - a.blockNumber }) } /** * Resolves a MessageLike into a CrossChainMessage object. * Unlike other coercion functions, this function is stateful and requires making additional * requests. For now I'm going to keep this function here, but we could consider putting a * similar function inside of utils/coercion.ts if people want to use this without having to * create an entire CrossChainProvider object. * * @param message MessageLike to resolve into a CrossChainMessage. * @param messageIndex The index of the message, if multiple exist from multicall * @returns Message coerced into a CrossChainMessage. */ public async toCrossChainMessage( message: MessageLike, messageIndex = 0 ): Promise<CrossChainMessage> { if (!message) { throw new Error('message is undefined') } // TODO: Convert these checks into proper type checks. if ((message as CrossChainMessage).message) { return message as CrossChainMessage } else if ( (message as TokenBridgeMessage).l1Token && (message as TokenBridgeMessage).l2Token && (message as TokenBridgeMessage).transactionHash ) { const messages = await this.getMessagesByTransaction( (message as TokenBridgeMessage).transactionHash ) // The `messages` object corresponds to a list of SentMessage events that were triggered by // the same transaction. We want to find the specific SentMessage event that corresponds to // the TokenBridgeMessage (either a ETHDepositInitiated, ERC20DepositInitiated, or // WithdrawalInitiated event). We expect the behavior of bridge contracts to be that these // TokenBridgeMessage events are triggered and then a SentMessage event is triggered. Our // goal here is therefore to find the first SentMessage event that comes after the input // event. const found = messages .sort((a, b) => { // Sort all messages in ascending order by log index. return a.logIndex - b.logIndex }) .find((m) => { return m.logIndex > (message as TokenBridgeMessage).logIndex }) if (!found) { throw new Error(`could not find SentMessage event for message`) } return found } else { // TODO: Explicit TransactionLike check and throw if not TransactionLike const messages = await this.getMessagesByTransaction( message as TransactionLike ) const out = messages[messageIndex] if (!out) { throw new Error( `withdrawal index ${messageIndex} out of bounds. There are ${messages.length} withdrawals` ) } return out } } /** * Retrieves the status of a particular message as an enum. * * @param message Cross chain message to check the status of. * @param messageIndex The index of the message, if multiple exist from multicall * @param fromBlockOrBlockHash The start block to use for the query filter on the RECEIVING chain * @param toBlockOrBlockHash The end block to use for the query filter on the RECEIVING chain * @returns Status of the message. */ public async getMessageStatus( message: MessageLike, // consider making this an options object next breaking release messageIndex = 0, /** * @deprecated no longer used since no log filters are used */ fromBlockOrBlockHash?: BlockTag, /** * @deprecated no longer used since no log filters are used */ toBlockOrBlockHash?: BlockTag ): Promise<MessageStatus> { const resolved = await this.toCrossChainMessage(message, messageIndex) // legacy withdrawals relayed prebedrock are v1 const messageHashV0 = hashCrossDomainMessagev0( resolved.target, resolved.sender, resolved.message, resolved.messageNonce ) // bedrock withdrawals are v1 // legacy withdrawals relayed postbedrock are v1 // there is no good way to differentiate between the two types of legacy // so what we will check for both const messageHashV1 = hashCrossDomainMessagev1( resolved.messageNonce, resolved.sender, resolved.target, resolved.value, resolved.minGasLimit, resolved.message ) // Here we want the messenger that will receive the message, not the one that sent it. const messenger = resolved.direction === MessageDirection.L1_TO_L2 ? this.contracts.l2.L2CrossDomainMessenger : this.contracts.l1.L1CrossDomainMessenger const success = (await messenger.successfulMessages(messageHashV0)) || (await messenger.successfulMessages(messageHashV1)) // Avoid the extra query if we already know the message was successful. if (success) { return MessageStatus.RELAYED } const failure = (await messenger.failedMessages(messageHashV0)) || (await messenger.failedMessages(messageHashV1)) if (resolved.direction === MessageDirection.L1_TO_L2) { if (failure) { return MessageStatus.FAILED_L1_TO_L2_MESSAGE } else { return MessageStatus.UNCONFIRMED_L1_TO_L2_MESSAGE } } else { if (failure) { return MessageStatus.READY_FOR_RELAY } else { let timestamp: number if (this.bedrock) { const output = await this.getMessageBedrockOutput( resolved, messageIndex ) if (output === null) { return MessageStatus.STATE_ROOT_NOT_PUBLISHED } // Convert the message to the low level message that was proven. const withdrawal = await this.toLowLevelMessage( resolved, messageIndex ) // Attempt to fetch the proven withdrawal. const provenWithdrawal = await this.getProvenWithdrawal( hashLowLevelMessage(withdrawal) ) // If the withdrawal hash has not been proven on L1, return READY_TO_PROVE. // Note that this will also apply in the case that a withdrawal has been proven but the // proposal used to create the proof was invalidated. This is fine because in that case // the withdrawal needs to be proven again anyway. if (provenWithdrawal === null) { return MessageStatus.READY_TO_PROVE } // Set the timestamp to the provenWithdrawal's timestamp timestamp = provenWithdrawal.timestamp.toNumber() } else { const stateRoot = await this.getMessageStateRoot( resolved, messageIndex ) if (stateRoot === null) { return MessageStatus.STATE_ROOT_NOT_PUBLISHED } const bn = stateRoot.batch.blockNumber const block = await this.l1Provider.getBlock(bn) timestamp = block.timestamp } if (await this.fpac()) { // Convert the message to the low level message that was proven. const withdrawal = await this.toLowLevelMessage( resolved, messageIndex ) // Get the withdrawal hash. const withdrawalHash = hashLowLevelMessage(withdrawal) // Grab the proven withdrawal data. const provenWithdrawal = await this.getProvenWithdrawal( withdrawalHash ) // Sanity check, should've already happened above but do it just in case. if (provenWithdrawal === null) { // Ready to prove is the correct status here, we would not expect to hit this code path // unless there was an unexpected reorg on L1. Since this is unlikely we log a warning. console.warn( 'Unexpected code path reached in getMessageStatus, returning READY_TO_PROVE' ) return MessageStatus.READY_TO_PROVE } // Shouldn't happen, but worth checking just in case. if (!('proofSubmitter' in provenWithdrawal)) { throw new Error( `expected to get FPAC withdrawal but got legacy withdrawal` ) } try { // If this doesn't revert then we should be fine to relay. await this.contracts.l1.OptimismPortal2.checkWithdrawal( hashLowLevelMessage(withdrawal), provenWithdrawal.proofSubmitter ) return MessageStatus.READY_FOR_RELAY } catch (err) { return MessageStatus.IN_CHALLENGE_PERIOD } } else { const challengePeriod = await this.getChallengePeriodSeconds() const latestBlock = await this.l1Provider.getBlock('latest') if (timestamp + challengePeriod > latestBlock.timestamp) { return MessageStatus.IN_CHALLENGE_PERIOD } else { return MessageStatus.READY_FOR_RELAY } } } } } /** * Finds the receipt of the transaction that executed a particular cross chain message. * * @param message Message to find the receipt of. * @param messageIndex The index of the message, if multiple exist from multicall * @param fromBlockOrBlockHash The start block to use for the query filter on the RECEIVING chain * @param toBlockOrBlockHash The end block to use for the query filter on the RECEIVING chain * @returns CrossChainMessage receipt including receipt of the transaction that relayed the * given message. */ public async getMessageReceipt( message: MessageLike, messageIndex = 0, fromBlockOrBlockHash?: BlockTag, toBlockOrHash?: BlockTag ): Promise<MessageReceipt> { const resolved = await this.toCrossChainMessage(message, messageIndex) // legacy withdrawals relayed prebedrock are v1 const messageHashV0 = hashCrossDomainMessagev0( resolved.target, resolved.sender, resolved.message, resolved.messageNonce ) // bedrock withdrawals are v1 // legacy withdrawals relayed postbedrock are v1 // there is no good way to differentiate between the two types of legacy // so what we will check for both const messageHashV1 = hashCrossDomainMessagev1( resolved.messageNonce, resolved.sender, resolved.target, resolved.value, resolved.minGasLimit, resolved.message ) // Here we want the messenger that will receive the message, not the one that sent it. const messenger = resolved.direction === MessageDirection.L1_TO_L2 ? this.contracts.l2.L2CrossDomainMessenger : this.contracts.l1.L1CrossDomainMessenger // this is safe because we can guarantee only one of these filters max will return something const relayedMessageEvents = [ ...(await messenger.queryFilter( messenger.filters.RelayedMessage(messageHashV0), fromBlockOrBlockHash, toBlockOrHash )), ...(await messenger.queryFilter( messenger.filters.RelayedMessage(messageHashV1), fromBlockOrBlockHash, toBlockOrHash )), ] // Great, we found the message. Convert it into a transaction receipt. if (relayedMessageEvents.length === 1) { return { receiptStatus: MessageReceiptStatus.RELAYED_SUCCEEDED, transactionReceipt: await relayedMessageEvents[0].getTransactionReceipt(), } } else if (relayedMessageEvents.length > 1) { // Should never happen! throw new Error(`multiple successful relays for message`) } // We didn't find a transaction that relayed the message. We now attempt to find // FailedRelayedMessage events instead. const failedRelayedMessageEvents = [ ...(await messenger.queryFilter( messenger.filters.FailedRelayedMessage(messageHashV0), fromBlockOrBlockHash, toBlockOrHash )), ...(await messenger.queryFilter( messenger.filters.FailedRelayedMessage(messageHashV1), fromBlockOrBlockHash, toBlockOrHash )), ] // A transaction can fail to be relayed multiple times. We'll always return the last // transaction that attempted to relay the message. // TODO: Is this the best way to handle this? if (failedRelayedMessageEvents.length > 0) { return { receiptStatus: MessageReceiptStatus.RELAYED_FAILED, transactionReceipt: await failedRelayedMessageEvents[ failedRelayedMessageEvents.length - 1 ].getTransactionReceipt(), } } // TODO: If the user doesn't provide enough gas then there's a chance that FailedRelayedMessage // will never be triggered. We should probably fix this at the contract level by requiring a // minimum amount of input gas and designing the contracts such that the gas will always be // enough to trigger the event. However, for now we need a temporary way to find L1 => L2 // transactions that fail but don't alert us because they didn't provide enough gas. // TODO: Talk with the systems and protocol team about coordinating a hard fork that fixes this // on both L1 and L2. // Just return null if we didn't find a receipt. Slightly nicer than throwing an error. return null } /** * Waits for a message to be executed and returns the receipt of the transaction that executed * the given message. * * @param message Message to wait for. * @param opts Options to pass to the waiting function. * @param opts.confirmations Number of transaction confirmations to wait for before returning. * @param opts.pollIntervalMs Number of milliseconds to wait between polling for the receipt. * @param opts.timeoutMs Milliseconds to wait before timing out. * @param opts.fromBlockOrBlockHash The start block to use for the query filter on the RECEIVING chain * @param opts.toBlockOrBlockHash The end block to use for the query filter on the RECEIVING chain * @param messageIndex The index of the message, if multiple exist from multicall * @returns CrossChainMessage receipt including receipt of the transaction that relayed the * given message. */ public async waitForMessageReceipt( message: MessageLike, opts: { fromBlockOrBlockHash?: BlockTag toBlockOrHash?: BlockTag confirmations?: number pollIntervalMs?: number timeoutMs?: number } = {}, /** * The index of the withdrawal if multiple are made with multicall */ messageIndex = 0 ): Promise<MessageReceipt> { // Resolving once up-front is slightly more efficient. const resolved = await this.toCrossChainMessage(message, messageIndex) let totalTimeMs = 0 while (totalTimeMs < (opts.timeoutMs || Infinity)) { const tick = Date.now() const receipt = await this.getMessageReceipt( resolved, messageIndex, opts.fromBlockOrBlockHash, opts.toBlockOrHash ) if (receipt !== null) { return receipt } else { await sleep(opts.pollIntervalMs || 4000) totalTimeMs += Date.now() - tick } } throw new Error(`timed out waiting for message receipt`) } /** * Waits until the status of a given message changes to the expected status. Note that if the * status of the given message changes to a status that implies the expected status, this will * still return. If the status of the message changes to a status that exclues the expected * status, this will throw an error. * * @param message Message to wait for. * @param status Expected status of the message. * @param opts Options to pass to the waiting function. * @param opts.pollIntervalMs Number of milliseconds to wait when polling. * @param opts.timeoutMs Milliseconds to wait before timing out. * @param opts.fromBlockOrBlockHash The start block to use for the query filter on the RECEIVING chain * @param opts.toBlockOrBlockHash The end block to use for the query filter on the RECEIVING chain * @param messageIndex The index of the message, if multiple exist from multicall */ public async waitForMessageStatus( message: MessageLike, status: MessageStatus, opts: { fromBlockOrBlockHash?: BlockTag toBlockOrBlockHash?: BlockTag pollIntervalMs?: number timeoutMs?: number } = {}, messageIndex = 0 ): Promise<void> { // Resolving once up-front is slightly more efficient. const resolved = await this.toCrossChainMessage(message, messageIndex) let totalTimeMs = 0 while (totalTimeMs < (opts.timeoutMs || Infinity)) { const tick = Date.now() const currentStatus = await this.getMessageStatus( resolved, messageIndex, opts.fromBlockOrBlockHash, opts.toBlockOrBlockHash ) // Handle special cases for L1 to L2 messages. if (resolved.direction === MessageDirection.L1_TO_L2) { // If we're at the expected status, we're done. if (currentStatus === status) { return } if ( status === MessageStatus.UNCONFIRMED_L1_TO_L2_MESSAGE && currentStatus > status ) { // Anything other than UNCONFIRMED_L1_TO_L2_MESSAGE implies that the message was at one // point "unconfirmed", so we can stop waiting. return } if ( status === MessageStatus.FAILED_L1_TO_L2_MESSAGE && currentStatus === MessageStatus.RELAYED ) { throw new Error( `incompatible message status, expected FAILED_L1_TO_L2_MESSAGE got RELAYED` ) } if ( status === MessageStatus.RELAYED && currentStatus === MessageStatus.FAILED_L1_TO_L2_MESSAGE ) { throw new Error( `incompatible message status, expected RELAYED got FAILED_L1_TO_L2_MESSAGE` ) } } // Handle special cases for L2 to L1 messages. if (resolved.direction === MessageDirection.L2_TO_L1) { if (currentStatus >= status) { // For L2 to L1 messages, anything after the expected status implies the previous status, // so we can safely return if the current status enum is larger than the expected one. return } } await sleep(opts.pollIntervalMs || 4000) totalTimeMs += Date.now() - tick } throw new Error(`timed out waiting for message status change`) } /** * Estimates the amount of gas required to fully execute a given message on L2. Only applies to * L1 => L2 messages. You would supply this gas limit when sending the message to L2. * * @param message Message get a gas estimate for. * @param opts Options object. * @param opts.bufferPercent Percentage of gas to add to the estimate. Defaults to 20. * @param opts.from Address to use as the sender. * @returns Estimates L2 gas limit. */ public async estimateL2MessageGasLimit( message: MessageRequestLike, opts?: { bufferPercent?: number from?: string }, messageIndex = 0 ): Promise<BigNumber> { let resolved: CrossChainMessage | CrossChainMessageRequest let from: string if ((message as CrossChainMessage).messageNonce === undefined) { resolved = message as CrossChainMessageRequest from = opts?.from } else { resolved = await this.toCrossChainMessage( message as MessageLike, messageIndex ) from = opts?.from || (resolved as CrossChainMessage).sender } // L2 message gas estimation is only used for L1 => L2 messages. if (resolved.direction === MessageDirection.L2_TO_L1) { throw new Error(`cannot estimate gas limit for L2 => L1 message`) } const estimate = await this.l2Provider.estimateGas({ from, to: resolved.target, data: resolved.message, }) // Return the estimate plus a buffer of 20% just in case. const bufferPercent = opts?.bufferPercent || 20 return estimate.mul(100 + bufferPercent).div(100) } /** * Returns the estimated amount of time before the message can be executed. When this is a * message being sent to L1, this will return the estimated time until the message will complete * its challenge period. When this is a message being sent to L2, this will return the estimated * amount of time until the message will be picked up and executed on L2. * * @param message Message to estimate the time remaining for. * @param messageIndex The index of the message, if multiple exist from multicall * @param opts.fromBlockOrBlockHash The start block to use for the query filter on the RECEIVING chain * @param opts.toBlockOrBlockHash The end block to use for the query filter on the RECEIVING chain * @returns Estimated amount of time remaining (in seconds) before the message can be executed. */ public async estimateMessageWaitTimeSeconds( message: MessageLike, // consider making this an options object next breaking release messageIndex = 0, fromBlockOrBlockHash?: BlockTag, toBlockOrBlockHash?: BlockTag ): Promise<number> { const resolved = await this.toCrossChainMessage(message, messageIndex) const status = await this.getMessageStatus( resolved, messageIndex, fromBlockOrBlockHash, toBlockOrBlockHash ) if (resolved.direction === MessageDirection.L1_TO_L2) { if ( status === MessageStatus.RELAYED || status === MessageStatus.FAILED_L1_TO_L2_MESSAGE ) { // Transactions that are relayed or failed are considered completed, so the wait time is 0. return 0 } else { // Otherwise we need to estimate the number of blocks left until the transaction will be // considered confirmed by the Layer 2 system. Then we multiply this by the estimated // average L1 block time. const receipt = await this.l1Provider.getTransactionReceipt( resolved.transactionHash ) const blocksLeft = Math.max( this.depositConfirmationBlocks - receipt.confirmations, 0 ) return blocksLeft * this.l1BlockTimeSeconds } } else { if ( status === MessageStatus.RELAYED || status === MessageStatus.READY_FOR_RELAY ) { // Transactions that are relayed or ready for relay are considered complete. return 0 } else if (status === MessageStatus.STATE_ROOT_NOT_PUBLISHED) { // If the state root hasn't been published yet, just assume it'll be published relatively // quickly and return the challenge period for now. In the future we could use more // advanced techniques to figure out average time between transaction execution and // state root publication. return this.getChallengePeriodSeconds() } else if (status === MessageStatus.IN_CHALLENGE_PERIOD) { // If the message is still within the challenge period, then we need to estimate exactly // the amount of time left until the challenge period expires. The challenge period starts // when the state root is published. const stateRoot = await this.getMessageStateRoot(resolved, messageIndex) const challengePeriod = await this.getChallengePeriodSeconds() const targetBlock = await this.l1Provider.getBlock( stateRoot.batch.blockNumber ) const latestBlock = await this.l1Provider.getBlock('latest') return Math.max( challengePeriod - (latestBlock.timestamp - targetBlock.timestamp), 0 ) } else { // Should not happen throw new Error(`unexpected message status`) } } } /** * Queries the current challenge period in seconds from the StateCommitmentChain. * * @returns Current challenge period in seconds. */ public async getChallengePeriodSeconds(): Promise<number> { if (!this.bedrock) { return ( await this.contracts.l1.StateCommitmentChain.FRAUD_PROOF_WINDOW() ).toNumber() } const oracleVersion = await this.contracts.l1.L2OutputOracle.version() const challengePeriod = oracleVersion === '1.0.0' ? // The ABI in the SDK does not contain FINALIZATION_PERIOD_SECONDS // in OptimismPortal, so making an explicit call instead. BigNumber.from( await this.contracts.l1.OptimismPortal.provider.call({ to: this.contracts.l1.OptimismPortal.address, data: '0xf4daa291', // FINALIZATION_PERIOD_SECONDS }) ) : await this.contracts.l1.L2OutputOracle.FINALIZATION_PERIOD_SECONDS() return challengePeriod.toNumber() } /** * Queries the OptimismPortal contract's `provenWithdrawals` mapping * for a ProvenWithdrawal that matches the passed withdrawalHash * * @bedrock * Note: This function is bedrock-specific. * * @returns A ProvenWithdrawal object */ public async getProvenWithdrawal( withdrawalHash: string ): Promise<ProvenWithdrawal | null> { if (!this.bedrock) { throw new Error('message proving only applies after the bedrock upgrade') } // Getting the withdrawal is easy before FPAC. if (!(await this.fpac())) { // Grab the proven withdrawal directly by hash. const provenWithdrawal = await this.contracts.l1.OptimismPortal.provenWithdrawals(withdrawalHash) // If the timestamp is 0 then the withdrawal has not been proven. if (provenWithdrawal.timestamp.eq(0)) { return null } else { return provenWithdrawal } } // Getting the withdrawal is a bit more complicated after FPAC. // First we need to get the number of proof submitters for this withdrawal. const numProofSubmitters = BigNumber.from( await this.contracts.l1.OptimismPortal2.numProofSubmitters(withdrawalHash) ).toNumber() // Now we need to find any withdrawal where the output proposal that the withdrawal was proven // against is actually valid. We can use the same output validation cache used elsewhere. for (let i = 0; i < numProofSubmitters; i++) { // Grab the proof submitter. const proofSubmitter = await this.contracts.l1.OptimismPortal2.proofSubmitters( withdrawalHash, i ) // Grab the ProvenWithdrawal struct for this proof. const provenWithdrawal = await this.contracts.l1.OptimismPortal2.provenWithdrawals( withdrawalHash, proofSubmitter ) // Grab the game that was proven against. const game = new ethers.Contract( provenWithdrawal.disputeGameProxy, getContractInterfaceBedrock('FaultDisputeGame'), this.l1SignerOrProvider ) // Check the game status. const status = await game.status() if (status === 1) { // If status is CHALLENGER_WINS then it's no good. continue } else if (status === 2) { // If status is DEFENDER_WINS then it's a valid proof. return { ...provenWithdrawal, proofSubmitter, } } else if (status > 2) { // Shouldn't happen in practice. throw new Error('got invalid game status') } // Otherwise we're IN_PROGRESS. // Grab the block number from the extra data. Since this is not a standardized field we need // to be defensive and assume that the extra data could be anything. If the extra data does // not decode properly then we just skip this game. const extraData = await game.extraData() let l2BlockNumber: number try { ;[l2BlockNumber] = ethers.utils.defaultAbiCoder.decode( ['uint256'], extraData ) } catch (err) { // Didn't decode properly, bad game. continue } // Finally we check if the output root is valid. If it is, then we can return the proven // withdrawal. If it isn't, then we act as if this proof does not exist because it isn't // useful for finalizing the withdrawal. if (await this.isValidOutputRoot(await game.rootClaim(), l2BlockNumber)) { return { ...provenWithdrawal, proofSubmitter, } } } // Return null if we didn't find a valid proof. return null } /** * Checks whether a given root claim is valid. Uses the L2 node that the SDK is connected to * when verifying the claim. Assumes that the connected L2 node is honest. * * @param outputRoot Output root to verify. * @param l2BlockNumber L2 block number the root is for. * @returns Whether or not the root is valid. */ public async isValidOutputRoot( outputRoot: string, l2BlockNumber: number ): Promise<boolean> { // Use the cache if we can. const cached = this._outputCache.find((other) => { return other.root === outputRoot }) // Skip if we can use the cached. if (cached) { return cached.valid } // If the cache ever gets to 10k elements, clear out the first half. Works well enough // since the cache will generally tend to be used in a FIFO manner. if (this._outputCache.length > 10000) { this._outputCache = this._outputCache.slice(5000) } // We didn't hit the cache so we're going to have to do the work. try { // Make sure this is a JSON RPC provider. const provider = toJsonRpcProvider(this.l2Provider) // Grab the block and storage proof at the same time. const [block, proof] = await Promise.all([ provider.send('eth_getBlockByNumber', [ toRpcHexString(l2BlockNumber), false, ]), makeStateTrieProof( provider, l2BlockNumber, this.contracts.l2.OVM_L2ToL1MessagePasser.address, ethers.constants.HashZero ), ]) // Compute the output. const output = ethers.utils.solidityKeccak256( ['bytes32', 'bytes32', 'bytes32', 'bytes32'], [ ethers.constants.HashZero, block.stateRoot, proof.storageRoot, block.hash, ] ) // If the output matches the proposal then we're good. const valid = output === outputRoot this._outputCache.push({ root: outputRoot, valid }) return valid } catch (err) { // Assume the game is invalid but don't add it to the cache just in case we had a temp error. return false } } /** * Returns the Bedrock output root that corresponds to the given message. * * @param message Message to get the Bedrock output root for. * @param messageIndex The index of the message, if multiple ex