diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index b3b831657b..89e5eb6d2c 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -160,6 +160,11 @@ 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 + // 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. 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..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', () => ({ @@ -56,6 +57,23 @@ 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), +})) +// 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(), @@ -181,4 +199,58 @@ 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({ 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 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) + // 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> + await act(async () => { + out = await result.current.serializeGrant() + }) + + 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 ed405322e4..5afbe8ff61 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 { 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 * this flow touches. The SDK doesn't surface these on its public account type, @@ -96,7 +99,8 @@ 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) 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,11 +236,61 @@ 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 + // 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' }) + } 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 && 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. + validatorNonce = ( + await repairEnableNonce({ + // viem's generic readContract collapses structural + // assignability to the minimal client interface. + publicClient: peanutPublicClient as unknown as NoncePublicClient, + accountAddress, + 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 @@ -252,7 +312,7 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { const serialized = await serializePermissionAccount(sessionKernelAccount, undefined, enableSignature) return { ok: true, serialized } - }, [overview, getClientForChain, getPatchedSudoValidator]) + }, [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 new file mode 100644 index 0000000000..9eba90c320 --- /dev/null +++ b/src/utils/__tests__/kernelNonceRepair.utils.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for the grant-preflight enable-nonce repair. + * + * 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 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 +// 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 { + 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, failNextRead: false } + const publicClient = { + 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 + throw new Error(`unexpected read: ${functionName}`) + }), + } + const sendUserOp = jest.fn(async () => ({})) + return { state, publicClient, sendUserOp } +} + +const deps = (chain: ReturnType, validNonceFrom: number) => ({ + publicClient: chain.publicClient, + accountAddress: ACCOUNT, + validNonceFrom, + sendUserOp: chain.sendUserOp, + retries: 2, + intervalMs: 0, +}) + +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 () => { + chain.state.cn = 4 + chain.state.vnf = 4 + return {} + }) + + const { validatorNonce } = await repairEnableNonce(deps(chain, 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, 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, 20))).rejects.toBeInstanceOf(KernelNonceRepairUnrepairableError) + expect(chain.sendUserOp).not.toHaveBeenCalled() + }) + + 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() + }) + + 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.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, 3)) + expect(validatorNonce).toBe(4) + }) + + 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) + }) +}) + +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..8df5358fb3 --- /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' + +/** + * 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: + * `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. + * + * 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 + /** 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). */ + 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 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 + + // 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++) { + 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) + } + throw new KernelNonceRepairPendingError() +}