diff --git a/.changeset/young-gorillas-roll.md b/.changeset/young-gorillas-roll.md new file mode 100644 index 000000000000..434f6f266229 --- /dev/null +++ b/.changeset/young-gorillas-roll.md @@ -0,0 +1,5 @@ +--- +'@eth-optimism/sdk': patch +--- + +Fixed bug where replayable transactions would fail `finalize` if they previously were marked as errors but replayable. diff --git a/bedrock-devnet/devnet/__init__.py b/bedrock-devnet/devnet/__init__.py index 09766bdd9c37..6e875ebf8d3c 100644 --- a/bedrock-devnet/devnet/__init__.py +++ b/bedrock-devnet/devnet/__init__.py @@ -282,7 +282,7 @@ def devnet_test(paths): ['npx', 'hardhat', 'deposit-eth', '--network', 'devnetL1', '--l1-contracts-json-path', paths.addresses_json_path, '--signer-index', '15'], cwd=paths.sdk_dir, timeout=8*60) - ], max_workers=2) + ], max_workers=1) def run_commands(commands: list[CommandPreset], max_workers=2): diff --git a/package.json b/package.json index f3cf78bca9d2..820e9311d9b9 100644 --- a/package.json +++ b/package.json @@ -70,12 +70,13 @@ "lint-staged": "15.2.0", "mocha": "^10.2.0", "nx": "18.1.2", + "nx-cloud": "latest", "nyc": "^15.1.0", "prettier": "^2.8.0", "rimraf": "^5.0.5", "ts-mocha": "^10.0.0", "typescript": "^5.3.3", - "nx-cloud": "latest" + "wait-on": "^7.2.0" }, "dependencies": { "@changesets/cli": "^2.27.1" diff --git a/packages/sdk/src/cross-chain-messenger.ts b/packages/sdk/src/cross-chain-messenger.ts index 4141431a0627..65df54dcf460 100644 --- a/packages/sdk/src/cross-chain-messenger.ts +++ b/packages/sdk/src/cross-chain-messenger.ts @@ -694,7 +694,13 @@ export class CrossChainMessenger { 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 { const resolved = await this.toCrossChainMessage(message, messageIndex) @@ -2301,15 +2307,64 @@ export class CrossChainMessenger { } if (this.bedrock) { - const withdrawal = await this.toLowLevelMessage(resolved, messageIndex) + // get everything we need to finalize + const messageHashV1 = hashCrossDomainMessagev1( + resolved.messageNonce, + resolved.sender, + resolved.target, + resolved.value, + resolved.minGasLimit, + resolved.message + ) + + // fetch the following + // 1. Whether it needs to be replayed because it failed + // 2. The withdrawal as a low level message + const [isFailed, withdrawal] = await Promise.allSettled([ + this.contracts.l1.L1CrossDomainMessenger.failedMessages( + messageHashV1 + ), + this.toLowLevelMessage(resolved, messageIndex), + ]) + + // handle errors + if ( + isFailed.status === 'rejected' || + withdrawal.status === 'rejected' + ) { + const rejections = [isFailed, withdrawal] + .filter((p) => p.status === 'rejected') + .map((p: PromiseRejectedResult) => p.reason) + throw rejections.length > 1 + ? new AggregateError(rejections) + : rejections[0] + } + + if (isFailed.value === true) { + const xdmWithdrawal = + this.contracts.l1.L1CrossDomainMessenger.interface.decodeFunctionData( + 'relayMessage', + withdrawal.value.message + ) + return this.contracts.l1.L1CrossDomainMessenger.populateTransaction.relayMessage( + xdmWithdrawal._nonce, + xdmWithdrawal._sender, + xdmWithdrawal._target, + xdmWithdrawal._value, + xdmWithdrawal._minGasLimit, + xdmWithdrawal._message, + opts?.overrides || {} + ) + } + return this.contracts.l1.OptimismPortal.populateTransaction.finalizeWithdrawalTransaction( [ - withdrawal.messageNonce, - withdrawal.sender, - withdrawal.target, - withdrawal.value, - withdrawal.minGasLimit, - withdrawal.message, + withdrawal.value.messageNonce, + withdrawal.value.sender, + withdrawal.value.target, + withdrawal.value.value, + withdrawal.value.minGasLimit, + withdrawal.value.message, ], opts?.overrides || {} ) diff --git a/packages/sdk/test-next/failedMessages.spec.ts b/packages/sdk/test-next/failedMessages.spec.ts new file mode 100644 index 000000000000..ac2ea9554a68 --- /dev/null +++ b/packages/sdk/test-next/failedMessages.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { Address, Hex, encodePacked, keccak256, toHex } from 'viem' +import { ethers } from 'ethers' +import { z } from 'zod' +import { hashCrossDomainMessagev1 } from '@eth-optimism/core-utils' +import { optimismSepolia } from 'viem/chains' + +import { CONTRACT_ADDRESSES, CrossChainMessenger } from '../src' +import { sepoliaPublicClient, sepoliaTestClient } from './testUtils/viemClients' +import { sepoliaProvider, opSepoliaProvider } from './testUtils/ethersProviders' + +/** + * Generated on Mar 28 2024 using + * `forge inspect L1CrossDomainMessenger storage-layout` + **/ +const failedMessagesStorageLayout = { + astId: 7989, + contract: 'src/L1/L1CrossDomainMessenger.sol:L1CrossDomainMessenger', + label: 'failedMessages', + offset: 0, + slot: 206n, + type: 't_mapping(t_bytes32,t_bool)', +} + +const sepoliaCrossDomainMessengerAddress = CONTRACT_ADDRESSES[ + optimismSepolia.id +].l1.L1CrossDomainMessenger as Address + +const setMessageAsFailed = async (tx: Hex) => { + const message = await crossChainMessenger.toCrossChainMessage(tx) + const messageHash = hashCrossDomainMessagev1( + message.messageNonce, + message.sender, + message.target, + message.value, + message.minGasLimit, + message.message + ) as Hex + + const keySlotHash = keccak256( + encodePacked( + ['bytes32', 'uint256'], + [messageHash, failedMessagesStorageLayout.slot] + ) + ) + return sepoliaTestClient.setStorageAt({ + address: sepoliaCrossDomainMessengerAddress, + index: keySlotHash, + value: toHex(true, { size: 32 }), + }) +} + +const E2E_PRIVATE_KEY = z + .string() + .describe('Private key') + // Mnemonic: test test test test test test test test test test test junk + .default('0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6') + .parse(import.meta.env.VITE_E2E_PRIVATE_KEY) + +const sepoliaWallet = new ethers.Wallet(E2E_PRIVATE_KEY, sepoliaProvider) +const crossChainMessenger = new CrossChainMessenger({ + l1SignerOrProvider: sepoliaWallet, + l2SignerOrProvider: opSepoliaProvider, + l1ChainId: 11155111, + l2ChainId: 11155420, + bedrock: true, +}) + +describe('replaying failed messages', () => { + it('should be able to replay failed messages', async () => { + // Grab an existing tx but mark it as failed + // @see https://sepolia-optimism.etherscan.io/tx/0x28249a36f764afab583a4633d59ff6c2a0e934293062bffa7cedb662e5da9abd + const tx = + '0x28249a36f764afab583a4633d59ff6c2a0e934293062bffa7cedb662e5da9abd' + + await setMessageAsFailed(tx) + + // debugging ethers.js is brutal because of error message so let's instead + // send the tx with viem. If it succeeds we will then test with ethers + const txData = + await crossChainMessenger.populateTransaction.finalizeMessage(tx) + + await sepoliaPublicClient.call({ + data: txData.data as Hex, + to: txData.to as Address, + }) + + // finalize the message + const finalizeTx = await crossChainMessenger.finalizeMessage(tx) + + const receipt = await finalizeTx.wait() + + expect(receipt.transactionHash).toBeDefined() + }) +}) diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 5cb4fda3c546..28d2dacc7f69 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "lib": ["ES2021"], "rootDir": "./src", "outDir": "./dist" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 147dc061bcd7..a8256020ff2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: typescript: specifier: ^5.3.3 version: 5.3.3 + wait-on: + specifier: ^7.2.0 + version: 7.2.0 endpoint-monitor: {} @@ -15323,6 +15326,12 @@ packages: resolution: {integrity: sha512-4VlvkRUuCJvr2J6Y0ImW7NvTCriMi7ErOAqWk1y69vAdoNIzCF3yPmgeNzx+RQTLEDFq5sHfscn1MwHxP9hNfA==} dev: true + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.6.2 + dev: true + /safe-array-concat@1.0.0: resolution: {integrity: sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==} engines: {node: '>=0.4'} @@ -17338,6 +17347,20 @@ packages: - zod dev: true + /wait-on@7.2.0: + resolution: {integrity: sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + axios: 1.6.7 + joi: 17.11.0 + lodash: 4.17.21 + minimist: 1.2.8 + rxjs: 7.8.1 + transitivePeerDependencies: + - debug + dev: true + /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: