diff --git a/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx b/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx new file mode 100644 index 000000000..a2e4a60aa --- /dev/null +++ b/src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx @@ -0,0 +1,161 @@ +/** + * Regression guard for the card session-key grant binding its enable approval + * to the WRONG kernel nonce. + * + * The session-key permission installs on-chain via an "enable" approval the + * passkey signs at grant time, bound to the account's `currentNonce`. The SDK's + * internal `getKernelV3Nonce` silently returns `1` on a read failure — minting + * an approval frozen to nonce 1 that the kernel rejects with AA23 InvalidNonce + * for any account whose live nonce ≠ 1 (migrated / sudo-changed accounts). The + * card then declines forever. The fix reads `currentNonce` explicitly, binds the + * enable to it, and throws on a read failure instead of producing a bad approval. + * + * These tests lock down that contract: + * 1. the enable approval is bound to the live currentNonce (not a fallback), and + * 2. a failed currentNonce read aborts the grant — no approval is produced. + */ +import { renderHook, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { serializePermissionAccount, toPermissionValidator } from '@zerodev/permissions' +import { toCallPolicy } from '@zerodev/permissions/policies' +import { toECDSASigner } from '@zerodev/permissions/signers' +import { peanutPublicClient } from '@/app/actions/clients' +import { useKernelClient } from '@/context/kernelClient.context' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { rainApi } from '@/services/rain' + +const COLLATERAL = '0x4e5b89fd498f333ed7f2a59c5f23d5b5dc41b3de' +const COORDINATOR = '0xc0d5bd6307ec8c8da03e7502a00b8cba24eefc06' +const ACCOUNT = '0xc97fffbf8768ca90cd62fae2e313b084fe13e553' + +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@/constants/analytics.consts', () => ({ + 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', + }, +})) +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', +})) +jest.mock('@/constants/rain.consts', () => ({ + rainCoordinatorAbi: [ + { type: 'function', name: 'withdrawAsset', inputs: [], outputs: [], stateMutability: 'nonpayable' }, + ], +})) +jest.mock('@/context/kernelClient.context', () => ({ useKernelClient: jest.fn() })) +jest.mock('@/hooks/useRainCardOverview', () => ({ + useRainCardOverview: jest.fn(), + RAIN_CARD_OVERVIEW_QUERY_KEY: 'rain-card-overview', +})) +jest.mock('@/app/actions/clients', () => ({ peanutPublicClient: { readContract: jest.fn() } })) +jest.mock('@/services/rain', () => ({ rainApi: { getSessionKeyAddress: jest.fn() } })) +jest.mock('@zerodev/permissions', () => ({ + toPermissionValidator: jest.fn(), + serializePermissionAccount: jest.fn(), +})) +jest.mock('@zerodev/permissions/policies', () => ({ + toCallPolicy: jest.fn(), + CallPolicyVersion: { V0_0_4: '0.0.4' }, + ParamCondition: { EQUAL: 0 }, +})) +jest.mock('@zerodev/permissions/signers', () => ({ toECDSASigner: jest.fn() })) +// jest resolves '@zerodev/sdk' and '@zerodev/sdk/constants' to the same module +// registry entry (subpath collapses onto the package), so the two mocks clobber +// each other — last wins. Give BOTH the union of needed exports so every symbol +// (createKernelAccount, getPluginsEnableTypedData, getEntryPoint, KERNEL_V3_1) +// is present whichever factory wins. +jest.mock('@zerodev/sdk', () => ({ + createKernelAccount: jest.fn(), + getPluginsEnableTypedData: jest.fn(), + getEntryPoint: () => ({ address: '0x0', version: '0.7' }), + KERNEL_V3_1: '0.3.1', +})) +jest.mock('@zerodev/sdk/constants', () => ({ + createKernelAccount: jest.fn(), + getPluginsEnableTypedData: jest.fn(), + getEntryPoint: () => ({ address: '0x0', version: '0.7' }), + KERNEL_V3_1: '0.3.1', +})) + +import { useGrantSessionKey } from '../useGrantSessionKey' + +// `@zerodev/sdk`'s named imports don't bind to the mock cleanly at the test's +// top level (module-interop quirk); pull the mocked fns from jest's registry. +const { createKernelAccount, getPluginsEnableTypedData } = jest.requireMock('@zerodev/sdk') as { + createKernelAccount: jest.Mock + getPluginsEnableTypedData: jest.Mock +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +let signTypedData: jest.Mock + +beforeEach(() => { + jest.clearAllMocks() + signTypedData = jest.fn().mockResolvedValue('0xENABLESIG') + ;(useKernelClient as jest.Mock).mockReturnValue({ + getClientForChain: () => ({ + account: { address: ACCOUNT, kernelPluginManager: { sudoValidator: { signTypedData } } }, + }), + }) + ;(useRainCardOverview as jest.Mock).mockReturnValue({ + overview: { status: { contractAddress: COLLATERAL, coordinatorAddress: COORDINATOR }, cards: [{}] }, + refetch: jest.fn(), + }) + ;(rainApi.getSessionKeyAddress as jest.Mock).mockResolvedValue({ + address: '0x4300F803a281e257F3C1de001512e68972f8d022', + }) + ;(toCallPolicy as jest.Mock).mockResolvedValue({ __policy: true }) + ;(toECDSASigner as jest.Mock).mockResolvedValue({ __signer: true }) + ;(toPermissionValidator as jest.Mock).mockResolvedValue({ __permission: true }) + ;(createKernelAccount as jest.Mock).mockResolvedValue({ + address: ACCOUNT, + kernelPluginManager: { getAction: () => ({ selector: '0xe9ae5c53', address: ACCOUNT }), hook: undefined }, + }) + ;(getPluginsEnableTypedData as jest.Mock).mockResolvedValue({ primaryType: 'Enable', message: {} }) + ;(serializePermissionAccount as jest.Mock).mockResolvedValue('SERIALIZED_APPROVAL') +}) + +describe('useGrantSessionKey — enable approval binds to the live currentNonce', () => { + it('reads currentNonce and binds the enable approval to it, injecting the signature', async () => { + ;(peanutPublicClient.readContract as jest.Mock).mockResolvedValue(2n) // migrated account, nonce advanced past 1 + + const { result } = renderHook(() => useGrantSessionKey(), { wrapper }) + let out: Awaited> + await act(async () => { + out = await result.current.serializeGrant() + }) + + // currentNonce was read on the account… + expect(peanutPublicClient.readContract).toHaveBeenCalledWith( + expect.objectContaining({ address: ACCOUNT, functionName: 'currentNonce' }) + ) + // …the enable typed data was built for THAT nonce (2), not a fallback (1)… + expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 2 })) + // …signed by the sudo (passkey) validator, and injected into serialize. + expect(signTypedData).toHaveBeenCalledWith({ primaryType: 'Enable', message: {} }) + expect(serializePermissionAccount).toHaveBeenCalledWith(expect.anything(), undefined, '0xENABLESIG') + expect(out!).toEqual({ ok: true, serialized: 'SERIALIZED_APPROVAL' }) + }) + + it('aborts the grant when currentNonce cannot be read — no approval is produced', async () => { + ;(peanutPublicClient.readContract as jest.Mock).mockRejectedValue(new Error('RPC read failed')) + + const { result } = renderHook(() => useGrantSessionKey(), { wrapper }) + let out: Awaited> + await act(async () => { + out = await result.current.serializeGrant() + }) + + // The old silent fallback would have minted a nonce-1 approval here. + expect(getPluginsEnableTypedData).not.toHaveBeenCalled() + expect(serializePermissionAccount).not.toHaveBeenCalled() + expect(out!.ok).toBe(false) + }) +}) diff --git a/src/hooks/wallet/useGrantSessionKey.ts b/src/hooks/wallet/useGrantSessionKey.ts index a5eb330f2..de6367d2c 100644 --- a/src/hooks/wallet/useGrantSessionKey.ts +++ b/src/hooks/wallet/useGrantSessionKey.ts @@ -13,9 +13,16 @@ import { rainCoordinatorAbi } from '@/constants/rain.consts' import { toPermissionValidator } from '@zerodev/permissions' import { toCallPolicy, CallPolicyVersion, ParamCondition } from '@zerodev/permissions/policies' import { toECDSASigner } from '@zerodev/permissions/signers' -import { createKernelAccount } from '@zerodev/sdk' +import { createKernelAccount, getPluginsEnableTypedData } from '@zerodev/sdk' import { getEntryPoint, KERNEL_V3_1 } from '@zerodev/sdk/constants' import { serializePermissionAccount } from '@zerodev/permissions' + +/** Kernel v3 validator-enable epoch. The permission's enable approval is signed + * over this value; the kernel rejects the enable with `InvalidNonce` if the + * signed value ≠ the account's live `currentNonce`. */ +const CURRENT_NONCE_ABI = [ + { type: 'function', name: 'currentNonce', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint32' }] }, +] as const import { peanutPublicClient } from '@/app/actions/clients' import { rainApi } from '@/services/rain' @@ -159,17 +166,49 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { // natural counterfactual of `createKernelAccount({sudo: newValidator})` // is a different, never-funded address. Forcing the address here makes // the grant work for both legacy and post-migration users. + const sudoValidator = (kernelClient.account as any).kernelPluginManager.sudoValidator const sessionKernelAccount = await createKernelAccount(peanutPublicClient, { address: kernelClient.account!.address, entryPoint: getEntryPoint('0.7'), kernelVersion: KERNEL_V3_1, plugins: { - sudo: (kernelClient.account as any).kernelPluginManager.sudoValidator, + sudo: sudoValidator, regular: permissionPlugin, }, }) - const serialized = await serializePermissionAccount(sessionKernelAccount) + // The session-key permission installs on-chain via an "enable" approval + // the passkey signs here, bound to the account's `currentNonce`. We read + // that nonce EXPLICITLY and let the read throw on failure: the SDK's + // internal `getKernelV3Nonce` silently falls back to `1` on a read error, + // which produces an approval frozen to nonce 1. That only mismatches + // accounts whose live nonce ≠ 1 (e.g. any account migrated / with a + // sudo-validator change), and the grant still "succeeds" — so the card + // then declines forever because the enable reverts `AA23 InvalidNonce` + // on every auto-balance. Binding the enable to the verified live nonce + // (and failing loudly if we can't read it) is the fix. + const validatorNonce = Number( + await peanutPublicClient.readContract({ + address: sessionKernelAccount.address, + abi: CURRENT_NONCE_ABI, + functionName: 'currentNonce', + }) + ) + const pm = (sessionKernelAccount as any).kernelPluginManager + const enableTypedData = await getPluginsEnableTypedData({ + accountAddress: sessionKernelAccount.address, + chainId: PEANUT_WALLET_CHAIN.id, + kernelVersion: KERNEL_V3_1, + action: pm.getAction(), + hook: pm.hook, + validator: permissionPlugin, + validatorNonce, + } as any) + // Same sudo validator + signing path the SDK uses internally, so for + // healthy nonce=1 accounts this yields an identical approval. + const enableSignature = (await sudoValidator.signTypedData(enableTypedData)) as Hex + + const serialized = await serializePermissionAccount(sessionKernelAccount, undefined, enableSignature) return { ok: true, serialized } }, [overview, getClientForChain])