Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/constants/analytics.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ export const 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',
// Grant preflight found a repair-needing kernel (nonce floor from the
// 2025-09-18 migration wave, or an undeployed pre-cutoff account) and
// completed the repair before signing (captured on success), or fell back
// on a flaky floor read. `mode`: invalidate | deploy | floor-read-failed.
CARD_SESSION_KEY_PREFLIGHT_REPAIR: 'card_session_key_preflight_repair',
// Withdraw refused with 409 STALE_CARD_APPROVAL — stored session-key
// approval is bound to a deprecated validator; user must re-enable the card.
CARD_STALE_APPROVAL_HIT: 'card_stale_approval_hit',
Expand Down
13 changes: 13 additions & 0 deletions src/hooks/wallet/__tests__/useGrantSessionKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ let mockClientSudoValidatorAddress = V003_VALIDATOR
// The patched validator also signs the enable typed data (hotfix #2313 path).
const mockPatchedValidator = { address: V003_VALIDATOR, signTypedData: jest.fn(() => Promise.resolve('0xENABLESIG')) }
const mockGetPatchedSudoValidator = jest.fn(() => Promise.resolve(mockPatchedValidator))
const mockHandleSendUserOpEncoded = jest.fn(() => Promise.resolve({ userOpHash: '0xhash', receipt: null }))
jest.mock('@/hooks/useZeroDev', () => ({
useZeroDev: () => ({ handleSendUserOpEncoded: mockHandleSendUserOpEncoded }),
}))

// The preflight repair itself is unit-tested in kernelNonceRepair.utils.test.ts;
// here we only assert the hook routes floored accounts through it and binds
// the post-repair nonce.
const mockRepairEnableNonce = jest.fn()
jest.mock('@/utils/kernelNonceRepair.utils', () => ({
repairEnableNonce: (...args: unknown[]) => mockRepairEnableNonce(...args),
}))

jest.mock('@/context/kernelClient.context', () => ({
useKernelClient: () => ({
getClientForChain: () => ({
Expand Down
72 changes: 72 additions & 0 deletions src/hooks/wallet/__tests__/useGrantSessionKey.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jest.mock('@/constants/analytics.consts', () => ({
CARD_SESSION_KEY_PROMPTED: 'card_session_key_prompted',
CARD_SESSION_KEY_GRANTED: 'card_session_key_granted',
CARD_SESSION_KEY_FAILED: 'card_session_key_failed',
CARD_SESSION_KEY_PREFLIGHT_REPAIR: 'card_session_key_preflight_repair',
},
}))
jest.mock('@/constants/zerodev.consts', () => ({
Expand All @@ -56,6 +57,23 @@ jest.mock('@/hooks/useRainCardOverview', () => ({
}))
jest.mock('@/app/actions/clients', () => ({ peanutPublicClient: { readContract: jest.fn(), getCode: jest.fn() } }))
jest.mock('@/services/rain', () => ({ rainApi: { getSessionKeyAddress: jest.fn() } }))
const mockHandleSendUserOpEncoded = jest.fn(() => Promise.resolve({ userOpHash: '0xhash', receipt: null }))
jest.mock('@/hooks/useZeroDev', () => ({
useZeroDev: () => ({ handleSendUserOpEncoded: mockHandleSendUserOpEncoded }),
}))
// Repair internals are unit-tested in kernelNonceRepair.utils.test.ts — here we
// only assert the hook routes floored accounts through it and binds the result.
const mockRepairEnableNonce = jest.fn()
jest.mock('@/utils/kernelNonceRepair.utils', () => ({
repairEnableNonce: (...args: unknown[]) => mockRepairEnableNonce(...args),
}))
// Deploy path goes through the hardened migration gate (ground-truth verified);
// its internals are covered by kernelMigration.utils.test.ts.
const mockEnsureRootValidatorMigrated = jest.fn()
jest.mock('@/utils/kernelMigration.utils', () => ({
...jest.requireActual('@/utils/kernelMigration.utils'),
ensureRootValidatorMigrated: (...args: unknown[]) => mockEnsureRootValidatorMigrated(...args),
}))
jest.mock('@zerodev/permissions', () => ({
toPermissionValidator: jest.fn(),
serializePermissionAccount: jest.fn(),
Expand Down Expand Up @@ -181,4 +199,58 @@ describe('useGrantSessionKey — enable approval binds to the live currentNonce'

expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 1 }))
})

it('repairs a nonce-floored account (validNonceFrom > currentNonce) before signing and binds the fresh nonce', async () => {
// The 2025-09-18 migration-wave state: an approval signed without repair
// would fail AA23 InvalidNonce on every backend replay, forever.
;(peanutPublicClient.readContract as jest.Mock).mockImplementation(({ functionName }) =>
Promise.resolve(functionName === 'validNonceFrom' ? 3n : 2n)
)
mockRepairEnableNonce.mockResolvedValue({ validatorNonce: 4 })

const { result } = renderHook(() => useGrantSessionKey(), { wrapper })
let out: Awaited<ReturnType<typeof result.current.serializeGrant>>
await act(async () => {
out = await result.current.serializeGrant()
})

expect(mockRepairEnableNonce).toHaveBeenCalledWith(
expect.objectContaining({ validNonceFrom: 3, accountAddress: ACCOUNT })
)
expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 4 }))
expect(out!).toEqual({ ok: true, serialized: 'SERIALIZED_APPROVAL' })
})

it('deploys an undeployed pre-cutoff (migration-wrapper) account via the migration gate before granting — its approval would AA14 otherwise', async () => {
;(useKernelClient as jest.Mock).mockReturnValue({
getClientForChain: () => ({
// getRootValidatorMigrationStatus marks the migration wrapper.
account: { address: ACCOUNT, getRootValidatorMigrationStatus: jest.fn() },
}),
getPatchedSudoValidator: jest.fn().mockResolvedValue({ signTypedData }),
rebuildClientForChain: jest.fn(),
})
;(peanutPublicClient.getCode as jest.Mock).mockResolvedValue(undefined)
// Pre-deploy reads revert; after the migration gate runs (deploy lands),
// the fresh currentNonce read returns 1.
let migrated = false
mockEnsureRootValidatorMigrated.mockImplementation(async () => {
migrated = true
return {}
})
;(peanutPublicClient.readContract as jest.Mock).mockImplementation(() =>
migrated ? Promise.resolve(1n) : Promise.reject(new Error('returned no data ("0x")'))
)

const { result } = renderHook(() => useGrantSessionKey(), { wrapper })
let out: Awaited<ReturnType<typeof result.current.serializeGrant>>
await act(async () => {
out = await result.current.serializeGrant()
})

expect(mockEnsureRootValidatorMigrated).toHaveBeenCalled()
expect(mockRepairEnableNonce).not.toHaveBeenCalled()
expect(getPluginsEnableTypedData).toHaveBeenCalledWith(expect.objectContaining({ validatorNonce: 1 }))
expect(out!).toEqual({ ok: true, serialized: 'SERIALIZED_APPROVAL' })
})
})
70 changes: 65 additions & 5 deletions src/hooks/wallet/useGrantSessionKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { getEntryPoint, KERNEL_V3_1 } from '@zerodev/sdk/constants'
import { serializePermissionAccount } from '@zerodev/permissions'
import { peanutPublicClient } from '@/app/actions/clients'
import { rainApi } from '@/services/rain'
import { useZeroDev } from '@/hooks/useZeroDev'
import { ensureRootValidatorMigrated, isMigrationWrapperAccount } from '@/utils/kernelMigration.utils'
import { repairEnableNonce, type NoncePublicClient } from '@/utils/kernelNonceRepair.utils'

/** Minimal structural view of the bits of the kernel account's plugin manager
* this flow touches. The SDK doesn't surface these on its public account type,
Expand Down Expand Up @@ -96,7 +99,8 @@ export interface GrantSessionKeyResult {

export const useGrantSessionKey = (): GrantSessionKeyResult => {
const { overview, refetch } = useRainCardOverview()
const { getClientForChain, getPatchedSudoValidator } = useKernelClient()
const { getClientForChain, getPatchedSudoValidator, rebuildClientForChain } = useKernelClient()
const { handleSendUserOpEncoded } = useZeroDev()
const queryClient = useQueryClient()
const [isGranting, setIsGranting] = useState(false)
const [lastError, setLastError] = useState<GrantSessionKeyError | null>(null)
Expand Down Expand Up @@ -188,9 +192,9 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => {
// is a different, never-funded address. Forcing the address here makes
// the grant work for both legacy and post-migration users.
const accountAddress = kernelClient.account!.address
// The three on-chain reads only need the (already-known) account address,
// The four on-chain reads only need the (already-known) account address,
// so they run alongside the account construction.
const [sessionKernelAccount, bytecode, metadata, nonceRead] = await Promise.all([
const [sessionKernelAccount, bytecode, metadata, nonceRead, floorRead] = await Promise.all([
createKernelAccount(peanutPublicClient, {
address: accountAddress,
entryPoint: getEntryPoint('0.7'),
Expand All @@ -212,6 +216,12 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => {
(nonce) => ({ read: true as const, nonce: Number(nonce) }),
(error: unknown) => ({ read: false as const, error })
),
peanutPublicClient
.readContract({ address: accountAddress, abi: KernelV3AccountAbi, functionName: 'validNonceFrom' })
.then(
(floor) => ({ read: true as const, floor: Number(floor) }),
(error: unknown) => ({ read: false as const, error })
),
])

// The session-key permission installs on-chain via an "enable" approval the
Expand All @@ -226,11 +236,61 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => {
// `currentNonce` to 1 at deployment (the read itself reverts pre-deploy).
let validatorNonce: number
if (!bytecode) {
validatorNonce = 1
if (isMigrationWrapperAccount(kernelClient.account)) {
// Undeployed PRE-cutoff account: the serialized approval would bake
// a v0.0.3 initCode that derives a different CREATE2 address than
// this wallet, so every backend replay reverts AA14. Deploy first
// via the hardened migration gate — it verifies the root-validator
// swap against ON-CHAIN ground truth (a reverted migration inside a
// successful bundle would otherwise deploy the account on v0.0.2
// and the approval signed below would be silently dead) and hands
// back a rebuilt client. One extra passkey tap.
await ensureRootValidatorMigrated({
client: kernelClient,
sendNoopUserOp: (call) => handleSendUserOpEncoded([call], chainId),
rebuildClient: () => rebuildClientForChain(chainId),
})
// Freshly deployed: read the live nonce; fail loud if unreadable.
const freshNonce = await peanutPublicClient.readContract({
address: accountAddress,
abi: KernelV3AccountAbi,
functionName: 'currentNonce',
})
validatorNonce = Math.max(Number(freshNonce), 1)
posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'deploy' })
} else {
// Post-cutoff counterfactual: the kernel initializes currentNonce
// to 1 at deployment, so 1 is provably exact.
validatorNonce = 1
}
} else if (!nonceRead.read) {
// Deployed but unreadable: fail LOUDLY rather than sign a guess.
throw nonceRead.error
} else if (floorRead.read && floorRead.floor > Math.max(nonceRead.nonce, 1)) {
// validNonceFrom AHEAD of currentNonce — the 2025-09-18 migration-wave
// state. Every enable-mode install lands below the floor and reverts
// InvalidNonce forever, so an approval signed now would be dead on
// arrival. Repair inline (one extra passkey tap: invalidateNonce
// syncs the counter up to the floor), then bind the fresh nonce.
validatorNonce = (
await repairEnableNonce({
// viem's generic readContract collapses structural
// assignability to the minimal client interface.
publicClient: peanutPublicClient as unknown as NoncePublicClient,
accountAddress,
validNonceFrom: floorRead.floor,
sendUserOp: (call) => handleSendUserOpEncoded([call], chainId),
})
).validatorNonce
posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'invalidate' })
} else {
if (!floorRead.read) {
// Don't regress every healthy grant on one flaky read: proceed on
// the old (pre-floor-check) behavior and flag it — a floored
// account slipping through here still gets caught by the sweep's
// permanent-failure path and /fix-card-signature.
posthog.capture(ANALYTICS_EVENTS.CARD_SESSION_KEY_PREFLIGHT_REPAIR, { mode: 'floor-read-failed' })
}
// A deployed-but-uninitialized proxy reports 0; enables validate
// against ≥1 post-init, so normalize the way the SDK does.
validatorNonce = nonceRead.nonce === 0 ? 1 : nonceRead.nonce
Expand All @@ -252,7 +312,7 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => {

const serialized = await serializePermissionAccount(sessionKernelAccount, undefined, enableSignature)
return { ok: true, serialized }
}, [overview, getClientForChain, getPatchedSudoValidator])
}, [overview, getClientForChain, getPatchedSudoValidator, handleSendUserOpEncoded, rebuildClientForChain])

const wrap = useCallback(
async <T>(
Expand Down
Loading
Loading