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.'}
+
+
+ {isRepairing ? 'Repairing…' : 'Repair now'}
+
+ >
+ )}
+
+ )}
+
+ {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.
+
+
+ {isGranting ? 'Waiting for confirmation…' : 'Re-enable funding'}
+
+ {!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 && (
+ void diagnose()}>
+ Check again
+
+ )}
{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