diff --git a/packages/tokamak/sdk/src/portals.ts b/packages/tokamak/sdk/src/portals.ts index 184a116f0..18ba52c7c 100644 --- a/packages/tokamak/sdk/src/portals.ts +++ b/packages/tokamak/sdk/src/portals.ts @@ -12,7 +12,10 @@ import { DepositTx, toRpcHexString, predeploys, + BedrockOutputData, + BedrockCrossChainMessageProof, } from '@tokamak-network/core-utils' +import semver from 'semver' import { DepositTransactionRequest, @@ -24,9 +27,10 @@ import { WithdrawalMessageInfo, WithdrawalTransactionRequest, MessageStatus, + StateRoot, + StateRootBatch, } from './interfaces' import { - calculateWithdrawalMessage, hashLowLevelMessage, hashMessageHash, makeStateTrieProof, @@ -37,6 +41,8 @@ import { DEPOSIT_CONFIRMATION_BLOCKS, CHAIN_BLOCK_TIMES, calculateWithdrawalMessageUsingRecept, + getContractInterfaceBedrock, + toJsonRpcProvider, } from './utils' export class Portals { @@ -75,6 +81,16 @@ export class Portals { */ 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. * @@ -86,6 +102,7 @@ export class Portals { * @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.bedrock Whether or not to enable Bedrock compatibility. */ constructor(opts: { l1SignerOrProvider: SignerOrProviderLike @@ -95,7 +112,9 @@ export class Portals { depositConfirmationBlocks?: NumberLike l1BlockTimeSeconds?: NumberLike contracts?: DeepPartial + bedrock?: boolean }) { + this.bedrock = opts.bedrock ?? true this.l1SignerOrProvider = toSignerOrProvider(opts.l1SignerOrProvider) this.l2SignerOrProvider = toSignerOrProvider(opts.l2SignerOrProvider) @@ -176,26 +195,6 @@ export class Portals { return this.contracts.l2.OVM_L1BlockNumber.getL1BlockNumber() } - public async waitingDepositTransactionRelayed( - txReceipt: TransactionReceipt, - opts?: { - pollIntervalMs?: number - timeoutMs?: number - } - ): Promise { - const l1BlockNumber = txReceipt.blockNumber - let totalTimeMs = 0 - while (totalTimeMs < (opts?.timeoutMs || Infinity)) { - const tick = Date.now() - if (l1BlockNumber <= (await this.getL1BlockNumber())) { - return this.calculateRelayedDepositTxID(txReceipt) - } - await sleep(opts?.pollIntervalMs || 1000) - totalTimeMs += Date.now() - tick - } - throw new Error(`timed out waiting for relayed deposit transaction`) - } - public async getMessageStatus( txReceipt: TransactionReceipt ): Promise { @@ -213,51 +212,393 @@ export class Portals { : MessageStatus.RELAYED } + /** + * Returns the StateBatchAppended event that was emitted when the batch with a given index was + * created. Returns null if no such event exists (the batch has not been submitted). + * + * @param batchIndex Index of the batch to find an event for. + * @returns StateBatchAppended event for the batch, or null if no such batch exists. + */ + public async getStateBatchAppendedEventByBatchIndex( + batchIndex: number + ): Promise { + const events = await this.contracts.l1.StateCommitmentChain.queryFilter( + this.contracts.l1.StateCommitmentChain.filters.StateBatchAppended( + batchIndex + ) + ) + + if (events.length === 0) { + return null + } else if (events.length > 1) { + // Should never happen! + throw new Error(`found more than one StateBatchAppended event`) + } else { + return events[0] + } + } + + /** + * Returns the StateBatchAppended event for the batch that includes the transaction with the + * given index. Returns null if no such event exists. + * + * @param transactionIndex Index of the L2 transaction to find an event for. + * @returns StateBatchAppended event for the batch that includes the given transaction by index. + */ + public async getStateBatchAppendedEventByTransactionIndex( + transactionIndex: number + ): Promise { + const isEventHi = (event: ethers.Event, index: number) => { + const prevTotalElements = event.args._prevTotalElements.toNumber() + return index < prevTotalElements + } + + const isEventLo = (event: ethers.Event, index: number) => { + const prevTotalElements = event.args._prevTotalElements.toNumber() + const batchSize = event.args._batchSize.toNumber() + return index >= prevTotalElements + batchSize + } + + const totalBatches: ethers.BigNumber = + await this.contracts.l1.StateCommitmentChain.getTotalBatches() + if (totalBatches.eq(0)) { + return null + } + + let lowerBound = 0 + let upperBound = totalBatches.toNumber() - 1 + let batchEvent: ethers.Event | null = + await this.getStateBatchAppendedEventByBatchIndex(upperBound) + + // Only happens when no batches have been submitted yet. + if (batchEvent === null) { + return null + } + + if (isEventLo(batchEvent, transactionIndex)) { + // Upper bound is too low, means this transaction doesn't have a corresponding state batch yet. + return null + } else if (!isEventHi(batchEvent, transactionIndex)) { + // Upper bound is not too low and also not too high. This means the upper bound event is the + // one we're looking for! Return it. + return batchEvent + } + + // Binary search to find the right event. The above checks will guarantee that the event does + // exist and that we'll find it during this search. + while (lowerBound < upperBound) { + const middleOfBounds = Math.floor((lowerBound + upperBound) / 2) + batchEvent = await this.getStateBatchAppendedEventByBatchIndex( + middleOfBounds + ) + + if (isEventHi(batchEvent, transactionIndex)) { + upperBound = middleOfBounds + } else if (isEventLo(batchEvent, transactionIndex)) { + lowerBound = middleOfBounds + } else { + break + } + } + + return batchEvent + } + + /** + * Returns information about the state root batch that included the state root for the given + * transaction by index. Returns null if no such state root has been published yet. + * + * @param transactionIndex Index of the L2 transaction to find a state root batch for. + * @returns State root batch for the given transaction index, or null if none exists yet. + */ + public async getStateRootBatchByTransactionIndex( + transactionIndex: number + ): Promise { + const stateBatchAppendedEvent = + await this.getStateBatchAppendedEventByTransactionIndex(transactionIndex) + if (stateBatchAppendedEvent === null) { + return null + } + + const stateBatchTransaction = await stateBatchAppendedEvent.getTransaction() + const [stateRoots] = + this.contracts.l1.StateCommitmentChain.interface.decodeFunctionData( + 'appendStateBatch', + stateBatchTransaction.data + ) + + return { + blockNumber: stateBatchAppendedEvent.blockNumber, + stateRoots, + header: { + batchIndex: stateBatchAppendedEvent.args._batchIndex, + batchRoot: stateBatchAppendedEvent.args._batchRoot, + batchSize: stateBatchAppendedEvent.args._batchSize, + prevTotalElements: stateBatchAppendedEvent.args._prevTotalElements, + extraData: stateBatchAppendedEvent.args._extraData, + }, + } + } + + /** + * Returns the state root that corresponds to a given message. This is the state root for the + * block in which the transaction was included, as published to the StateCommitmentChain. If the + * state root for the given message has not been published yet, this function returns null. + * + * @param message Message to find a state root for. + * @param messageIndex The index of the message, if multiple exist from multicall + * @returns State root for the block in which the message was created. + */ + public async getMessageStateRoot( + transactionHash: string + ): Promise { + // We need the block number of the transaction that triggered the message so we can look up the + // state root batch that corresponds to that block number. + const messageTxReceipt = await this.l2Provider.getTransactionReceipt( + transactionHash + ) + + // Every block has exactly one transaction in it. Since there's a genesis block, the + // transaction index will always be one less than the block number. + const messageTxIndex = messageTxReceipt.blockNumber - 1 + + // Pull down the state root batch, we'll try to pick out the specific state root that + // corresponds to our message. + const stateRootBatch = await this.getStateRootBatchByTransactionIndex( + messageTxIndex + ) + + // No state root batch, no state root. + if (stateRootBatch === null) { + return null + } + + // We have a state root batch, now we need to find the specific state root for our transaction. + // First we need to figure out the index of the state root within the batch we found. This is + // going to be the original transaction index offset by the total number of previous state + // roots. + const indexInBatch = + messageTxIndex - stateRootBatch.header.prevTotalElements.toNumber() + + // Just a sanity check. + if (stateRootBatch.stateRoots.length <= indexInBatch) { + // Should never happen! + throw new Error(`state root does not exist in batch`) + } + + return { + stateRoot: stateRootBatch.stateRoots[indexInBatch], + stateRootIndexInBatch: indexInBatch, + batch: stateRootBatch, + } + } + + /** + * 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 exist from multicall + * @returns Bedrock output root. + */ + public async getMessageBedrockOutput( + l2BlockNumber: number + ): Promise { + let proposal: any + let l2OutputIndex: BigNumber + if (await this.fpac()) { + // Get the respected game type from the portal. + const gameType = + await this.contracts.l1.OptimismPortal2.respectedGameType() + + // Get the total game count from the DisputeGameFactory since that will give us the end of + // the array that we're searching over. We'll then use that to find the latest games. + const gameCount = await this.contracts.l1.DisputeGameFactory.gameCount() + + // Find the latest 100 games (or as many as we can up to 100). + const latestGames = + await this.contracts.l1.DisputeGameFactory.findLatestGames( + gameType, + Math.max(0, gameCount.sub(1).toNumber()), + Math.min(100, gameCount.toNumber()) + ) + + // Find all games that are for proposals about blocks newer than the message block. + const matches: any[] = [] + for (const game of latestGames) { + try { + const [blockNumber] = ethers.utils.defaultAbiCoder.decode( + ['uint256'], + game.extraData + ) + if (blockNumber.gte(l2BlockNumber)) { + matches.push({ + ...game, + l2BlockNumber: blockNumber, + }) + } + } catch (err) { + // If we can't decode the extra data then we just skip this game. + continue + } + } + + // Shuffle the list of matches. We shuffle here to avoid potential DoS vectors where the + // latest games are all invalid and the SDK would be forced to make a bunch of archive calls. + for (let i = matches.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[matches[i], matches[j]] = [matches[j], matches[i]] + } + + // Now we verify the proposals in the matches array. + let match: any + for (const option of matches) { + if ( + await this.isValidOutputRoot(option.rootClaim, option.l2BlockNumber) + ) { + match = option + break + } + } + + // If there's no match then we can't prove the message to the portal. + if (!match) { + return null + } + + // Put the result into the same format as the old logic for now to reduce added code. + l2OutputIndex = match.index + proposal = { + outputRoot: match.rootClaim, + timestamp: match.timestamp, + l2BlockNumber: match.l2BlockNumber, + } + } else { + // Try to find the output index that corresponds to the block number attached to the message. + // We'll explicitly handle "cannot get output" errors as a null return value, but anything else + // needs to get thrown. Might need to revisit this in the future to be a little more robust + // when connected to RPCs that don't return nice error messages. + try { + l2OutputIndex = + await this.contracts.l1.L2OutputOracle.getL2OutputIndexAfter( + l2BlockNumber + ) + } catch (err) { + if (err.message.includes('L2OutputOracle: cannot get output')) { + return null + } else { + throw err + } + } + + // Now pull the proposal out given the output index. Should always work as long as the above + // codepath completed successfully. + proposal = await this.contracts.l1.L2OutputOracle.getL2Output( + l2OutputIndex + ) + } + + // Format everything and return it nicely. + return { + outputRoot: proposal.outputRoot, + l1Timestamp: proposal.timestamp.toNumber(), + l2BlockNumber: proposal.l2BlockNumber.toNumber(), + l2OutputIndex: l2OutputIndex.toNumber(), + } + } + public async getL2ToL1MessageStatusByReceipt( txReceipt: TransactionReceipt ): Promise { - const l2BlockNumber = txReceipt.blockNumber - if (l2BlockNumber > (await this.getL2BlockNumberInOO())) { - return MessageStatus.STATE_ROOT_NOT_PUBLISHED - } const withdrawalMessageInfo = await this.calculateWithdrawalMessage( txReceipt ) - const provenWithdrawal = await this.getProvenWithdrawal( + + if (!withdrawalMessageInfo) { + throw Error('withdrawal message not found') + } + + const finalizedMessage = await this.getFinalizedWithdrawalStatus( withdrawalMessageInfo.withdrawalHash ) - if (!provenWithdrawal || provenWithdrawal.timestamp.toNumber() === 0) { - return MessageStatus.READY_TO_PROVE + if (finalizedMessage) { + return MessageStatus.RELAYED } - const BUFFER_TIME = 12 - const currentTimestamp = Date.now() - const finalizedPeriod = await this.getChallengePeriodSeconds() - if ( - currentTimestamp / 1000 - BUFFER_TIME < - provenWithdrawal.timestamp.toNumber() + finalizedPeriod - ) { - return MessageStatus.IN_CHALLENGE_PERIOD + let timestamp: number + if (this.bedrock) { + const output = await this.getMessageBedrockOutput( + withdrawalMessageInfo.l2BlockNumber + ) + if (output === null) { + return MessageStatus.STATE_ROOT_NOT_PUBLISHED + } + + const provenWithdrawal = await this.getProvenWithdrawal( + withdrawalMessageInfo.withdrawalHash + ) + if (!provenWithdrawal || provenWithdrawal.timestamp.toNumber() === 0) { + return MessageStatus.READY_TO_PROVE + } + + timestamp = provenWithdrawal.timestamp.toNumber() + } else { + const stateRoot = await this.getMessageStateRoot( + txReceipt.transactionHash + ) + if (stateRoot === null) { + return MessageStatus.STATE_ROOT_NOT_PUBLISHED + } + + const bn = stateRoot.batch.blockNumber + const block = await this.l1Provider.getBlock(bn) + + timestamp = block.timestamp } - const finalizedStatus = await this.getFinalizedWithdrawalStatus( - withdrawalMessageInfo.withdrawalHash - ) - return finalizedStatus - ? MessageStatus.RELAYED - : MessageStatus.READY_FOR_RELAY - } - public async waitingDepositTransactionRelayedUsingL1Tx( - transactionHash: string, - opts?: { - pollIntervalMs?: number - timeoutMs?: number + if (await this.fpac()) { + // Grab the proven withdrawal data. + const provenWithdrawal = await this.getProvenWithdrawal( + withdrawalMessageInfo.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(withdrawalMessageInfo), + 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 + } } - ): Promise { - const txReceipt = await this.l1Provider.getTransactionReceipt( - transactionHash - ) - return this.waitingDepositTransactionRelayed(txReceipt, opts) } public async calculateRelayedDepositTxID( @@ -279,10 +620,6 @@ export class Portals { return promiseString } - public async getL2BlockNumberInOO(): Promise { - return this.contracts.l1.L2OutputOracle.latestBlockNumber() - } - public async calculateWithdrawalMessage( txReceipt: TransactionReceipt ): Promise { @@ -304,45 +641,41 @@ export class Portals { return this.calculateWithdrawalMessage(txReceipt) } - public async waitForWithdrawalTxReadyForRelay( + public async waitForMessageStatus( txReceipt: TransactionReceipt, + status: MessageStatus, opts?: { pollIntervalMs?: number timeoutMs?: number } - ) { - const l2BlockNumber = txReceipt.blockNumber + ): Promise { let totalTimeMs = 0 while (totalTimeMs < (opts?.timeoutMs || Infinity)) { const tick = Date.now() - if (l2BlockNumber <= (await this.getL2BlockNumberInOO())) { + + const currentStatus = await this.getMessageStatus(txReceipt) + if (currentStatus >= status) { return } + await sleep(opts?.pollIntervalMs || 1000) totalTimeMs += Date.now() - tick } throw new Error(`timed out waiting for relayed deposit transaction`) } - public async waitForWithdrawalTxReadyForRelayUsingL2Tx( - transactionHash: string, - opts?: { - pollIntervalMs?: number - timeoutMs?: number - } - ) { - const txReceipt = await this.l2Provider.getTransactionReceipt( - transactionHash - ) - return this.waitForWithdrawalTxReadyForRelay(txReceipt) - } - /** * Queries the current challenge period in seconds from the StateCommitmentChain. * * @returns Current challenge period in seconds. */ public async getChallengePeriodSeconds(): Promise { + 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' @@ -358,6 +691,52 @@ export class Portals { return challengePeriod.toNumber() } + /** + * Generates the bedrock proof required to finalize an L2 to L1 message. + * + * @param message Message to generate a proof for. + * @param messageIndex The index of the message, if multiple exist from multicall + * @returns Proof that can be used to finalize the message. + */ + public async getBedrockMessageProof( + withdrawalMessageInfo: WithdrawalMessageInfo + ): Promise { + const output = await this.getMessageBedrockOutput( + withdrawalMessageInfo.l2BlockNumber + ) + if (output === null) { + throw new Error(`state root for message not yet published`) + } + + const hash = hashLowLevelMessage(withdrawalMessageInfo) + const messageSlot = hashMessageHash(hash) + + const provider = toJsonRpcProvider(this.l2Provider) + + const stateTrieProof = await makeStateTrieProof( + provider, + output.l2BlockNumber, + this.contracts.l2.BedrockMessagePasser.address, + messageSlot + ) + + const block = await provider.send('eth_getBlockByNumber', [ + toRpcHexString(output.l2BlockNumber), + false, + ]) + + return { + outputRootProof: { + version: ethers.constants.HashZero, + stateRoot: block.stateRoot, + messagePasserStorageRoot: stateTrieProof.storageRoot, + latestBlockhash: block.hash, + }, + withdrawalProof: stateTrieProof.storageProof, + l2OutputIndex: output.l2OutputIndex, + } + } + /** * Queries the OptimismPortal contract's `provenWithdrawals` mapping * for a ProvenWithdrawal that matches the passed withdrawalHash @@ -370,7 +749,153 @@ export class Portals { public async getProvenWithdrawal( withdrawalHash: string ): Promise { - return this.contracts.l1.OptimismPortal.provenWithdrawals(withdrawalHash) + if (!this.bedrock) { + throw new Error('message proving only applies after the bedrock upgrade') + } + + if (await this.fpac()) { + // 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 + } else { + return this.contracts.l1.OptimismPortal.provenWithdrawals(withdrawalHash) + } + } + + public async isValidOutputRoot( + outputRoot: string, + l2BlockNumber: number + ): Promise { + // 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 + } } public async getFinalizedWithdrawalStatus( @@ -423,46 +948,6 @@ export class Portals { return this.proveWithdrawalTransaction(message, opts) } - public async waitForFinalization( - message: WithdrawalMessageInfo, - opts?: { - pollIntervalMs?: number - timeoutMs?: number - } - ) { - const provenWithdrawal = await this.getProvenWithdrawal( - message.withdrawalHash - ) - const finalizedPeriod = await this.getChallengePeriodSeconds() - const BUFFER_TIME = 12 - let totalTimeMs = 0 - while (totalTimeMs < (opts?.timeoutMs || Infinity)) { - const currentTimestamp = Date.now() - if ( - currentTimestamp / 1000 - BUFFER_TIME > - provenWithdrawal.timestamp.toNumber() + finalizedPeriod - ) { - return - } - await sleep(opts?.pollIntervalMs || 1000) - totalTimeMs += Date.now() - currentTimestamp - } - throw new Error(`timed out waiting for relayed deposit transaction`) - } - - public async waitForFinalizationUsingL2Tx( - transactionHash: string, - opts?: { - pollIntervalMs?: number - timeoutMs?: number - } - ) { - const message = await this.calculateWithdrawalMessageByL2TxHash( - transactionHash - ) - return this.waitForFinalization(message, opts) - } - public async finalizeWithdrawalTransaction( message: WithdrawalMessageInfo, opts?: { @@ -487,6 +972,29 @@ export class Portals { return this.finalizeWithdrawalTransaction(message) } + /** + * 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 { + 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' + ) + } + } + populateTransaction = { depositTransaction: async ( request: DepositTransactionRequest @@ -520,38 +1028,7 @@ export class Portals { overrides?: PayableOverrides } ): Promise => { - const l2OutputIndex = - await this.contracts.l1.L2OutputOracle.getL2OutputIndexAfter( - message.l2BlockNumber - ) - const proposal = await this.contracts.l1.L2OutputOracle.getL2Output( - l2OutputIndex - ) - - const withdrawalInfoSlot = hashMessageHash( - hashLowLevelMessage({ - message: message.message, - messageNonce: message.messageNonce, - minGasLimit: message.minGasLimit, - value: message.value, - sender: message.sender, - target: message.target, - }) - ) - - const storageProof = await makeStateTrieProof( - this.l2Provider as ethers.providers.JsonRpcProvider, - proposal.l2BlockNumber, - this.contracts.l2.BedrockMessagePasser.address, - withdrawalInfoSlot - ) - - const block = await ( - this.l2Provider as ethers.providers.JsonRpcProvider - ).send('eth_getBlockByNumber', [ - toRpcHexString(proposal.l2BlockNumber), - false, - ]) + const proof = await this.getBedrockMessageProof(message) const args = [ [ @@ -562,14 +1039,14 @@ export class Portals { message.minGasLimit, message.message, ], - l2OutputIndex.toNumber(), + proof.l2OutputIndex, [ - ethers.constants.HashZero, - block.stateRoot, - storageProof.storageRoot, // for proving storage root is correct - block.hash, + proof.outputRootProof.version, + proof.outputRootProof.stateRoot, + proof.outputRootProof.messagePasserStorageRoot, + proof.outputRootProof.latestBlockhash, ], - storageProof.storageProof, // for proving withdrawal info + proof.withdrawalProof, opts?.overrides || {}, ] as const return this.contracts.l1.OptimismPortal.populateTransaction.proveWithdrawalTransaction( diff --git a/packages/tokamak/sdk/src/utils/message-utils.ts b/packages/tokamak/sdk/src/utils/message-utils.ts index 3fe1926db..2ec3c833a 100644 --- a/packages/tokamak/sdk/src/utils/message-utils.ts +++ b/packages/tokamak/sdk/src/utils/message-utils.ts @@ -3,7 +3,11 @@ import { BigNumber, utils, ethers } from 'ethers' import { Log, TransactionReceipt } from '@ethersproject/abstract-provider' import { hexDataSlice } from 'ethers/lib/utils' -import { L1ChainID, LowLevelMessage, WithdrawalMessageInfo } from '../interfaces' +import { + L1ChainID, + LowLevelMessage, + WithdrawalMessageInfo, +} from '../interfaces' const { hexDataLength } = utils @@ -65,7 +69,9 @@ export const migratedWithdrawalGasLimit = ( if (chainID === 420) { overhead = BigNumber.from(200_000) } else { - const relayGasBuffer = Object.values(L1ChainID).includes(chainID) ? RELAY_GAS_CHECK_BUFFER : RELAY_GAS_CHECK_BUFFER_INCLUDING_APPROVAL + const relayGasBuffer = Object.values(L1ChainID).includes(chainID) + ? RELAY_GAS_CHECK_BUFFER + : RELAY_GAS_CHECK_BUFFER_INCLUDING_APPROVAL // Dynamic overhead (EIP-150) // We use a constant 1 million gas limit due to the overhead of simulating all migrated withdrawal diff --git a/packages/tokamak/sdk/tasks/deposit-withdraw-erc20.ts b/packages/tokamak/sdk/tasks/deposit-withdraw-erc20.ts index f9f3970a3..32fef40c5 100644 --- a/packages/tokamak/sdk/tasks/deposit-withdraw-erc20.ts +++ b/packages/tokamak/sdk/tasks/deposit-withdraw-erc20.ts @@ -214,7 +214,7 @@ const depositWTON = async (hre: HardhatRuntimeEnvironment) => { OptimismMintableERC20.address, utils.parseEther('1') ) - await depositTx.wait() + const depositReceipt = await depositTx.wait() console.log(`ERC20 deposited - ${depositTx.hash}`) const portals = new Portals({ @@ -227,9 +227,15 @@ const depositWTON = async (hre: HardhatRuntimeEnvironment) => { l2SignerOrProvider: l2Wallet, }) - const relayedDepositTx = - await portals.waitingDepositTransactionRelayedUsingL1Tx(depositTx.hash) - console.log('relayed tx:', relayedDepositTx) + await portals.waitForMessageStatus(depositReceipt, MessageStatus.RELAYED) + + const relayedTxHash = await portals.calculateRelayedDepositTxID( + depositReceipt + ) + console.log('relayed tx:', relayedTxHash) + + const relayedTxReceipt = await l2Provider.getTransactionReceipt(relayedTxHash) + console.log('Relayed tx receipt:', relayedTxReceipt) console.log(`Balance WTON after depositing...`) diff --git a/packages/tokamak/sdk/tasks/deposit-withdraw-eth.ts b/packages/tokamak/sdk/tasks/deposit-withdraw-eth.ts index 1967ee4c0..66fa61177 100644 --- a/packages/tokamak/sdk/tasks/deposit-withdraw-eth.ts +++ b/packages/tokamak/sdk/tasks/deposit-withdraw-eth.ts @@ -147,7 +147,7 @@ const depositETH = async (amount: NumberLike) => { console.log('l2 eth balance:', l2Balance.toString()) const depositTx = await messenger.bridgeETH(amount) - await depositTx.wait() + const depositReceipt = await depositTx.wait() console.log('depositTx:', depositTx.hash) const portals = new Portals({ @@ -160,9 +160,15 @@ const depositETH = async (amount: NumberLike) => { l2SignerOrProvider: l2Wallet, }) - const relayedDepositTx = - await portals.waitingDepositTransactionRelayedUsingL1Tx(depositTx.hash) - console.log('relayed tx:', relayedDepositTx) + await portals.waitForMessageStatus(depositReceipt, MessageStatus.RELAYED) + + const relayedTxHash = await portals.calculateRelayedDepositTxID( + depositReceipt + ) + console.log('relayed tx:', relayedTxHash) + + const relayedTxReceipt = await l2Provider.getTransactionReceipt(relayedTxHash) + console.log('Relayed tx receipt:', relayedTxReceipt) l1Balance = await l1Wallet.getBalance() console.log('l1 eth balance: ', l1Balance.toString()) diff --git a/packages/tokamak/sdk/tasks/portals.ts b/packages/tokamak/sdk/tasks/portals.ts index 1112299e3..2282a8e3b 100644 --- a/packages/tokamak/sdk/tasks/portals.ts +++ b/packages/tokamak/sdk/tasks/portals.ts @@ -124,7 +124,7 @@ const depositViaOP = async (amount: NumberLike) => { const l1ChainId = (await l1Provider.getNetwork()).chainId const l2ChainId = (await l2Provider.getNetwork()).chainId - const portals = new Portals({ + const portal = new Portals({ contracts: { l1: l1Contracts, }, @@ -154,21 +154,18 @@ const depositViaOP = async (amount: NumberLike) => { isCreation: false, data: '0x', } - const estimateGasDeposit = await portals.estimateGas.depositTransaction( + const estimateGasDeposit = await portal.estimateGas.depositTransaction( depositParams ) console.log(`Estimate gas for depositing: ${estimateGasDeposit}`) - const depositTx = await portals.depositTransaction(depositParams) + const depositTx = await portal.depositTransaction(depositParams) const depositReceipt = await depositTx.wait() console.log('depositTx:', depositReceipt.transactionHash) - const relayedTxHash = await portals.waitingDepositTransactionRelayed( - depositReceipt, - {} - ) - const status = await portals.getMessageStatus(depositReceipt) - console.log('deposit status relayed:', status === MessageStatus.RELAYED) + await portal.waitForMessageStatus(depositReceipt, MessageStatus.RELAYED) + + const relayedTxHash = await portal.calculateRelayedDepositTxID(depositReceipt) console.log('relayedTxHash:', relayedTxHash) const depositedTxReceipt = await l2Provider.getTransactionReceipt( relayedTxHash @@ -207,7 +204,7 @@ const depositViaOPV1 = async (mint: NumberLike, value: NumberLike) => { const l1ChainId = (await l1Provider.getNetwork()).chainId const l2ChainId = (await l2Provider.getNetwork()).chainId - const portals = new Portals({ + const portal = new Portals({ contracts: { l1: l1Contracts, }, @@ -239,21 +236,21 @@ const depositViaOPV1 = async (mint: NumberLike, value: NumberLike) => { isCreation: false, data: '0x', } - const estimateGasDeposit = await portals.estimateGas.depositTransaction( + const estimateGasDeposit = await portal.estimateGas.depositTransaction( depositParams ) console.log(`Estimate gas for depositing: ${estimateGasDeposit}`) - const depositTx = await portals.depositTransaction(depositParams) + const depositTx = await portal.depositTransaction(depositParams) const depositReceipt = await depositTx.wait() console.log('depositTx:', depositReceipt.transactionHash) - const relayedTxHash = await portals.waitingDepositTransactionRelayed( - depositReceipt, - {} - ) - const status = await portals.getMessageStatus(depositReceipt) + await portal.waitForMessageStatus(depositReceipt, MessageStatus.RELAYED) + + const status = await portal.getMessageStatus(depositReceipt) console.log('deposit status relayed:', status === MessageStatus.RELAYED) + + const relayedTxHash = await portal.calculateRelayedDepositTxID(depositReceipt) console.log('relayedTxHash:', relayedTxHash) const depositedTxReceipt = await l2Provider.getTransactionReceipt( relayedTxHash @@ -285,7 +282,7 @@ const calculateRelayedTransactionOnL2 = async (txId: string) => { const l1ChainId = (await l1Provider.getNetwork()).chainId const l2ChainId = (await l2Provider.getNetwork()).chainId - const portals = new Portals({ + const portal = new Portals({ contracts: { l1: l1Contracts, }, @@ -297,11 +294,10 @@ const calculateRelayedTransactionOnL2 = async (txId: string) => { const depositReceipt = await l1Provider.getTransactionReceipt(txId) console.log('depositTx:', depositReceipt.transactionHash) - const relayedTxHash = await portals.waitingDepositTransactionRelayed( - depositReceipt, - {} - ) - const status = await portals.getMessageStatus(depositReceipt) + await portal.waitForMessageStatus(depositReceipt, MessageStatus.RELAYED) + + const relayedTxHash = await portal.calculateRelayedDepositTxID(depositReceipt) + const status = await portal.getMessageStatus(depositReceipt) console.log('deposit status relayed:', status === MessageStatus.RELAYED) console.log('relayedTxHash:', relayedTxHash) } @@ -324,10 +320,12 @@ const withdrawViaBedrockMessagePasser = async (amount: NumberLike) => { OptimismPortal: optimismPortal, L2OutputOracle: l2OutputOracle, } + console.log('l1Contracts', l1Contracts) + const l1ChainId = (await l1Provider.getNetwork()).chainId const l2ChainId = (await l2Provider.getNetwork()).chainId - const portals = new Portals({ + const portal = new Portals({ contracts: { l1: l1Contracts, }, @@ -335,6 +333,7 @@ const withdrawViaBedrockMessagePasser = async (amount: NumberLike) => { l2ChainId, l1SignerOrProvider: l1Wallet, l2SignerOrProvider: l2Wallet, + bedrock: true, }) let l2NativeTokenBalance = await l2NativeTokenContract.balanceOf( @@ -349,155 +348,62 @@ const withdrawViaBedrockMessagePasser = async (amount: NumberLike) => { target: l1Wallet.address, value: BigNumber.from(amount), gasLimit: BigNumber.from('200000'), - data: '0x12345678', + data: '0x', } - const initiateWithdrawalGas = await portals.estimateGas.initiateWithdrawal( + const initiateWithdrawalGas = await portal.estimateGas.initiateWithdrawal( initiateWithdrawalParams ) console.log(`Estimate gas for initiating withdraw: ${initiateWithdrawalGas}`) - const withdrawalTx = await portals.initiateWithdrawal( - initiateWithdrawalParams - ) + const withdrawalTx = await portal.initiateWithdrawal(initiateWithdrawalParams) + console.log(`transaction receipt hash: ${withdrawalTx.hash}`) const withdrawalReceipt = await withdrawalTx.wait() - const withdrawalMessageInfo = await portals.calculateWithdrawalMessage( + const withdrawalMessageInfo = await portal.calculateWithdrawalMessage( withdrawalReceipt ) console.log('withdrawalMessageInfo:', withdrawalMessageInfo) - let status = await portals.getMessageStatus(withdrawalReceipt) + let status = await portal.getMessageStatus(withdrawalReceipt) console.log('[Withdrawal Status] check publish L2 root:', status) - await portals.waitForWithdrawalTxReadyForRelay(withdrawalReceipt) + await portal.waitForMessageStatus( + withdrawalReceipt, + MessageStatus.READY_TO_PROVE + ) - status = await portals.getMessageStatus(withdrawalReceipt) + status = await portal.getMessageStatus(withdrawalReceipt) console.log('[Withdrawal Status] check ready for proving:', status) - const proveTransaction = await portals.proveWithdrawalTransaction( + const proveTransaction = await portal.proveWithdrawalTransaction( withdrawalMessageInfo ) await proveTransaction.wait() - status = await portals.getMessageStatus(withdrawalReceipt) + status = await portal.getMessageStatus(withdrawalReceipt) console.log('[Withdrawal Status] check in challenging:', status) - await portals.waitForFinalization(withdrawalMessageInfo) + await portal.waitForMessageStatus( + withdrawalReceipt, + MessageStatus.READY_FOR_RELAY + ) const estimateFinalizedTransaction = - await portals.estimateGas.finalizeWithdrawalTransaction( + await portal.estimateGas.finalizeWithdrawalTransaction( withdrawalMessageInfo ) console.log( `Estimate gas for finalizing transaction: ${estimateFinalizedTransaction}` ) - const finalizedTransaction = await portals.finalizeWithdrawalTransaction( + const finalizedTransaction = await portal.finalizeWithdrawalTransaction( withdrawalMessageInfo ) const finalizedTransactionReceipt = await finalizedTransaction.wait() console.log('finalized transaction receipt:', finalizedTransactionReceipt) - status = await portals.getMessageStatus(withdrawalReceipt) - console.log('[Withdrawal Status] check relayed:', status) - - const transferTx = await l2NativeTokenContract.transferFrom( - l1Contracts.OptimismPortal, - l1Wallet.address, - amount - ) - await transferTx.wait() - l2NativeTokenBalance = await l2NativeTokenContract.balanceOf(l1Wallet.address) - console.log( - 'l2 native token balance after withdraw in L1: ', - l2NativeTokenBalance.toString() - ) -} - -const withdrawViaBedrockMessagePasserV2 = async (amount: NumberLike) => { - console.log('Withdraw Native token:', amount) - console.log('Native token address:', l2NativeToken) - - const l1Wallet = new ethers.Wallet(privateKey, l1Provider) - const l2Wallet = new ethers.Wallet(privateKey, asL2Provider(l2Provider)) - - const l2NativeTokenContract = new ethers.Contract( - l2NativeToken, - erc20ABI, - l1Wallet - ) - - const l1Contracts = { - AddressManager: addressManager, - OptimismPortal: optimismPortal, - L2OutputOracle: l2OutputOracle, - } - const l1ChainId = (await l1Provider.getNetwork()).chainId - const l2ChainId = (await l2Provider.getNetwork()).chainId - - const portals = new Portals({ - contracts: { - l1: l1Contracts, - }, - l1ChainId, - l2ChainId, - l1SignerOrProvider: l1Wallet, - l2SignerOrProvider: l2Wallet, - }) - - let l2NativeTokenBalance = await l2NativeTokenContract.balanceOf( - l1Wallet.address - ) - console.log( - 'l2 native token balance before withdraw in L1: ', - l2NativeTokenBalance.toString() - ) - - const withdrawalTx = await portals.initiateWithdrawal({ - target: l1Wallet.address, - value: BigNumber.from(amount), - gasLimit: BigNumber.from('200000'), - data: '0x12345678', - }) - const withdrawalReceipt = await withdrawalTx.wait() - - let status = await portals.getL2ToL1MessageStatusByReceipt(withdrawalReceipt) - console.log('[Withdrawal Status] check publish L2 root:', status) - - await portals.waitForWithdrawalTxReadyForRelayUsingL2Tx( - withdrawalReceipt.transactionHash - ) - - status = await portals.getL2ToL1MessageStatusByReceipt(withdrawalReceipt) - console.log('[Withdrawal Status] check ready for proving:', status) - - const proveTransaction = await portals.proveWithdrawalTransactionUsingL2Tx( - withdrawalReceipt.transactionHash - ) - await proveTransaction.wait() - - status = await portals.getL2ToL1MessageStatusByReceipt(withdrawalReceipt) - console.log('[Withdrawal Status] check in challenging:', status) - - await portals.waitForFinalizationUsingL2Tx(withdrawalReceipt.transactionHash) - status = await portals.getL2ToL1MessageStatusByReceipt(withdrawalReceipt) - console.log('[Withdrawal Status] check ready for relay:', status) - - const finalizedTransaction = - await portals.finalizeWithdrawalTransactionUsingL2Tx( - withdrawalReceipt.transactionHash - ) - const finalizedTransactionReceipt = await finalizedTransaction.wait() - console.log('finalized transaction receipt:', finalizedTransactionReceipt) - - status = await portals.getL2ToL1MessageStatusByReceipt(withdrawalReceipt) + status = await portal.getMessageStatus(withdrawalReceipt) console.log('[Withdrawal Status] check relayed:', status) - const transferTx = await l2NativeTokenContract.transferFrom( - l1Contracts.OptimismPortal, - l1Wallet.address, - amount - ) - await transferTx.wait() l2NativeTokenBalance = await l2NativeTokenContract.balanceOf(l1Wallet.address) console.log( 'l2 native token balance after withdraw in L1: ', @@ -530,16 +436,6 @@ task( await withdrawViaBedrockMessagePasser(args.amount) }) -task( - 'withdraw-portal-v2', - 'Withdraw L2NativeToken to L1 via BedrockMessagePasser.' -) - .addParam('amount', 'Withdrawal amount', '1', types.string) - .setAction(async (args, hre) => { - await updateAddresses(hre) - await withdrawViaBedrockMessagePasserV2(args.amount) - }) - task('calculate-hash', 'Calculate relayed deposit hash') .addParam('amount', 'Withdrawal amount', '1', types.string) .setAction(async (args, hre) => {