Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
)

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<ReturnType<typeof result.current.serializeGrant>>
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<ReturnType<typeof result.current.serializeGrant>>
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)
})
})
45 changes: 42 additions & 3 deletions src/hooks/wallet/useGrantSessionKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Comment on lines +169 to +206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and related type declarations
git ls-files | rg 'src/hooks/wallet/useGrantSessionKey\.ts|createKernelAccount|getPluginsEnableTypedData|kernelPluginManager|CURRENT_NONCE_ABI'

# Show the relevant section with line numbers
sed -n '1,260p' src/hooks/wallet/useGrantSessionKey.ts

# Search for type/signature definitions
rg -n "createKernelAccount|getPluginsEnableTypedData|kernelPluginManager|CURRENT_NONCE_ABI|currentNonce" src

Repository: peanutprotocol/peanut-ui

Length of output: 18297


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect kernel client/account typing around the relevant access paths
sed -n '1,260p' src/context/kernelClient.context.tsx

# Look for any local type declarations or wrappers around kernel account/client
rg -n "type .*Kernel|interface .*Kernel|useKernelClient|kernelAccount|GenericSmartAccountClient|kernelPluginManager" src/context src/hooks src/utils

Repository: peanutprotocol/peanut-ui

Length of output: 17066


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the neighboring wallet hooks for existing account typing patterns
sed -n '1,320p' src/hooks/wallet/useSignSpendBundle.ts
sed -n '1,260p' src/hooks/wallet/useSpendBundle.ts
sed -n '1,220p' src/hooks/wallet/useSignUserOp.ts

# Find any local helpers/types that already model the account/plugin-manager shape
rg -n "signTypedData\\(|kernelPluginManager|sudoValidator|getAction\\(|hook\\b" src/hooks/wallet src/context

Repository: peanutprotocol/peanut-ui

Length of output: 35670


Model the minimal kernelPluginManager shape locally. The any casts here trip lint and hide the account surface this flow relies on; a small structural type for sudoValidator, getAction, and hook keeps the nonce path intact without suppressions.

🧰 Tools
🪛 ESLint

[error] 169-169: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 197-197: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 206-206: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/wallet/useGrantSessionKey.ts` around lines 169 - 206, The use of
`any` around `kernelPluginManager` is masking the exact account shape and
triggering lint issues in the session-key grant flow. Replace the `as any` casts
with a small स्थानीय structural type for the `kernelPluginManager` object used
by `useGrantSessionKey`, covering the referenced `sudoValidator`, `getAction`,
and `hook` members so `createKernelAccount` and `getPluginsEnableTypedData` can
use it safely without suppressions.

Source: Linters/SAST tools

// 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])

Expand Down
Loading