diff --git a/.changeset/weak-bags-tease.md b/.changeset/weak-bags-tease.md new file mode 100644 index 0000000000..0c421bf2ec --- /dev/null +++ b/.changeset/weak-bags-tease.md @@ -0,0 +1,5 @@ +--- +"viem": patch +--- + +**OP Stack:** Fixed `getWithdrawalStatus` for Upgrade 16. diff --git a/src/op-stack/abis.ts b/src/op-stack/abis.ts index 2a09066ece..b4aaa18cb3 100644 --- a/src/op-stack/abis.ts +++ b/src/op-stack/abis.ts @@ -1488,6 +1488,19 @@ export const portal2Abi = [ stateMutability: 'view', type: 'function', }, + { + inputs: [], + name: 'anchorStateRegistry', + outputs: [ + { + internalType: 'contract IAnchorStateRegistry', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, { inputs: [], name: 'version', @@ -1755,6 +1768,31 @@ export const portal2Abi = [ name: 'Unproven', type: 'error', }, + { + inputs: [], + name: 'OptimismPortal_AlreadyFinalized', + type: 'error', + }, + { + inputs: [], + name: 'OptimismPortal_Unproven', + type: 'error', + }, + { + inputs: [], + name: 'OptimismPortal_InvalidProofTimestamp', + type: 'error', + }, + { + inputs: [], + name: 'OptimismPortal_ProofNotOldEnough', + type: 'error', + }, + { + inputs: [], + name: 'OptimismPortal_InvalidRootClaim', + type: 'error', + }, ] as const export const portalAbi = [ @@ -2074,3 +2112,27 @@ export const portalAbi = [ }, { stateMutability: 'payable', type: 'receive' }, ] as const + +export const anchorStateRegistryAbi = [ + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: '_game', internalType: 'address', type: 'address' }], + name: 'isGameProper', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: '_game', internalType: 'address', type: 'address' }], + name: 'isGameRespected', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + }, + { + stateMutability: 'view', + type: 'function', + inputs: [{ name: '_game', internalType: 'address', type: 'address' }], + name: 'isGameFinalized', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + }, +] diff --git a/src/op-stack/actions/getWithdrawalStatus.test.ts b/src/op-stack/actions/getWithdrawalStatus.test.ts index 567cb8d93a..554041a9a3 100644 --- a/src/op-stack/actions/getWithdrawalStatus.test.ts +++ b/src/op-stack/actions/getWithdrawalStatus.test.ts @@ -28,19 +28,62 @@ test('waiting-to-prove', async () => { }) expect(status).toBe('waiting-to-prove') }) +test('ready-to-prove (U16)', async () => { + await reset(client, { + blockNumber: 22991516n, + jsonRpcUrl: anvilMainnet.forkUrl, + }) + await reset(optimismClient, { + blockNumber: 138895794n, + jsonRpcUrl: anvilOptimism.forkUrl, + }) + + const receipt = await getTransactionReceipt(optimismClient, { + hash: '0x94ad4281439a63fa2a4f172fe40dffaace4b0d4ba2cf7ef9373509cbce772b84', + }) -test('ready-to-prove', async () => { + const status = await getWithdrawalStatus(client, { + gameLimit: 10, + receipt, + targetChain: optimismClient.chain, + }) + expect(status).toBe('ready-to-prove') +}) + +test('waiting-to-finalize (U16)', async () => { await reset(client, { - blockNumber: 19868020n, + blockNumber: 22991516n, jsonRpcUrl: anvilMainnet.forkUrl, }) await reset(optimismClient, { - blockNumber: 131027872n, + blockNumber: 138895794n, jsonRpcUrl: anvilOptimism.forkUrl, }) const receipt = await getTransactionReceipt(optimismClient, { - hash: '0xb3cd0bf131e97b0339b6abde0aa7636fc87114ec6ff8cec28b5002c851c929a3', + hash: '0xd62d19e87e2d6ff23935dd6891a6605562dd1f15bb554ff3cfd31794e167d9ab', + }) + + const status = await getWithdrawalStatus(client, { + gameLimit: 10, + receipt, + targetChain: optimismClient.chain, + }) + expect(status).toBe('waiting-to-finalize') +}) + +test('waiting-to-prove (U16)', async () => { + await reset(client, { + blockNumber: 22991714n, + jsonRpcUrl: anvilMainnet.forkUrl, + }) + await reset(optimismClient, { + blockNumber: 138896489n, + jsonRpcUrl: anvilOptimism.forkUrl, + }) + + const receipt = await getTransactionReceipt(optimismClient, { + hash: '0x084536c07c302b4cc3462049d26de42638b4fd140cfc0b19c128be7249567102', }) const status = await getWithdrawalStatus(client, { diff --git a/src/op-stack/actions/getWithdrawalStatus.ts b/src/op-stack/actions/getWithdrawalStatus.ts index 0af5348caa..50eb88d64d 100644 --- a/src/op-stack/actions/getWithdrawalStatus.ts +++ b/src/op-stack/actions/getWithdrawalStatus.ts @@ -16,7 +16,7 @@ import type { import type { Hash } from '../../types/misc.js' import type { TransactionReceipt } from '../../types/transaction.js' import type { OneOf } from '../../types/utils.js' -import { portal2Abi, portalAbi } from '../abis.js' +import { anchorStateRegistryAbi, portal2Abi, portalAbi } from '../abis.js' import { ReceiptContainsNoWithdrawalsError, type ReceiptContainsNoWithdrawalsErrorType, @@ -249,35 +249,49 @@ export async function getWithdrawalStatus< args: [withdrawal.withdrawalHash, numProofSubmitters - 1n], }).catch(() => withdrawal.sender) - const [disputeGameResult, checkWithdrawalResult, finalizedResult] = - await Promise.allSettled([ - getGame(client, { - ...parameters, - l2BlockNumber, - limit: gameLimit, - } as GetGameParameters), - readContract(client, { - abi: portal2Abi, - address: portalAddress, - functionName: 'checkWithdrawal', - args: [withdrawal.withdrawalHash, proofSubmitter], - }), - readContract(client, { - abi: portal2Abi, - address: portalAddress, - functionName: 'finalizedWithdrawals', - args: [withdrawal.withdrawalHash], - }), - ]) + const [ + disputeGameResult, + provenWithdrawalsResult, + checkWithdrawalResult, + finalizedResult, + ] = await Promise.allSettled([ + getGame(client, { + ...parameters, + l2BlockNumber, + limit: gameLimit, + } as GetGameParameters), + readContract(client, { + abi: portal2Abi, + address: portalAddress, + functionName: 'provenWithdrawals', + args: [withdrawal.withdrawalHash, proofSubmitter], + }), + readContract(client, { + abi: portal2Abi, + address: portalAddress, + functionName: 'checkWithdrawal', + args: [withdrawal.withdrawalHash, proofSubmitter], + }), + readContract(client, { + abi: portal2Abi, + address: portalAddress, + functionName: 'finalizedWithdrawals', + args: [withdrawal.withdrawalHash], + }), + ]) if (finalizedResult.status === 'fulfilled' && finalizedResult.value) return 'finalized' + if (provenWithdrawalsResult.status === 'rejected') + throw provenWithdrawalsResult.reason + if (disputeGameResult.status === 'rejected') { const error = disputeGameResult.reason as GetGameErrorType if (error.name === 'GameNotFoundError') return 'waiting-to-prove' throw disputeGameResult.reason } + if (checkWithdrawalResult.status === 'rejected') { const error = checkWithdrawalResult.reason as ReadContractErrorType if (error.cause instanceof ContractFunctionRevertedError) { @@ -292,6 +306,9 @@ export async function getWithdrawalStatus< 'InvalidGameType', 'LegacyGame', 'Unproven', + // After U16 + 'OptimismPortal_Unproven', + 'OptimismPortal_InvalidProofTimestamp', ], 'waiting-to-finalize': [ 'OptimismPortal: proven withdrawal has not matured yet', @@ -306,6 +323,85 @@ export async function getWithdrawalStatus< error.cause.data?.errorName, error.cause.data?.args?.[0] as string, ] + + // After U16 we get a generic error message (OptimismPortal_InvalidRootClaim) because the + // OptimismPortal will call AnchorStateRegistry.isGameClaimValid which simply returns + // true/false. If we get this generic error, we need to figure out why the function returned + // false and return a proper status accordingly. We can also check these conditions when we + // get ProofNotOldEnough so users can be notified when their pending proof becomes invalid + // before it can be finalized. + if ( + errors.includes('OptimismPortal_InvalidRootClaim') || + errors.includes('OptimismPortal_ProofNotOldEnough') + ) { + // Get the dispute game address from the proven withdrawal. + const disputeGameAddress = provenWithdrawalsResult.value[0] as Address + + // Get the AnchorStateRegistry address from the portal. + const anchorStateRegistry = await readContract(client, { + abi: portal2Abi, + address: portalAddress, + functionName: 'anchorStateRegistry', + }) + + // Check if the game is proper, respected, and finalized. + const [ + isGameProperResult, + isGameRespectedResult, + isGameFinalizedResult, + ] = await Promise.allSettled([ + readContract(client, { + abi: anchorStateRegistryAbi, + address: anchorStateRegistry, + functionName: 'isGameProper', + args: [disputeGameAddress], + }), + readContract(client, { + abi: anchorStateRegistryAbi, + address: anchorStateRegistry, + functionName: 'isGameRespected', + args: [disputeGameAddress], + }), + readContract(client, { + abi: anchorStateRegistryAbi, + address: anchorStateRegistry, + functionName: 'isGameFinalized', + args: [disputeGameAddress], + }), + ]) + + // If any of the calls failed, throw the error. + if (isGameProperResult.status === 'rejected') + throw isGameProperResult.reason + if (isGameRespectedResult.status === 'rejected') + throw isGameRespectedResult.reason + if (isGameFinalizedResult.status === 'rejected') + throw isGameFinalizedResult.reason + + // If the game isn't proper, the user needs to re-prove. + if (!isGameProperResult.value) { + return 'ready-to-prove' + } + + // If the game isn't respected, the user needs to re-prove. + if (!isGameRespectedResult.value) { + return 'ready-to-prove' + } + + // If the game isn't finalized, the user needs to wait to finalize. + if (!isGameFinalizedResult.value) { + return 'waiting-to-finalize' + } + + // If the actual error was ProofNotOldEnough, then at this point the game is probably + // completely fine but the proof hasn't passed the waiting period. Otherwise, the only + // reason we'd be here is if the game resolved in favor of the challenger, which means the + // user needs to re-prove the withdrawal. + if (errors.includes('OptimismPortal_ProofNotOldEnough')) { + return 'waiting-to-finalize' + } + return 'ready-to-prove' + } if (errorCauses['ready-to-prove'].some((cause) => errors.includes(cause))) return 'ready-to-prove' if (