Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/weak-bags-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"viem": patch
---

**OP Stack:** Fixed `getWithdrawalStatus` for Upgrade 16.
62 changes: 62 additions & 0 deletions src/op-stack/abis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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' }],
},
]
51 changes: 47 additions & 4 deletions src/op-stack/actions/getWithdrawalStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
138 changes: 117 additions & 21 deletions src/op-stack/actions/getWithdrawalStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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',
Expand All @@ -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 (
Expand Down
Loading