From 0adb18f3c69c3b6ba262f67cfdee8b543c9e958e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 08:38:10 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(card):=20/fix-card-signature=20?= =?UTF-8?q?=E2=80=94=20guided=20repair=20for=20enable-bricked=20kernels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three accounts carry validNonceFrom > currentNonce (left by the 2025-09-18 root-validator migration wave); Kernel v3.1 rejects every enable-mode card approval below that floor with InvalidNonce, so auto-balance fails hourly forever and no re-grant can help — the account state itself is the poison. A fourth account is undeployed with a v0.0.3-initCode approval (AA14). Hidden support page (not linked anywhere): diagnoses the account on-chain, sends the matching repair userOp (invalidateNonce(floor+1), or the migration no-op deploy), confirms against re-read chain state, then re-grants — the existing session-approve route stores the fresh approval and kicks off a funding run immediately. --- .../(mobile-ui)/fix-card-signature/page.tsx | 145 ++++++++++++ src/constants/routes.ts | 1 + .../__tests__/useCardSignatureRepair.test.ts | 208 ++++++++++++++++++ src/hooks/wallet/useCardSignatureRepair.ts | 151 +++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 src/app/(mobile-ui)/fix-card-signature/page.tsx create mode 100644 src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts create mode 100644 src/hooks/wallet/useCardSignatureRepair.ts diff --git a/src/app/(mobile-ui)/fix-card-signature/page.tsx b/src/app/(mobile-ui)/fix-card-signature/page.tsx new file mode 100644 index 0000000000..d8f82d00a1 --- /dev/null +++ b/src/app/(mobile-ui)/fix-card-signature/page.tsx @@ -0,0 +1,145 @@ +'use client' + +/** + * Hidden support page: /fix-card-signature + * + * Guided repair for accounts whose card auto-funding approval can never + * validate (nonce-bricked or undeployed kernel — see useCardSignatureRepair). + * Not linked from anywhere; support DMs the URL to affected users. Two passkey + * taps: repair the wallet state, then re-grant auto-funding (the backend + * kicks off a funding run the moment the new approval is stored). + */ + +import { useEffect, useState } from 'react' +import { Button } from '@/components/0_Bruddle/Button' +import { Card } from '@/components/0_Bruddle/Card' +import NavHeader from '@/components/Global/NavHeader' +import { findActiveCard } from '@/components/Card/cardState.utils' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useCardSignatureRepair } from '@/hooks/wallet/useCardSignatureRepair' +import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' + +export default function FixCardSignaturePage() { + const { overview } = useRainCardOverview() + const { diagnosis, isDiagnosing, isRepairing, error, diagnose, repair } = useCardSignatureRepair() + const { grant, isGranting, lastError } = useGrantSessionKey() + const [grantDone, setGrantDone] = useState(false) + const [grantErrorMessage, setGrantErrorMessage] = useState(null) + + const card = findActiveCard(overview) + + useEffect(() => { + void diagnose() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const needsRepair = diagnosis !== null && diagnosis.state !== 'healthy' + const busy = isDiagnosing || isRepairing || isGranting + + const handleRepair = async () => { + setGrantErrorMessage(null) + await repair() + } + + const handleGrant = async () => { + setGrantErrorMessage(null) + const result = await grant() + if (result.ok) { + setGrantDone(true) + } else if (result.error.kind !== 'user-cancelled') { + setGrantErrorMessage( + result.error.kind === 'no-card' + ? 'No active card found on this account — please contact support.' + : 'Re-enabling did not complete — please try again or contact support.' + ) + } + } + + return ( +
+ +
+

+ This tool repairs a wallet state issue that stops your card from funding itself automatically. It + takes up to two quick passkey confirmations. +

+ + {isDiagnosing &&

Checking your wallet…

} + + {diagnosis && ( + +
+ 1. Repair wallet state + {needsRepair ? (isRepairing ? '⏳' : '⚠️ needed') : '✅'} +
+ {needsRepair && ( + <> +

+ {diagnosis.state === 'nonce-bricked' + ? 'Your wallet is blocking new card permissions after an earlier security upgrade. One confirmation clears it.' + : 'Your wallet needs a one-time on-chain activation before card permissions can work.'} +

+ + + )} +
+ )} + + {diagnosis?.state === 'healthy' && ( + +
+ 2. Re-enable automatic funding + {grantDone ? '✅' : isGranting ? '⏳' : ''} +
+ {grantDone ? ( +

+ All set! Automatic funding is back on and a funding run has been started — your card + balance should update within a few minutes. +

+ ) : ( + <> +

+ One more confirmation re-enables automatic card funding with a fresh permission. +

+ + {!card && ( +

+ No active card found on this account — please contact support. +

+ )} + + )} +
+ )} + + {(error || grantErrorMessage || (lastError && lastError.kind !== 'user-cancelled' && !grantDone)) && ( +

+ {error ?? grantErrorMessage ?? 'Something went wrong — please try again.'} +

+ )} + + {diagnosis && diagnosis.state !== 'undeployed' && ( +

+ Diagnostics: nonce {diagnosis.currentNonce} / floor {diagnosis.validNonceFrom} +

+ )} +
+
+ ) +} diff --git a/src/constants/routes.ts b/src/constants/routes.ts index cd6814876c..cd5577f9a9 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -45,6 +45,7 @@ export const DEDICATED_ROUTES = [ 'recover-funds', 'card-recovery', 'recover-wallet', + 'fix-card-signature', // Public pages (existing) 'm', // merchant landing pages (/m/[slug]) — added on main; register so the catch-all never treats it as a recipient diff --git a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts new file mode 100644 index 0000000000..39ec5a8207 --- /dev/null +++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts @@ -0,0 +1,208 @@ +/** + * Tests for useCardSignatureRepair — the /fix-card-signature diagnose/repair hook. + * + * Money-path invariant under test: accounts whose kernel has + * `validNonceFrom > currentNonce` can NEVER validate an enable-mode card + * approval (Kernel v3.1 rejects installs below the floor with InvalidNonce), + * and the ONLY unbrick is a root userOp calling + * `invalidateNonce(validNonceFrom + 1)` on the account itself. Undeployed + * accounts instead need a deploy (migration no-op). The hook must pick the + * right repair call for each diagnosis and must confirm the repair against + * re-read on-chain state, never the bundle receipt. + */ + +import { renderHook, act } from '@testing-library/react' +import { encodeFunctionData } from 'viem' + +const USER_ADDRESS = '0x00000000000000000000000000000000000000aa' +const USDC = '0x1111111111111111111111111111111111111111' + +// Defined inside the factory (jest.mock is hoisted above module consts); the +// test body reads it back through the mocked module. +jest.mock('@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', + }, + ], +})) +// eslint-disable-next-line import/order +import { KernelV3AccountAbi as KERNEL_ABI } from '@zerodev/sdk' + +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: USDC, +})) + +const mockGetCode = jest.fn() +const mockReadContract = jest.fn() +jest.mock('@/app/actions/clients', () => ({ + peanutPublicClient: { + getCode: (...args: unknown[]) => mockGetCode(...args), + readContract: (...args: unknown[]) => mockReadContract(...args), + }, +})) + +const mockSendUserOp = jest.fn() +jest.mock('@/hooks/useZeroDev', () => ({ + useZeroDev: () => ({ address: USER_ADDRESS, handleSendUserOpEncoded: mockSendUserOp }), +})) + +const mockRebuildClient = jest.fn() +jest.mock('@/context/kernelClient.context', () => ({ + useKernelClient: () => ({ rebuildClientForChain: mockRebuildClient }), +})) + +import { useCardSignatureRepair } from '../useCardSignatureRepair' + +/** Point the on-chain reads at a fake account state. */ +const chainState = (state: { deployed: boolean; cn?: number; vnf?: number }) => { + mockGetCode.mockResolvedValue(state.deployed ? '0xdeadbeef' : undefined) + mockReadContract.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === 'currentNonce') return Promise.resolve(state.cn) + if (functionName === 'validNonceFrom') return Promise.resolve(state.vnf) + return Promise.reject(new Error(`unexpected read: ${functionName}`)) + }) +} + +beforeEach(() => { + jest.clearAllMocks() + mockSendUserOp.mockResolvedValue({ userOpHash: '0xhash', receipt: null }) + mockRebuildClient.mockResolvedValue({}) +}) + +describe('diagnose', () => { + it('classifies an account with no code as undeployed', async () => { + chainState({ deployed: false }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + expect(await result.current.diagnose()).toEqual({ state: 'undeployed' }) + }) + expect(result.current.diagnosis).toEqual({ state: 'undeployed' }) + }) + + it('classifies validNonceFrom > currentNonce as nonce-bricked', async () => { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + expect(result.current.diagnosis).toEqual({ state: 'nonce-bricked', currentNonce: 2, validNonceFrom: 3 }) + }) + + it('classifies validNonceFrom <= currentNonce as healthy', async () => { + chainState({ deployed: true, cn: 1, vnf: 0 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + expect(result.current.diagnosis).toEqual({ state: 'healthy', currentNonce: 1, validNonceFrom: 0 }) + }) +}) + +describe('repair', () => { + it('nonce-bricked → sends invalidateNonce(validNonceFrom + 1) to the account itself, then rebuilds', async () => { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + + // The repair userOp lands and the floor is cleared before the confirm poll. + mockSendUserOp.mockImplementation(async () => { + chainState({ deployed: true, cn: 4, vnf: 4 }) + return { userOpHash: '0xhash', receipt: null } + }) + + await act(async () => { + expect(await result.current.repair()).toEqual({ state: 'healthy', currentNonce: 4, validNonceFrom: 4 }) + }) + + expect(mockSendUserOp).toHaveBeenCalledWith( + [ + { + to: USER_ADDRESS, + value: 0n, + data: encodeFunctionData({ abi: KERNEL_ABI, functionName: 'invalidateNonce', args: [4] }), + }, + ], + '42161' + ) + expect(mockRebuildClient).toHaveBeenCalledWith('42161') + expect(result.current.diagnosis).toEqual({ state: 'healthy', currentNonce: 4, validNonceFrom: 4 }) + }) + + it('undeployed → sends the migration no-op (deploys the account), then rebuilds', async () => { + chainState({ deployed: false }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + + mockSendUserOp.mockImplementation(async () => { + chainState({ deployed: true, cn: 1, vnf: 0 }) + return { userOpHash: '0xhash', receipt: null } + }) + + await act(async () => { + expect(await result.current.repair()).toEqual({ state: 'healthy', currentNonce: 1, validNonceFrom: 0 }) + }) + + const [[calls, chainId]] = mockSendUserOp.mock.calls + expect(chainId).toBe('42161') + expect(calls).toHaveLength(1) + // The no-op is a zero-value USDC self-transfer — the SDK wrapper adds the migration. + expect(calls[0].to).toBe(USDC) + expect(calls[0].value).toBe(0n) + expect(mockRebuildClient).toHaveBeenCalledWith('42161') + }) + + it('does nothing when already healthy', async () => { + chainState({ deployed: true, cn: 1, vnf: 0 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + await result.current.repair() + }) + expect(mockSendUserOp).not.toHaveBeenCalled() + }) + + it('surfaces a retryable error when the repair never confirms on-chain', async () => { + jest.useFakeTimers() + try { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + + // The userOp "lands" but state never changes (e.g. reverted userOp + // inside a successful bundle) — the confirm poll must not trust the + // receipt and must give up with a retryable error. + let repaired: unknown + await act(async () => { + const promise = result.current.repair() + await jest.runAllTimersAsync() + repaired = await promise + }) + + expect(repaired).toBeNull() + expect(result.current.error).toMatch(/did not confirm/) + expect(mockRebuildClient).not.toHaveBeenCalled() + } finally { + jest.useRealTimers() + } + }) +}) diff --git a/src/hooks/wallet/useCardSignatureRepair.ts b/src/hooks/wallet/useCardSignatureRepair.ts new file mode 100644 index 0000000000..19de764df2 --- /dev/null +++ b/src/hooks/wallet/useCardSignatureRepair.ts @@ -0,0 +1,151 @@ +import { useCallback, useState } from 'react' +import type { Address } from 'viem' +import { encodeFunctionData } from 'viem' +import { KernelV3AccountAbi } from '@zerodev/sdk' +import { peanutPublicClient } from '@/app/actions/clients' +import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts' +import { useKernelClient } from '@/context/kernelClient.context' +import { useZeroDev } from '@/hooks/useZeroDev' +import { buildMigrationNoopCall } from '@/utils/kernelMigration.utils' + +/** + * Diagnose-and-repair for kernel accounts whose card auto-balance approval can + * never validate, no matter how correctly it was granted: + * + * - `nonce-bricked`: the account's `validNonceFrom` is AHEAD of `currentNonce` + * (left behind by the 2025-09-18 root-validator migration wave). Kernel + * v3.1 rejects every enable-mode validation installed below the + * `validNonceFrom` floor with `InvalidNonce()` (0x756688fe), so the backend + * sweep fails hourly forever. Repair: a root-passkey userOp calling + * `invalidateNonce(validNonceFrom + 1)` on the account itself — the kernel + * syncs `currentNonce` up to the floor, unbricking enable mode. + * - `undeployed`: the account is still counterfactual, and (for pre-cutoff + * accounts) stored approvals bake a v0.0.3 initCode that derives a different + * address → every replay reverts AA14. Repair: any root userOp deploys the + * account with its true initCode (the migration no-op — the SDK wrapper + * also swaps the root validator in the same op). + * + * After a successful repair the caller re-grants (useGrantSessionKey), which + * signs against the fresh live nonce and re-stores the approval server-side. + */ + +export type CardSignatureDiagnosis = + | { state: 'undeployed' } + | { state: 'nonce-bricked'; currentNonce: number; validNonceFrom: number } + | { state: 'healthy'; currentNonce: number; validNonceFrom: number } + +interface RepairState { + diagnosis: CardSignatureDiagnosis | null + isDiagnosing: boolean + isRepairing: boolean + error: string | null +} + +const CHAIN_ID = String(PEANUT_WALLET_CHAIN.id) +// Post-repair confirmation poll: the public RPC can lag the bundler that +// included the repair userOp (same hazard ensureRootValidatorMigrated guards). +const CONFIRM_RETRIES = 5 +const CONFIRM_INTERVAL_MS = 1500 + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function readDiagnosis(address: Address): Promise { + const code = await peanutPublicClient.getCode({ address }) + if (!code || code === '0x') return { state: 'undeployed' } + const [currentNonce, validNonceFrom] = await Promise.all([ + peanutPublicClient.readContract({ address, abi: KernelV3AccountAbi, functionName: 'currentNonce' }), + peanutPublicClient.readContract({ address, abi: KernelV3AccountAbi, functionName: 'validNonceFrom' }), + ]) + const cn = Number(currentNonce) + const vnf = Number(validNonceFrom) + return vnf > cn + ? { state: 'nonce-bricked', currentNonce: cn, validNonceFrom: vnf } + : { state: 'healthy', currentNonce: cn, validNonceFrom: vnf } +} + +export const useCardSignatureRepair = () => { + const { address, handleSendUserOpEncoded } = useZeroDev() + const { rebuildClientForChain } = useKernelClient() + const [state, setState] = useState({ + diagnosis: null, + isDiagnosing: false, + isRepairing: false, + error: null, + }) + + const diagnose = useCallback(async (): Promise => { + if (!address) return null + setState((s) => ({ ...s, isDiagnosing: true, error: null })) + try { + const diagnosis = await readDiagnosis(address as Address) + setState((s) => ({ ...s, diagnosis, isDiagnosing: false })) + return diagnosis + } catch (error) { + setState((s) => ({ ...s, isDiagnosing: false, error: (error as Error).message })) + return null + } + }, [address]) + + /** + * Sends the repair userOp for the current diagnosis, confirms it landed by + * re-reading on-chain state (never trusting the bundle receipt — a + * reverted userOp still yields a successful bundle), and rebuilds the + * kernel client so subsequent signatures (the re-grant) bind fresh state. + * Returns the post-repair diagnosis, or null on failure. + */ + const repair = useCallback(async (): Promise => { + const diagnosis = state.diagnosis + if (!address || !diagnosis || diagnosis.state === 'healthy') return diagnosis + setState((s) => ({ ...s, isRepairing: true, error: null })) + try { + const call = + diagnosis.state === 'nonce-bricked' + ? { + to: address as Address, + value: 0n, + data: encodeFunctionData({ + abi: KernelV3AccountAbi, + functionName: 'invalidateNonce', + args: [diagnosis.validNonceFrom + 1], + }), + } + : buildMigrationNoopCall(address as Address) + await handleSendUserOpEncoded([call], CHAIN_ID) + + let confirmed: CardSignatureDiagnosis | null = null + for (let attempt = 0; attempt < CONFIRM_RETRIES; attempt++) { + const fresh = await readDiagnosis(address as Address) + if (fresh.state === 'healthy') { + confirmed = fresh + break + } + if (attempt < CONFIRM_RETRIES - 1) await delay(CONFIRM_INTERVAL_MS) + } + if (!confirmed) { + setState((s) => ({ + ...s, + isRepairing: false, + error: 'The repair did not confirm on-chain in time — please retry in a moment', + })) + return null + } + // The repair op may have run the root-validator migration; rebuild so + // the re-grant signs via the current validator, not a stale wrapper. + await rebuildClientForChain(CHAIN_ID) + setState((s) => ({ ...s, diagnosis: confirmed, isRepairing: false })) + return confirmed + } catch (error) { + setState((s) => ({ ...s, isRepairing: false, error: (error as Error).message })) + return null + } + }, [address, state.diagnosis, handleSendUserOpEncoded, rebuildClientForChain]) + + return { + diagnosis: state.diagnosis, + isDiagnosing: state.isDiagnosing, + isRepairing: state.isRepairing, + error: state.error, + diagnose, + repair, + } +} From 8289fe222f6210845f0b07e56c9f850d6ae03f6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 09:20:27 -0300 Subject: [PATCH 2/4] chore: drop eslint-disable for a rule this config doesn't define --- src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts index 39ec5a8207..f6c932e2b0 100644 --- a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts +++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts @@ -38,7 +38,6 @@ jest.mock('@zerodev/sdk', () => ({ }, ], })) -// eslint-disable-next-line import/order import { KernelV3AccountAbi as KERNEL_ABI } from '@zerodev/sdk' jest.mock('@/constants/zerodev.consts', () => ({ From aac3020e9cdca8aecc7d956623e32e2d7f451119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 09:38:40 -0300 Subject: [PATCH 3/4] review: harden /fix-card-signature per /code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - diagnose keyed on the async zerodev address (mount-only effect left the page dead on a cold load from a support DM — the exact target flow) - repair() re-diagnoses live state before sending, so a retry after a confirm-poll timeout can't re-send an already-consumed invalidateNonce - guard the Kernel v3.1 invalidation cap (floor > currentNonce+10 has no valid target — fail with clear copy instead of burning doomed passkey taps) - step 2 respects the card-overview loading state instead of flashing 'no active card — contact support' at users on the success path - 'Check again' affordance after a failed diagnosis; dismissed passkey sheets are quiet no-ops, matching the grant path; drop the dead lastError render condition and the type export from the hook file --- .../(mobile-ui)/fix-card-signature/page.tsx | 38 ++++++++++------ .../__tests__/useCardSignatureRepair.test.ts | 44 +++++++++++++++++++ src/hooks/wallet/useCardSignatureRepair.ts | 40 +++++++++++++++-- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/app/(mobile-ui)/fix-card-signature/page.tsx b/src/app/(mobile-ui)/fix-card-signature/page.tsx index d8f82d00a1..2ceb92b001 100644 --- a/src/app/(mobile-ui)/fix-card-signature/page.tsx +++ b/src/app/(mobile-ui)/fix-card-signature/page.tsx @@ -16,22 +16,26 @@ import { Card } from '@/components/0_Bruddle/Card' import NavHeader from '@/components/Global/NavHeader' import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useZeroDev } from '@/hooks/useZeroDev' import { useCardSignatureRepair } from '@/hooks/wallet/useCardSignatureRepair' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' export default function FixCardSignaturePage() { - const { overview } = useRainCardOverview() + const { address } = useZeroDev() + const { overview, isLoading: isOverviewLoading } = useRainCardOverview() const { diagnosis, isDiagnosing, isRepairing, error, diagnose, repair } = useCardSignatureRepair() - const { grant, isGranting, lastError } = useGrantSessionKey() + const { grant, isGranting } = useGrantSessionKey() const [grantDone, setGrantDone] = useState(false) const [grantErrorMessage, setGrantErrorMessage] = useState(null) const card = findActiveCard(overview) + // Keyed on address: the zerodev address hydrates asynchronously after the + // layout unblocks, so a mount-only effect would diagnose before it exists + // and never retry — dead page on a cold load from a support DM. useEffect(() => { - void diagnose() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (address) void diagnose() + }, [address, diagnose]) const needsRepair = diagnosis !== null && diagnosis.state !== 'healthy' const busy = isDiagnosing || isRepairing || isGranting @@ -64,7 +68,13 @@ export default function FixCardSignaturePage() { takes up to two quick passkey confirmations.

- {isDiagnosing &&

Checking your wallet…

} + {(isDiagnosing || (!address && !diagnosis)) &&

Checking your wallet…

} + + {!isDiagnosing && !diagnosis && error && ( + + )} {diagnosis && ( @@ -114,11 +124,15 @@ export default function FixCardSignaturePage() { shadowSize="4" className="w-full" onClick={handleGrant} - disabled={busy || !card} + disabled={busy || isOverviewLoading || !card} > - {isGranting ? 'Waiting for confirmation…' : 'Re-enable funding'} + {isGranting + ? 'Waiting for confirmation…' + : isOverviewLoading + ? 'Loading your card…' + : 'Re-enable funding'} - {!card && ( + {!isOverviewLoading && !card && (

No active card found on this account — please contact support.

@@ -128,11 +142,7 @@ export default function FixCardSignaturePage() {
)} - {(error || grantErrorMessage || (lastError && lastError.kind !== 'user-cancelled' && !grantDone)) && ( -

- {error ?? grantErrorMessage ?? 'Something went wrong — please try again.'} -

- )} + {(error || grantErrorMessage) &&

{error ?? grantErrorMessage}

} {diagnosis && diagnosis.state !== 'undeployed' && (

diff --git a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts index f6c932e2b0..6b7f4e176c 100644 --- a/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts +++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts @@ -178,6 +178,50 @@ describe('repair', () => { expect(mockSendUserOp).not.toHaveBeenCalled() }) + it('retry after a confirm timeout skips the send when the first op already landed', async () => { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + + // Between the stale diagnosis and the retry tap, the first repair op landed. + chainState({ deployed: true, cn: 4, vnf: 4 }) + + await act(async () => { + expect(await result.current.repair()).toEqual({ state: 'healthy', currentNonce: 4, validNonceFrom: 4 }) + }) + expect(mockSendUserOp).not.toHaveBeenCalled() + expect(mockRebuildClient).toHaveBeenCalledWith('42161') + }) + + it('refuses to send a doomed op when the floor is beyond the kernel invalidation cap', async () => { + chainState({ deployed: true, cn: 2, vnf: 20 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + await act(async () => { + expect(await result.current.repair()).toBeNull() + }) + expect(mockSendUserOp).not.toHaveBeenCalled() + expect(result.current.error).toMatch(/manual repair/) + }) + + it('treats a dismissed passkey sheet as a quiet no-op, not an error', async () => { + chainState({ deployed: true, cn: 2, vnf: 3 }) + const { result } = renderHook(() => useCardSignatureRepair()) + await act(async () => { + await result.current.diagnose() + }) + mockSendUserOp.mockRejectedValue(new Error('Signing failed: The operation was not allowed')) + await act(async () => { + expect(await result.current.repair()).toBeNull() + }) + expect(result.current.error).toBeNull() + expect(result.current.isRepairing).toBe(false) + }) + it('surfaces a retryable error when the repair never confirms on-chain', async () => { jest.useFakeTimers() try { diff --git a/src/hooks/wallet/useCardSignatureRepair.ts b/src/hooks/wallet/useCardSignatureRepair.ts index 19de764df2..41dc0bfba0 100644 --- a/src/hooks/wallet/useCardSignatureRepair.ts +++ b/src/hooks/wallet/useCardSignatureRepair.ts @@ -29,7 +29,7 @@ import { buildMigrationNoopCall } from '@/utils/kernelMigration.utils' * signs against the fresh live nonce and re-stores the approval server-side. */ -export type CardSignatureDiagnosis = +type CardSignatureDiagnosis = | { state: 'undeployed' } | { state: 'nonce-bricked'; currentNonce: number; validNonceFrom: number } | { state: 'healthy'; currentNonce: number; validNonceFrom: number } @@ -46,6 +46,15 @@ const CHAIN_ID = String(PEANUT_WALLET_CHAIN.id) // included the repair userOp (same hazard ensureRootValidatorMigrated guards). const CONFIRM_RETRIES = 5 const CONFIRM_INTERVAL_MS = 1500 +// 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 nonce has no valid invalidation target and needs manual repair. +const MAX_NONCE_INCREMENT_SIZE = 10 + +const isUserCancelled = (message: string) => { + const m = message.toLowerCase() + return m.includes('user rejected') || m.includes('cancelled') || m.includes('not allowed') +} const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -94,10 +103,30 @@ export const useCardSignatureRepair = () => { * Returns the post-repair diagnosis, or null on failure. */ const repair = useCallback(async (): Promise => { - const diagnosis = state.diagnosis - if (!address || !diagnosis || diagnosis.state === 'healthy') return diagnosis + if (!address || !state.diagnosis || state.diagnosis.state === 'healthy') return state.diagnosis setState((s) => ({ ...s, isRepairing: true, error: null })) try { + // Re-diagnose against live state, not the mount-time snapshot: a + // retry after a confirm-poll timeout must not re-send an + // invalidateNonce the first op already consumed (it would revert). + const diagnosis = await readDiagnosis(address as Address) + if (diagnosis.state === 'healthy') { + await rebuildClientForChain(CHAIN_ID) + setState((s) => ({ ...s, diagnosis, isRepairing: false })) + return diagnosis + } + if ( + diagnosis.state === 'nonce-bricked' && + diagnosis.validNonceFrom + 1 > diagnosis.currentNonce + MAX_NONCE_INCREMENT_SIZE + ) { + setState((s) => ({ + ...s, + diagnosis, + isRepairing: false, + error: 'This wallet needs a manual repair — please contact support.', + })) + return null + } const call = diagnosis.state === 'nonce-bricked' ? { @@ -135,7 +164,10 @@ export const useCardSignatureRepair = () => { setState((s) => ({ ...s, diagnosis: confirmed, isRepairing: false })) return confirmed } catch (error) { - setState((s) => ({ ...s, isRepairing: false, error: (error as Error).message })) + const message = (error as Error).message ?? String(error) + // A dismissed passkey sheet is not a failure — clear busy quietly, + // matching how the grant path treats user-cancelled. + setState((s) => ({ ...s, isRepairing: false, error: isUserCancelled(message) ? null : message })) return null } }, [address, state.diagnosis, handleSendUserOpEncoded, rebuildClientForChain]) From d0e51efdc33050f6df0c3aff85daa6e05aab88f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 14 Jul 2026 09:46:18 -0300 Subject: [PATCH 4/4] =?UTF-8?q?review:=20widen=20the=20repair=20confirm=20?= =?UTF-8?q?poll=20to=20~12s=20(CodeRabbit)=20=E2=80=94=20fewer=20false=20n?= =?UTF-8?q?egatives=20on=20laggy=20RPCs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/wallet/useCardSignatureRepair.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/wallet/useCardSignatureRepair.ts b/src/hooks/wallet/useCardSignatureRepair.ts index 41dc0bfba0..fcf21ac61d 100644 --- a/src/hooks/wallet/useCardSignatureRepair.ts +++ b/src/hooks/wallet/useCardSignatureRepair.ts @@ -44,7 +44,7 @@ interface RepairState { const CHAIN_ID = String(PEANUT_WALLET_CHAIN.id) // Post-repair confirmation poll: the public RPC can lag the bundler that // included the repair userOp (same hazard ensureRootValidatorMigrated guards). -const CONFIRM_RETRIES = 5 +const CONFIRM_RETRIES = 8 const CONFIRM_INTERVAL_MS = 1500 // Kernel v3.1 rejects invalidateNonce more than MAX_NONCE_INCREMENT_SIZE (10) // above currentNonce AND at-or-below validNonceFrom — a floor further than 10