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..2ceb92b001 --- /dev/null +++ b/src/app/(mobile-ui)/fix-card-signature/page.tsx @@ -0,0 +1,155 @@ +'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 { useZeroDev } from '@/hooks/useZeroDev' +import { useCardSignatureRepair } from '@/hooks/wallet/useCardSignatureRepair' +import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' + +export default function FixCardSignaturePage() { + const { address } = useZeroDev() + const { overview, isLoading: isOverviewLoading } = useRainCardOverview() + const { diagnosis, isDiagnosing, isRepairing, error, diagnose, repair } = useCardSignatureRepair() + 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(() => { + if (address) void diagnose() + }, [address, diagnose]) + + 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 || (!address && !diagnosis)) &&

Checking your wallet…

} + + {!isDiagnosing && !diagnosis && error && ( + + )} + + {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. +

+ + {!isOverviewLoading && !card && ( +

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

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

{error ?? grantErrorMessage}

} + + {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..6b7f4e176c --- /dev/null +++ b/src/hooks/wallet/__tests__/useCardSignatureRepair.test.ts @@ -0,0 +1,251 @@ +/** + * 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', + }, + ], +})) +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('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 { + 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..fcf21ac61d --- /dev/null +++ b/src/hooks/wallet/useCardSignatureRepair.ts @@ -0,0 +1,183 @@ +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. + */ + +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 = 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 +// 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)) + +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 => { + 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' + ? { + 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) { + 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]) + + return { + diagnosis: state.diagnosis, + isDiagnosing: state.isDiagnosing, + isRepairing: state.isRepairing, + error: state.error, + diagnose, + repair, + } +}