From d5fd316a04f8b9bc7b21f30f64de9e59d1384706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 10:39:41 -0300 Subject: [PATCH 1/2] feat(card): repair enable-bricked kernels inline in the grant preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subset of accounts from the 2025-09-18 root-validator migration wave carry validNonceFrom > currentNonce, so every card session-key enable they ever grant reverts InvalidNonce on backend replay — dead on arrival, invisible to the user until the card silently never funds. A separate cohort of undeployed pre-cutoff accounts mints approvals whose initCode derives the wrong address (AA14). Both states are detectable from pure on-chain reads of the connected wallet (nothing account-specific ships in this repo). The grant now reads validNonceFrom alongside currentNonce and, when repair is needed, sends the fixing root userOp first (invalidateNonce(floor+1), or the migration no-op deploy) — one extra passkey tap during card setup instead of a support ticket. Confirmation is by re-read chain state, never the bundle receipt, mirroring ensureRootValidatorMigrated. --- src/constants/analytics.consts.ts | 4 + .../__tests__/useGrantSessionKey.test.ts | 13 ++ .../__tests__/useGrantSessionKey.test.tsx | 56 +++++++ src/hooks/wallet/useGrantSessionKey.ts | 59 ++++++- .../__tests__/kernelNonceRepair.utils.test.ts | 153 ++++++++++++++++++ src/utils/kernelNonceRepair.utils.ts | 137 ++++++++++++++++ 6 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/kernelNonceRepair.utils.test.ts create mode 100644 src/utils/kernelNonceRepair.utils.ts diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index b3b831657b..476895bf28 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -160,6 +160,10 @@ export const ANALYTICS_EVENTS = { CARD_SESSION_KEY_PROMPTED: 'card_session_key_prompted', CARD_SESSION_KEY_GRANTED: 'card_session_key_granted', CARD_SESSION_KEY_FAILED: 'card_session_key_failed', + // Grant preflight found a repair-needing kernel (nonce floor from the + // 2025-09-18 migration wave, or an undeployed pre-cutoff account) and + // sent the repair userOp before signing. `mode`: invalidate | deploy. + CARD_SESSION_KEY_PREFLIGHT_REPAIR: 'card_session_key_preflight_repair', // Withdraw refused with 409 STALE_CARD_APPROVAL — stored session-key // approval is bound to a deprecated validator; user must re-enable the card. CARD_STALE_APPROVAL_HIT: 'card_stale_approval_hit', diff --git a/src/hooks/wallet/__tests__/useGrantSessionKey.test.ts b/src/hooks/wallet/__tests__/useGrantSessionKey.test.ts index cf7cc59e49..e4130d95ce 100644 --- a/src/hooks/wallet/__tests__/useGrantSessionKey.test.ts +++ b/src/hooks/wallet/__tests__/useGrantSessionKey.test.ts @@ -97,6 +97,19 @@ let mockClientSudoValidatorAddress = V003_VALIDATOR // The patched validator also signs the enable typed data (hotfix #2313 path). const mockPatchedValidator = { address: V003_VALIDATOR, signTypedData: jest.fn(() => Promise.resolve('0xENABLESIG')) } const mockGetPatchedSudoValidator = jest.fn(() => Promise.resolve(mockPatchedValidator)) +const mockHandleSendUserOpEncoded = jest.fn(() => Promise.resolve({ userOpHash: '0xhash', receipt: null })) +jest.mock('@/hooks/useZeroDev', () => ({ + useZeroDev: () => ({ handleSendUserOpEncoded: mockHandleSendUserOpEncoded }), +})) + +// The preflight repair itself is unit-tested in kernelNonceRepair.utils.test.ts; +// here we only assert the hook routes floored accounts through it and binds +// the post-repair nonce. +const mockRepairEnableNonce = jest.fn() +jest.mock('@/utils/kernelNonceRepair.utils', () => ({ + repairEnableNonce: (...args: unknown[]) => mockRepairEnableNonce(...args), +})) + jest.mock('@/context/kernelClient.context', () => ({ useKernelClient: () => ({ getClientForChain: () => ({ diff --git a/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx b/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx index 6390cf0651..3982ec2fa8 100644 --- a/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx +++ b/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx @@ -56,6 +56,16 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ })) jest.mock('@/app/actions/clients', () => ({ peanutPublicClient: { readContract: jest.fn(), getCode: jest.fn() } })) jest.mock('@/services/rain', () => ({ rainApi: { getSessionKeyAddress: jest.fn() } })) +const mockHandleSendUserOpEncoded = jest.fn(() => Promise.resolve({ userOpHash: '0xhash', receipt: null })) +jest.mock('@/hooks/useZeroDev', () => ({ + useZeroDev: () => ({ handleSendUserOpEncoded: mockHandleSendUserOpEncoded }), +})) +// Repair internals are unit-tested in kernelNonceRepair.utils.test.ts — here we +// only assert the hook routes floored accounts through it and binds the result. +const mockRepairEnableNonce = jest.fn() +jest.mock('@/utils/kernelNonceRepair.utils', () => ({ + repairEnableNonce: (...args: unknown[]) => mockRepairEnableNonce(...args), +})) jest.mock('@zerodev/permissions', () => ({ toPermissionValidator: jest.fn(), serializePermissionAccount: jest.fn(), @@ -181,4 +191,50 @@ describe('useGrantSessionKey — enable approval binds to the live currentNonce' expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 1 })) }) + + it('repairs a nonce-floored account (validNonceFrom > currentNonce) before signing and binds the fresh nonce', async () => { + // The 2025-09-18 migration-wave state: an approval signed without repair + // would fail AA23 InvalidNonce on every backend replay, forever. + ;(peanutPublicClient.readContract as jest.Mock).mockImplementation(({ functionName }) => + Promise.resolve(functionName === 'validNonceFrom' ? 3n : 2n) + ) + mockRepairEnableNonce.mockResolvedValue({ validatorNonce: 4 }) + + const { result } = renderHook(() => useGrantSessionKey(), { wrapper }) + let out: Awaited> + await act(async () => { + out = await result.current.serializeGrant() + }) + + expect(mockRepairEnableNonce).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'invalidate', validNonceFrom: 3, accountAddress: ACCOUNT }) + ) + expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 4 })) + expect(out!).toEqual({ ok: true, serialized: 'SERIALIZED_APPROVAL' }) + }) + + it('deploys an undeployed pre-cutoff (migration-wrapper) account before granting — its approval would AA14 otherwise', async () => { + ;(useKernelClient as jest.Mock).mockReturnValue({ + getClientForChain: () => ({ + // getRootValidatorMigrationStatus marks the migration wrapper. + account: { address: ACCOUNT, getRootValidatorMigrationStatus: jest.fn() }, + }), + getPatchedSudoValidator: jest.fn().mockResolvedValue({ signTypedData }), + }) + ;(peanutPublicClient.getCode as jest.Mock).mockResolvedValue(undefined) + ;(peanutPublicClient.readContract as jest.Mock).mockRejectedValue(new Error('returned no data ("0x")')) + mockRepairEnableNonce.mockResolvedValue({ validatorNonce: 1 }) + + const { result } = renderHook(() => useGrantSessionKey(), { wrapper }) + let out: Awaited> + await act(async () => { + out = await result.current.serializeGrant() + }) + + expect(mockRepairEnableNonce).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'deploy', accountAddress: ACCOUNT }) + ) + expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 1 })) + expect(out!).toEqual({ ok: true, serialized: 'SERIALIZED_APPROVAL' }) + }) }) diff --git a/src/hooks/wallet/useGrantSessionKey.ts b/src/hooks/wallet/useGrantSessionKey.ts index ed405322e4..af8563e44e 100644 --- a/src/hooks/wallet/useGrantSessionKey.ts +++ b/src/hooks/wallet/useGrantSessionKey.ts @@ -19,6 +19,9 @@ import { getEntryPoint, KERNEL_V3_1 } from '@zerodev/sdk/constants' import { serializePermissionAccount } from '@zerodev/permissions' import { peanutPublicClient } from '@/app/actions/clients' import { rainApi } from '@/services/rain' +import { useZeroDev } from '@/hooks/useZeroDev' +import { isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' +import { repairEnableNonce, type NoncePublicClient } from '@/utils/kernelNonceRepair.utils' /** Minimal structural view of the bits of the kernel account's plugin manager * this flow touches. The SDK doesn't surface these on its public account type, @@ -97,6 +100,7 @@ export interface GrantSessionKeyResult { export const useGrantSessionKey = (): GrantSessionKeyResult => { const { overview, refetch } = useRainCardOverview() const { getClientForChain, getPatchedSudoValidator } = useKernelClient() + const { handleSendUserOpEncoded } = useZeroDev() const queryClient = useQueryClient() const [isGranting, setIsGranting] = useState(false) const [lastError, setLastError] = useState(null) @@ -188,9 +192,9 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { // is a different, never-funded address. Forcing the address here makes // the grant work for both legacy and post-migration users. const accountAddress = kernelClient.account!.address - // The three on-chain reads only need the (already-known) account address, + // The four on-chain reads only need the (already-known) account address, // so they run alongside the account construction. - const [sessionKernelAccount, bytecode, metadata, nonceRead] = await Promise.all([ + const [sessionKernelAccount, bytecode, metadata, nonceRead, floorRead] = await Promise.all([ createKernelAccount(peanutPublicClient, { address: accountAddress, entryPoint: getEntryPoint('0.7'), @@ -212,6 +216,12 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { (nonce) => ({ read: true as const, nonce: Number(nonce) }), (error: unknown) => ({ read: false as const, error }) ), + peanutPublicClient + .readContract({ address: accountAddress, abi: KernelV3AccountAbi, functionName: 'validNonceFrom' }) + .then( + (floor) => ({ read: true as const, floor: Number(floor) }), + (error: unknown) => ({ read: false as const, error }) + ), ]) // The session-key permission installs on-chain via an "enable" approval the @@ -226,10 +236,51 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { // `currentNonce` to 1 at deployment (the read itself reverts pre-deploy). let validatorNonce: number if (!bytecode) { - validatorNonce = 1 + if (isMigrationWrapperAccount(kernelClient.account)) { + // Undeployed PRE-cutoff account: the serialized approval would bake + // a v0.0.3 initCode that derives a different CREATE2 address than + // this wallet, so every backend replay reverts AA14. Deploy first + // (one extra passkey tap; the wrapper also migrates the root + // validator in the same op), then bind the fresh live nonce. + posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'deploy' }) + validatorNonce = ( + await repairEnableNonce({ + // viem's generic readContract collapses structural + // assignability to the minimal client interface. + publicClient: peanutPublicClient as unknown as NoncePublicClient, + accountAddress, + mode: 'deploy', + sendUserOp: (call) => handleSendUserOpEncoded([call], chainId), + }) + ).validatorNonce + } else { + // Post-cutoff counterfactual: the kernel initializes currentNonce + // to 1 at deployment, so 1 is provably exact. + validatorNonce = 1 + } } else if (!nonceRead.read) { // Deployed but unreadable: fail LOUDLY rather than sign a guess. throw nonceRead.error + } else if (!floorRead.read) { + // Same rule for the floor read — skipping the check could mint an + // approval that is dead on arrival for a floored account. + throw floorRead.error + } else if (floorRead.floor > Math.max(nonceRead.nonce, 1)) { + // validNonceFrom AHEAD of currentNonce — the 2025-09-18 migration-wave + // state. Every enable-mode install lands below the floor and reverts + // InvalidNonce forever, so an approval signed now would be dead on + // arrival. Repair inline (one extra passkey tap: invalidateNonce + // syncs the counter up to the floor), then bind the fresh nonce. + posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'invalidate' }) + validatorNonce = ( + await repairEnableNonce({ + publicClient: peanutPublicClient as unknown as NoncePublicClient, + accountAddress, + mode: 'invalidate', + validNonceFrom: floorRead.floor, + sendUserOp: (call) => handleSendUserOpEncoded([call], chainId), + }) + ).validatorNonce } else { // A deployed-but-uninitialized proxy reports 0; enables validate // against ≥1 post-init, so normalize the way the SDK does. @@ -252,7 +303,7 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { const serialized = await serializePermissionAccount(sessionKernelAccount, undefined, enableSignature) return { ok: true, serialized } - }, [overview, getClientForChain, getPatchedSudoValidator]) + }, [overview, getClientForChain, getPatchedSudoValidator, handleSendUserOpEncoded]) const wrap = useCallback( async ( diff --git a/src/utils/__tests__/kernelNonceRepair.utils.test.ts b/src/utils/__tests__/kernelNonceRepair.utils.test.ts new file mode 100644 index 0000000000..42d67efb3d --- /dev/null +++ b/src/utils/__tests__/kernelNonceRepair.utils.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for the grant-preflight enable-nonce repair. + * + * Regression context: the 2025-09-18 root-validator migration batch left a + * subset of kernel accounts with `validNonceFrom (3) > currentNonce (2)`. + * Kernel v3.1 rejects every enable-mode validation installed below the floor + * with `InvalidNonce()`, so a card session-key approval granted for such an + * account is dead on arrival — the backend sweep fails hourly forever. The + * repair must: pick the correct root userOp per mode, confirm against + * re-read chain state (never a bundle receipt), refuse un-invalidatable + * gaps, and not re-send an invalidation a prior attempt already consumed. + */ + +// jest.mock is hoisted above module consts — define the mini-ABI inside the +// factory and read it back through the mocked module (the global +// moduleNameMapper stubs @zerodev/sdk with an empty ABI otherwise). Spread +// the shared stub first: the mapper resolves '@zerodev/sdk/constants' to the +// same file, so replacing it wholesale would drop getEntryPoint & friends. +jest.mock('@zerodev/sdk', () => ({ + ...jest.requireActual('@/utils/__mocks__/zerodev-sdk'), + KernelV3AccountAbi: [ + { type: 'function', name: 'currentNonce', inputs: [], outputs: [{ type: 'uint32' }], stateMutability: 'view' }, + { + type: 'function', + name: 'validNonceFrom', + inputs: [], + outputs: [{ type: 'uint32' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'invalidateNonce', + inputs: [{ name: 'nonce', type: 'uint32' }], + outputs: [], + stateMutability: 'payable', + }, + ], +})) + +import type { Address } from 'viem' +import { encodeFunctionData } from 'viem' +import { KernelV3AccountAbi } from '@zerodev/sdk' +import { PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' +import { + buildInvalidateNonceCall, + KernelNonceRepairPendingError, + KernelNonceRepairUnrepairableError, + repairEnableNonce, +} from '../kernelNonceRepair.utils' + +const ACCOUNT = '0x70f22a4db066aed9bcd2157a7b19e2e28c10c483' as Address + +/** Mutable fake chain: reads reflect `state`, sendUserOp can mutate it. */ +const makeChain = (initial: { deployed: boolean; cn?: number; vnf?: number }) => { + const state = { ...initial } + const publicClient = { + getCode: jest.fn(async () => (state.deployed ? ('0xdeadbeef' as const) : undefined)), + readContract: jest.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === 'currentNonce') return state.cn + if (functionName === 'validNonceFrom') return state.vnf + throw new Error(`unexpected read: ${functionName}`) + }), + } + const sendUserOp = jest.fn(async () => ({})) + return { state, publicClient, sendUserOp } +} + +const deps = (chain: ReturnType, mode: 'invalidate' | 'deploy', validNonceFrom?: number) => ({ + publicClient: chain.publicClient, + accountAddress: ACCOUNT, + mode, + validNonceFrom, + sendUserOp: chain.sendUserOp, + retries: 2, + intervalMs: 0, +}) + +describe('repairEnableNonce — invalidate mode (floored account)', () => { + it('sends invalidateNonce(floor + 1) to the account itself and returns the synced nonce', async () => { + const chain = makeChain({ deployed: true, cn: 2, vnf: 3 }) + chain.sendUserOp.mockImplementation(async () => { + chain.state.cn = 4 + chain.state.vnf = 4 + return {} + }) + + const { validatorNonce } = await repairEnableNonce(deps(chain, 'invalidate', 3)) + + expect(validatorNonce).toBe(4) + expect(chain.sendUserOp).toHaveBeenCalledWith({ + to: ACCOUNT, + value: 0n, + data: encodeFunctionData({ abi: KernelV3AccountAbi, functionName: 'invalidateNonce', args: [4] }), + }) + }) + + it('skips the send entirely when a fresh read shows the floor already cleared (retry safety)', async () => { + const chain = makeChain({ deployed: true, cn: 4, vnf: 4 }) + const { validatorNonce } = await repairEnableNonce(deps(chain, 'invalidate', 3)) + expect(validatorNonce).toBe(4) + expect(chain.sendUserOp).not.toHaveBeenCalled() + }) + + it('refuses a floor beyond the kernel invalidation cap instead of sending a doomed op', async () => { + const chain = makeChain({ deployed: true, cn: 2, vnf: 20 }) + await expect(repairEnableNonce(deps(chain, 'invalidate', 20))).rejects.toBeInstanceOf( + KernelNonceRepairUnrepairableError + ) + expect(chain.sendUserOp).not.toHaveBeenCalled() + }) + + it('throws the retryable pending error when state never confirms (reverted op in a successful bundle)', async () => { + const chain = makeChain({ deployed: true, cn: 2, vnf: 3 }) + await expect(repairEnableNonce(deps(chain, 'invalidate', 3))).rejects.toBeInstanceOf( + KernelNonceRepairPendingError + ) + expect(chain.sendUserOp).toHaveBeenCalledTimes(1) + }) +}) + +describe('repairEnableNonce — deploy mode (undeployed pre-cutoff account)', () => { + it('sends the migration no-op (zero-value USDC self-transfer) and returns the fresh nonce', async () => { + const chain = makeChain({ deployed: false }) + chain.sendUserOp.mockImplementation(async () => { + chain.state.deployed = true + chain.state.cn = 1 + chain.state.vnf = 0 + return {} + }) + + const { validatorNonce } = await repairEnableNonce(deps(chain, 'deploy')) + + expect(validatorNonce).toBe(1) + const [[call]] = chain.sendUserOp.mock.calls as unknown as [[{ to: string; value: bigint }]] + expect(call.to).toBe(PEANUT_WALLET_TOKEN) + expect(call.value).toBe(0n) + }) + + it('throws the retryable pending error when the deploy never appears on-chain', async () => { + const chain = makeChain({ deployed: false }) + await expect(repairEnableNonce(deps(chain, 'deploy'))).rejects.toBeInstanceOf(KernelNonceRepairPendingError) + }) +}) + +describe('buildInvalidateNonceCall', () => { + it('targets the account itself with the floor + 1 argument', () => { + const call = buildInvalidateNonceCall(ACCOUNT, 7) + expect(call.to).toBe(ACCOUNT) + expect(call.data).toBe( + encodeFunctionData({ abi: KernelV3AccountAbi, functionName: 'invalidateNonce', args: [8] }) + ) + }) +}) diff --git a/src/utils/kernelNonceRepair.utils.ts b/src/utils/kernelNonceRepair.utils.ts new file mode 100644 index 0000000000..e203a465a3 --- /dev/null +++ b/src/utils/kernelNonceRepair.utils.ts @@ -0,0 +1,137 @@ +import type { Address, Hex } from 'viem' +import { encodeFunctionData } from 'viem' +import { KernelV3AccountAbi } from '@zerodev/sdk' +import { buildMigrationNoopCall } from './kernelMigration.utils' + +/** + * The 2025-09-18 root-validator migration batch left a subset of pre-cutoff + * kernel accounts with `validNonceFrom` AHEAD of `currentNonce` (a revocation + * floor above the counter — unreachable through Kernel v3.1's own + * `invalidateNonce`, which syncs the counter up). Kernel.sol rejects every + * NON-root validation installed below the floor with `InvalidNonce()` + * (0x756688fe), so any enable-mode approval (the card auto-balance session + * key) minted for such an account fails on every backend replay, forever — + * however correctly it was signed. Root-passkey ops are exempt, so the + * account looks healthy until a card grant. + * + * Repair is a single root userOp the user confirms with one passkey tap: + * - floored (deployed, vnf > cn): `invalidateNonce(validNonceFrom + 1)` on + * the account itself — the kernel syncs `currentNonce` up to the new floor. + * - undeployed pre-cutoff (migration-wrapper) account: any root userOp — the + * wrapper prepends the root migration and the deploy uses the account's + * true initCode. Without this, the grant's serialized approval bakes a + * v0.0.3 initCode that CREATE2-derives a different address, and the + * backend replay reverts `AA14 initCode must return sender`. + * + * Detection is purely on-chain state of the connected wallet — nothing + * account-specific ships in this repo. + * + * Confirmation is by re-reading chain state, never the bundle receipt: a + * reverted userOp still yields a successful bundle (same rationale as + * `ensureRootValidatorMigrated`). + */ + +// Kernel v3.1 rejects invalidateNonce more than MAX_NONCE_INCREMENT_SIZE (10) +// above currentNonce AND at-or-below validNonceFrom — a floor further than 10 +// ahead of the counter has no valid invalidation target at all. +const MAX_NONCE_INCREMENT_SIZE = 10 + +/** Transient: the repair op hasn't been observed on-chain yet — retrying is correct. */ +export class KernelNonceRepairPendingError extends Error { + constructor() { + super('Wallet repair did not confirm in time — please retry in a moment') + this.name = 'KernelNonceRepairPendingError' + } +} + +/** Deterministic: no single invalidateNonce target exists — needs manual intervention. */ +export class KernelNonceRepairUnrepairableError extends Error { + constructor() { + super('This wallet needs a manual repair — please contact support') + this.name = 'KernelNonceRepairUnrepairableError' + } +} + +interface NonceState { + deployed: boolean + currentNonce: number + validNonceFrom: number +} + +export interface NoncePublicClient { + getCode(args: { address: Address }): Promise + readContract(args: { address: Address; abi: unknown; functionName: string }): Promise +} + +export interface RepairEnableNonceDeps { + publicClient: NoncePublicClient + accountAddress: Address + /** 'invalidate' = floored deployed account; 'deploy' = undeployed wrapper account. */ + mode: 'invalidate' | 'deploy' + /** Required for 'invalidate': the floor observed by the caller's read. */ + validNonceFrom?: number + /** Sends one root userOp through the user's kernel client (one passkey tap). */ + sendUserOp: (call: { to: Hex; value: bigint; data: Hex }) => Promise + /** On-chain confirmation attempts / spacing (overridable for tests). */ + retries?: number + intervalMs?: number +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function readNonceState(publicClient: NoncePublicClient, address: Address): Promise { + const code = await publicClient.getCode({ address }) + if (!code || code === '0x') return { deployed: false, currentNonce: 0, validNonceFrom: 0 } + const [currentNonce, validNonceFrom] = await Promise.all([ + publicClient.readContract({ address, abi: KernelV3AccountAbi, functionName: 'currentNonce' }), + publicClient.readContract({ address, abi: KernelV3AccountAbi, functionName: 'validNonceFrom' }), + ]) + return { deployed: true, currentNonce: Number(currentNonce), validNonceFrom: Number(validNonceFrom) } +} + +export const buildInvalidateNonceCall = ( + accountAddress: Address, + validNonceFrom: number +): { to: Hex; value: bigint; data: Hex } => ({ + to: accountAddress, + value: 0n, + data: encodeFunctionData({ + abi: KernelV3AccountAbi, + functionName: 'invalidateNonce', + args: [validNonceFrom + 1], + }), +}) + +/** + * Sends the repair userOp and confirms it against re-read on-chain state. + * Returns the enable nonce the caller must bind the grant to. + */ +export async function repairEnableNonce(deps: RepairEnableNonceDeps): Promise<{ validatorNonce: number }> { + const retries = deps.retries ?? 8 + const intervalMs = deps.intervalMs ?? 1500 + + if (deps.mode === 'invalidate') { + // Re-read live state first: a retry after a confirm timeout must not + // re-send an invalidateNonce the first op already consumed. + const fresh = await readNonceState(deps.publicClient, deps.accountAddress) + if (fresh.deployed && fresh.validNonceFrom <= fresh.currentNonce) { + return { validatorNonce: Math.max(fresh.currentNonce, 1) } + } + const floor = fresh.deployed ? fresh.validNonceFrom : (deps.validNonceFrom ?? 0) + if (floor + 1 > fresh.currentNonce + MAX_NONCE_INCREMENT_SIZE) { + throw new KernelNonceRepairUnrepairableError() + } + await deps.sendUserOp(buildInvalidateNonceCall(deps.accountAddress, floor)) + } else { + await deps.sendUserOp(buildMigrationNoopCall(deps.accountAddress)) + } + + for (let attempt = 0; attempt < retries; attempt++) { + const state = await readNonceState(deps.publicClient, deps.accountAddress) + if (state.deployed && state.validNonceFrom <= state.currentNonce) { + return { validatorNonce: Math.max(state.currentNonce, 1) } + } + if (attempt < retries - 1) await delay(intervalMs) + } + throw new KernelNonceRepairPendingError() +} From 3f5b96b9825395d7ecdc452f6fa27953fc30d79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 11:02:06 -0300 Subject: [PATCH 2/2] review: harden the grant-preflight repair per /code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy path now goes through ensureRootValidatorMigrated: the hand-rolled confirm only checked 'deployed', which a reverted migration inside a successful bundle satisfies while leaving the account on v0.0.2 — the approval signed next would be silently dead (the exact class this PR repairs); the gate verifies the swap against on-chain ground truth and rebuilds the client - a flaky validNonceFrom read no longer aborts healthy grants — proceed on pre-floor-check behavior and flag it (floored stragglers are still caught by the sweep + /fix-card-signature) - repairEnableNonce: confirm-poll survives flaky reads (tap and gas are already spent); a lagging 'undeployed' fresh read is retryable, never 'contact support'; cap check runs against fresh state only - preflight-repair analytics captured after success, not before the tap --- src/constants/analytics.consts.ts | 3 +- .../__tests__/useGrantSessionKey.test.tsx | 30 +++++-- src/hooks/wallet/useGrantSessionKey.ts | 53 +++++++----- .../__tests__/kernelNonceRepair.utils.test.ts | 71 ++++++++-------- src/utils/kernelNonceRepair.utils.ts | 80 +++++++++---------- 5 files changed, 130 insertions(+), 107 deletions(-) diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 476895bf28..89e5eb6d2c 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -162,7 +162,8 @@ export const ANALYTICS_EVENTS = { CARD_SESSION_KEY_FAILED: 'card_session_key_failed', // Grant preflight found a repair-needing kernel (nonce floor from the // 2025-09-18 migration wave, or an undeployed pre-cutoff account) and - // sent the repair userOp before signing. `mode`: invalidate | deploy. + // completed the repair before signing (captured on success), or fell back + // on a flaky floor read. `mode`: invalidate | deploy | floor-read-failed. CARD_SESSION_KEY_PREFLIGHT_REPAIR: 'card_session_key_preflight_repair', // Withdraw refused with 409 STALE_CARD_APPROVAL — stored session-key // approval is bound to a deprecated validator; user must re-enable the card. diff --git a/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx b/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx index 3982ec2fa8..9b575671f6 100644 --- a/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx +++ b/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx @@ -38,6 +38,7 @@ jest.mock('@/constants/analytics.consts', () => ({ CARD_SESSION_KEY_PROMPTED: 'card_session_key_prompted', CARD_SESSION_KEY_GRANTED: 'card_session_key_granted', CARD_SESSION_KEY_FAILED: 'card_session_key_failed', + CARD_SESSION_KEY_PREFLIGHT_REPAIR: 'card_session_key_preflight_repair', }, })) jest.mock('@/constants/zerodev.consts', () => ({ @@ -66,6 +67,13 @@ const mockRepairEnableNonce = jest.fn() jest.mock('@/utils/kernelNonceRepair.utils', () => ({ repairEnableNonce: (...args: unknown[]) => mockRepairEnableNonce(...args), })) +// Deploy path goes through the hardened migration gate (ground-truth verified); +// its internals are covered by kernelMigration.utils.test.ts. +const mockEnsureRootValidatorMigrated = jest.fn() +jest.mock('@/utils/kernelMigration.utils', () => ({ + ...jest.requireActual('@/utils/kernelMigration.utils'), + ensureRootValidatorMigrated: (...args: unknown[]) => mockEnsureRootValidatorMigrated(...args), +})) jest.mock('@zerodev/permissions', () => ({ toPermissionValidator: jest.fn(), serializePermissionAccount: jest.fn(), @@ -207,23 +215,32 @@ describe('useGrantSessionKey — enable approval binds to the live currentNonce' }) expect(mockRepairEnableNonce).toHaveBeenCalledWith( - expect.objectContaining({ mode: 'invalidate', validNonceFrom: 3, accountAddress: ACCOUNT }) + expect.objectContaining({ validNonceFrom: 3, accountAddress: ACCOUNT }) ) expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 4 })) expect(out!).toEqual({ ok: true, serialized: 'SERIALIZED_APPROVAL' }) }) - it('deploys an undeployed pre-cutoff (migration-wrapper) account before granting — its approval would AA14 otherwise', async () => { + it('deploys an undeployed pre-cutoff (migration-wrapper) account via the migration gate before granting — its approval would AA14 otherwise', async () => { ;(useKernelClient as jest.Mock).mockReturnValue({ getClientForChain: () => ({ // getRootValidatorMigrationStatus marks the migration wrapper. account: { address: ACCOUNT, getRootValidatorMigrationStatus: jest.fn() }, }), getPatchedSudoValidator: jest.fn().mockResolvedValue({ signTypedData }), + rebuildClientForChain: jest.fn(), }) ;(peanutPublicClient.getCode as jest.Mock).mockResolvedValue(undefined) - ;(peanutPublicClient.readContract as jest.Mock).mockRejectedValue(new Error('returned no data ("0x")')) - mockRepairEnableNonce.mockResolvedValue({ validatorNonce: 1 }) + // Pre-deploy reads revert; after the migration gate runs (deploy lands), + // the fresh currentNonce read returns 1. + let migrated = false + mockEnsureRootValidatorMigrated.mockImplementation(async () => { + migrated = true + return {} + }) + ;(peanutPublicClient.readContract as jest.Mock).mockImplementation(() => + migrated ? Promise.resolve(1n) : Promise.reject(new Error('returned no data ("0x")')) + ) const { result } = renderHook(() => useGrantSessionKey(), { wrapper }) let out: Awaited> @@ -231,9 +248,8 @@ describe('useGrantSessionKey — enable approval binds to the live currentNonce' out = await result.current.serializeGrant() }) - expect(mockRepairEnableNonce).toHaveBeenCalledWith( - expect.objectContaining({ mode: 'deploy', accountAddress: ACCOUNT }) - ) + expect(mockEnsureRootValidatorMigrated).toHaveBeenCalled() + expect(mockRepairEnableNonce).not.toHaveBeenCalled() expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 1 })) expect(out!).toEqual({ ok: true, serialized: 'SERIALIZED_APPROVAL' }) }) diff --git a/src/hooks/wallet/useGrantSessionKey.ts b/src/hooks/wallet/useGrantSessionKey.ts index af8563e44e..5afbe8ff61 100644 --- a/src/hooks/wallet/useGrantSessionKey.ts +++ b/src/hooks/wallet/useGrantSessionKey.ts @@ -20,7 +20,7 @@ import { serializePermissionAccount } from '@zerodev/permissions' import { peanutPublicClient } from '@/app/actions/clients' import { rainApi } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' -import { isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' +import { ensureRootValidatorMigrated, isMigrationWrapperAccount } from '@/utils/kernelMigration.utils' import { repairEnableNonce, type NoncePublicClient } from '@/utils/kernelNonceRepair.utils' /** Minimal structural view of the bits of the kernel account's plugin manager @@ -99,7 +99,7 @@ export interface GrantSessionKeyResult { export const useGrantSessionKey = (): GrantSessionKeyResult => { const { overview, refetch } = useRainCardOverview() - const { getClientForChain, getPatchedSudoValidator } = useKernelClient() + const { getClientForChain, getPatchedSudoValidator, rebuildClientForChain } = useKernelClient() const { handleSendUserOpEncoded } = useZeroDev() const queryClient = useQueryClient() const [isGranting, setIsGranting] = useState(false) @@ -240,19 +240,24 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { // Undeployed PRE-cutoff account: the serialized approval would bake // a v0.0.3 initCode that derives a different CREATE2 address than // this wallet, so every backend replay reverts AA14. Deploy first - // (one extra passkey tap; the wrapper also migrates the root - // validator in the same op), then bind the fresh live nonce. + // via the hardened migration gate — it verifies the root-validator + // swap against ON-CHAIN ground truth (a reverted migration inside a + // successful bundle would otherwise deploy the account on v0.0.2 + // and the approval signed below would be silently dead) and hands + // back a rebuilt client. One extra passkey tap. + await ensureRootValidatorMigrated({ + client: kernelClient, + sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainId), + rebuildClient: () => rebuildClientForChain(chainId), + }) + // Freshly deployed: read the live nonce; fail loud if unreadable. + const freshNonce = await peanutPublicClient.readContract({ + address: accountAddress, + abi: KernelV3AccountAbi, + functionName: 'currentNonce', + }) + validatorNonce = Math.max(Number(freshNonce), 1) posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'deploy' }) - validatorNonce = ( - await repairEnableNonce({ - // viem's generic readContract collapses structural - // assignability to the minimal client interface. - publicClient: peanutPublicClient as unknown as NoncePublicClient, - accountAddress, - mode: 'deploy', - sendUserOp: (call) => handleSendUserOpEncoded([call], chainId), - }) - ).validatorNonce } else { // Post-cutoff counterfactual: the kernel initializes currentNonce // to 1 at deployment, so 1 is provably exact. @@ -261,27 +266,31 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { } else if (!nonceRead.read) { // Deployed but unreadable: fail LOUDLY rather than sign a guess. throw nonceRead.error - } else if (!floorRead.read) { - // Same rule for the floor read — skipping the check could mint an - // approval that is dead on arrival for a floored account. - throw floorRead.error - } else if (floorRead.floor > Math.max(nonceRead.nonce, 1)) { + } else if (floorRead.read && floorRead.floor > Math.max(nonceRead.nonce, 1)) { // validNonceFrom AHEAD of currentNonce — the 2025-09-18 migration-wave // state. Every enable-mode install lands below the floor and reverts // InvalidNonce forever, so an approval signed now would be dead on // arrival. Repair inline (one extra passkey tap: invalidateNonce // syncs the counter up to the floor), then bind the fresh nonce. - posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'invalidate' }) validatorNonce = ( await repairEnableNonce({ + // viem's generic readContract collapses structural + // assignability to the minimal client interface. publicClient: peanutPublicClient as unknown as NoncePublicClient, accountAddress, - mode: 'invalidate', validNonceFrom: floorRead.floor, sendUserOp: (call) => handleSendUserOpEncoded([call], chainId), }) ).validatorNonce + posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'invalidate' }) } else { + if (!floorRead.read) { + // Don't regress every healthy grant on one flaky read: proceed on + // the old (pre-floor-check) behavior and flag it — a floored + // account slipping through here still gets caught by the sweep's + // permanent-failure path and /fix-card-signature. + posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'floor-read-failed' }) + } // A deployed-but-uninitialized proxy reports 0; enables validate // against ≥1 post-init, so normalize the way the SDK does. validatorNonce = nonceRead.nonce === 0 ? 1 : nonceRead.nonce @@ -303,7 +312,7 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { const serialized = await serializePermissionAccount(sessionKernelAccount, undefined, enableSignature) return { ok: true, serialized } - }, [overview, getClientForChain, getPatchedSudoValidator, handleSendUserOpEncoded]) + }, [overview, getClientForChain, getPatchedSudoValidator, handleSendUserOpEncoded, rebuildClientForChain]) const wrap = useCallback( async ( diff --git a/src/utils/__tests__/kernelNonceRepair.utils.test.ts b/src/utils/__tests__/kernelNonceRepair.utils.test.ts index 42d67efb3d..9eba90c320 100644 --- a/src/utils/__tests__/kernelNonceRepair.utils.test.ts +++ b/src/utils/__tests__/kernelNonceRepair.utils.test.ts @@ -1,14 +1,14 @@ /** * Tests for the grant-preflight enable-nonce repair. * - * Regression context: the 2025-09-18 root-validator migration batch left a + * Regression context: the 2025-09-18 emergency validator migration left a * subset of kernel accounts with `validNonceFrom (3) > currentNonce (2)`. * Kernel v3.1 rejects every enable-mode validation installed below the floor * with `InvalidNonce()`, so a card session-key approval granted for such an * account is dead on arrival — the backend sweep fails hourly forever. The - * repair must: pick the correct root userOp per mode, confirm against - * re-read chain state (never a bundle receipt), refuse un-invalidatable - * gaps, and not re-send an invalidation a prior attempt already consumed. + * repair must confirm against re-read chain state (never a bundle receipt), + * refuse un-invalidatable gaps, tolerate flaky reads mid-poll, and never + * re-send an invalidation a prior attempt already consumed. */ // jest.mock is hoisted above module consts — define the mini-ABI inside the @@ -40,7 +40,6 @@ jest.mock('@zerodev/sdk', () => ({ import type { Address } from 'viem' import { encodeFunctionData } from 'viem' import { KernelV3AccountAbi } from '@zerodev/sdk' -import { PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { buildInvalidateNonceCall, KernelNonceRepairPendingError, @@ -52,9 +51,15 @@ const ACCOUNT = '0x70f22a4db066aed9bcd2157a7b19e2e28c10c483' as Address /** Mutable fake chain: reads reflect `state`, sendUserOp can mutate it. */ const makeChain = (initial: { deployed: boolean; cn?: number; vnf?: number }) => { - const state = { ...initial } + const state = { ...initial, failNextRead: false } const publicClient = { - getCode: jest.fn(async () => (state.deployed ? ('0xdeadbeef' as const) : undefined)), + getCode: jest.fn(async () => { + if (state.failNextRead) { + state.failNextRead = false + throw new Error('flaky RPC') + } + return state.deployed ? ('0xdeadbeef' as const) : undefined + }), readContract: jest.fn(async ({ functionName }: { functionName: string }) => { if (functionName === 'currentNonce') return state.cn if (functionName === 'validNonceFrom') return state.vnf @@ -65,17 +70,16 @@ const makeChain = (initial: { deployed: boolean; cn?: number; vnf?: number }) => return { state, publicClient, sendUserOp } } -const deps = (chain: ReturnType, mode: 'invalidate' | 'deploy', validNonceFrom?: number) => ({ +const deps = (chain: ReturnType, validNonceFrom: number) => ({ publicClient: chain.publicClient, accountAddress: ACCOUNT, - mode, validNonceFrom, sendUserOp: chain.sendUserOp, retries: 2, intervalMs: 0, }) -describe('repairEnableNonce — invalidate mode (floored account)', () => { +describe('repairEnableNonce', () => { it('sends invalidateNonce(floor + 1) to the account itself and returns the synced nonce', async () => { const chain = makeChain({ deployed: true, cn: 2, vnf: 3 }) chain.sendUserOp.mockImplementation(async () => { @@ -84,7 +88,7 @@ describe('repairEnableNonce — invalidate mode (floored account)', () => { return {} }) - const { validatorNonce } = await repairEnableNonce(deps(chain, 'invalidate', 3)) + const { validatorNonce } = await repairEnableNonce(deps(chain, 3)) expect(validatorNonce).toBe(4) expect(chain.sendUserOp).toHaveBeenCalledWith({ @@ -96,49 +100,42 @@ describe('repairEnableNonce — invalidate mode (floored account)', () => { it('skips the send entirely when a fresh read shows the floor already cleared (retry safety)', async () => { const chain = makeChain({ deployed: true, cn: 4, vnf: 4 }) - const { validatorNonce } = await repairEnableNonce(deps(chain, 'invalidate', 3)) + const { validatorNonce } = await repairEnableNonce(deps(chain, 3)) expect(validatorNonce).toBe(4) expect(chain.sendUserOp).not.toHaveBeenCalled() }) it('refuses a floor beyond the kernel invalidation cap instead of sending a doomed op', async () => { const chain = makeChain({ deployed: true, cn: 2, vnf: 20 }) - await expect(repairEnableNonce(deps(chain, 'invalidate', 20))).rejects.toBeInstanceOf( - KernelNonceRepairUnrepairableError - ) + await expect(repairEnableNonce(deps(chain, 20))).rejects.toBeInstanceOf(KernelNonceRepairUnrepairableError) expect(chain.sendUserOp).not.toHaveBeenCalled() }) - it('throws the retryable pending error when state never confirms (reverted op in a successful bundle)', async () => { - const chain = makeChain({ deployed: true, cn: 2, vnf: 3 }) - await expect(repairEnableNonce(deps(chain, 'invalidate', 3))).rejects.toBeInstanceOf( - KernelNonceRepairPendingError - ) - expect(chain.sendUserOp).toHaveBeenCalledTimes(1) + it('treats a lagging "undeployed" fresh read as retryable, never unrepairable', async () => { + // The caller only enters after observing a DEPLOYED floored account — + // an undeployed read here is a lagging node, not ground truth. + const chain = makeChain({ deployed: false }) + await expect(repairEnableNonce(deps(chain, 20))).rejects.toBeInstanceOf(KernelNonceRepairPendingError) + expect(chain.sendUserOp).not.toHaveBeenCalled() }) -}) -describe('repairEnableNonce — deploy mode (undeployed pre-cutoff account)', () => { - it('sends the migration no-op (zero-value USDC self-transfer) and returns the fresh nonce', async () => { - const chain = makeChain({ deployed: false }) + it('keeps polling through a flaky read after the op was sent (tap and gas already spent)', async () => { + const chain = makeChain({ deployed: true, cn: 2, vnf: 3 }) chain.sendUserOp.mockImplementation(async () => { - chain.state.deployed = true - chain.state.cn = 1 - chain.state.vnf = 0 + chain.state.cn = 4 + chain.state.vnf = 4 + chain.state.failNextRead = true // first poll read flakes; the next must still run return {} }) - const { validatorNonce } = await repairEnableNonce(deps(chain, 'deploy')) - - expect(validatorNonce).toBe(1) - const [[call]] = chain.sendUserOp.mock.calls as unknown as [[{ to: string; value: bigint }]] - expect(call.to).toBe(PEANUT_WALLET_TOKEN) - expect(call.value).toBe(0n) + const { validatorNonce } = await repairEnableNonce(deps(chain, 3)) + expect(validatorNonce).toBe(4) }) - it('throws the retryable pending error when the deploy never appears on-chain', async () => { - const chain = makeChain({ deployed: false }) - await expect(repairEnableNonce(deps(chain, 'deploy'))).rejects.toBeInstanceOf(KernelNonceRepairPendingError) + it('throws the retryable pending error when state never confirms (reverted op in a successful bundle)', async () => { + const chain = makeChain({ deployed: true, cn: 2, vnf: 3 }) + await expect(repairEnableNonce(deps(chain, 3))).rejects.toBeInstanceOf(KernelNonceRepairPendingError) + expect(chain.sendUserOp).toHaveBeenCalledTimes(1) }) }) diff --git a/src/utils/kernelNonceRepair.utils.ts b/src/utils/kernelNonceRepair.utils.ts index e203a465a3..8df5358fb3 100644 --- a/src/utils/kernelNonceRepair.utils.ts +++ b/src/utils/kernelNonceRepair.utils.ts @@ -1,27 +1,24 @@ import type { Address, Hex } from 'viem' import { encodeFunctionData } from 'viem' import { KernelV3AccountAbi } from '@zerodev/sdk' -import { buildMigrationNoopCall } from './kernelMigration.utils' /** - * The 2025-09-18 root-validator migration batch left a subset of pre-cutoff - * kernel accounts with `validNonceFrom` AHEAD of `currentNonce` (a revocation - * floor above the counter — unreachable through Kernel v3.1's own - * `invalidateNonce`, which syncs the counter up). Kernel.sol rejects every - * NON-root validation installed below the floor with `InvalidNonce()` - * (0x756688fe), so any enable-mode approval (the card auto-balance session - * key) minted for such an account fails on every backend replay, forever — - * however correctly it was signed. Root-passkey ops are exempt, so the - * account looks healthy until a card grant. + * The 2025-09-18 emergency validator migration left a subset of kernel + * accounts with `validNonceFrom` AHEAD of `currentNonce` (a revocation floor + * above the counter — unreachable through Kernel v3.1's own `invalidateNonce`, + * which syncs the counter up). Kernel.sol rejects every NON-root validation + * installed below the floor with `InvalidNonce()` (0x756688fe), so any + * enable-mode approval (the card auto-balance session key) minted for such an + * account fails on every backend replay, forever — however correctly it was + * signed. Root-passkey ops are exempt, so the account looks healthy until a + * card grant. * * Repair is a single root userOp the user confirms with one passkey tap: - * - floored (deployed, vnf > cn): `invalidateNonce(validNonceFrom + 1)` on - * the account itself — the kernel syncs `currentNonce` up to the new floor. - * - undeployed pre-cutoff (migration-wrapper) account: any root userOp — the - * wrapper prepends the root migration and the deploy uses the account's - * true initCode. Without this, the grant's serialized approval bakes a - * v0.0.3 initCode that CREATE2-derives a different address, and the - * backend replay reverts `AA14 initCode must return sender`. + * `invalidateNonce(validNonceFrom + 1)` on the account itself — the kernel + * syncs `currentNonce` up to the new floor, unbricking enable mode. + * (Undeployed pre-cutoff accounts are a different hazard — AA14 from a + * mismatched initCode — and are handled by `ensureRootValidatorMigrated`, + * which verifies the migration against ground truth; not here.) * * Detection is purely on-chain state of the connected wallet — nothing * account-specific ships in this repo. @@ -66,10 +63,8 @@ export interface NoncePublicClient { export interface RepairEnableNonceDeps { publicClient: NoncePublicClient accountAddress: Address - /** 'invalidate' = floored deployed account; 'deploy' = undeployed wrapper account. */ - mode: 'invalidate' | 'deploy' - /** Required for 'invalidate': the floor observed by the caller's read. */ - validNonceFrom?: number + /** The floor observed by the caller's read — fallback when re-reads flake. */ + validNonceFrom: number /** Sends one root userOp through the user's kernel client (one passkey tap). */ sendUserOp: (call: { to: Hex; value: bigint; data: Hex }) => Promise /** On-chain confirmation attempts / spacing (overridable for tests). */ @@ -103,33 +98,38 @@ export const buildInvalidateNonceCall = ( }) /** - * Sends the repair userOp and confirms it against re-read on-chain state. - * Returns the enable nonce the caller must bind the grant to. + * Sends the invalidateNonce repair userOp for a floored (deployed) account + * and confirms it against re-read on-chain state. Returns the enable nonce + * the caller must bind the grant to. */ export async function repairEnableNonce(deps: RepairEnableNonceDeps): Promise<{ validatorNonce: number }> { const retries = deps.retries ?? 8 const intervalMs = deps.intervalMs ?? 1500 - if (deps.mode === 'invalidate') { - // Re-read live state first: a retry after a confirm timeout must not - // re-send an invalidateNonce the first op already consumed. - const fresh = await readNonceState(deps.publicClient, deps.accountAddress) - if (fresh.deployed && fresh.validNonceFrom <= fresh.currentNonce) { - return { validatorNonce: Math.max(fresh.currentNonce, 1) } - } - const floor = fresh.deployed ? fresh.validNonceFrom : (deps.validNonceFrom ?? 0) - if (floor + 1 > fresh.currentNonce + MAX_NONCE_INCREMENT_SIZE) { - throw new KernelNonceRepairUnrepairableError() - } - await deps.sendUserOp(buildInvalidateNonceCall(deps.accountAddress, floor)) - } else { - await deps.sendUserOp(buildMigrationNoopCall(deps.accountAddress)) + // Re-read live state first: a retry after a confirm timeout must not + // re-send an invalidateNonce the first op already consumed. The caller + // only enters here after observing a DEPLOYED floored account, so an + // "undeployed" fresh read can only be a lagging node — retryable, never + // grounds for the unrepairable verdict. + const fresh = await readNonceState(deps.publicClient, deps.accountAddress) + if (!fresh.deployed) throw new KernelNonceRepairPendingError() + if (fresh.validNonceFrom <= fresh.currentNonce) { + return { validatorNonce: Math.max(fresh.currentNonce, 1) } + } + if (fresh.validNonceFrom + 1 > fresh.currentNonce + MAX_NONCE_INCREMENT_SIZE) { + throw new KernelNonceRepairUnrepairableError() } + await deps.sendUserOp(buildInvalidateNonceCall(deps.accountAddress, fresh.validNonceFrom)) for (let attempt = 0; attempt < retries; attempt++) { - const state = await readNonceState(deps.publicClient, deps.accountAddress) - if (state.deployed && state.validNonceFrom <= state.currentNonce) { - return { validatorNonce: Math.max(state.currentNonce, 1) } + try { + const state = await readNonceState(deps.publicClient, deps.accountAddress) + if (state.deployed && state.validNonceFrom <= state.currentNonce) { + return { validatorNonce: Math.max(state.currentNonce, 1) } + } + } catch { + // A flaky read after the op was already sent must not abort the + // poll — the tap and gas are spent; keep confirming. } if (attempt < retries - 1) await delay(intervalMs) }