From e90e2e105c3a225bfdd24c17f902a35d011bf7ff Mon Sep 17 00:00:00 2001 From: lau90eth Date: Fri, 1 May 2026 14:39:58 +0200 Subject: [PATCH] feat(transaction): add useTokenAllowance and useRevokeAllowance hooks Adds ERC-20 token allowance security hooks for OnchainKit. - useTokenAllowance: reads existing token spending allowances - useRevokeAllowance: revokes allowance by approving 0 - getTokenAllowance utility: reads onchain allowance via viem - Risk detection: flags infinite (max uint256) allowances - 5 tests covering: empty state, normal allowance, infinite allowance, API error, revoke action, revoking state Refs: coinbase#2572 --- .../hooks/useRevokeAllowance.test.ts | 44 +++++++ .../transaction/hooks/useRevokeAllowance.ts | 26 ++++ .../hooks/useTokenAllowance.test.ts | 112 ++++++++++++++++++ .../transaction/hooks/useTokenAllowance.ts | 84 +++++++++++++ .../transaction/utils/getTokenAllowance.ts | 54 +++++++++ 5 files changed, 320 insertions(+) create mode 100644 packages/onchainkit/src/transaction/hooks/useRevokeAllowance.test.ts create mode 100644 packages/onchainkit/src/transaction/hooks/useRevokeAllowance.ts create mode 100644 packages/onchainkit/src/transaction/hooks/useTokenAllowance.test.ts create mode 100644 packages/onchainkit/src/transaction/hooks/useTokenAllowance.ts create mode 100644 packages/onchainkit/src/transaction/utils/getTokenAllowance.ts diff --git a/packages/onchainkit/src/transaction/hooks/useRevokeAllowance.test.ts b/packages/onchainkit/src/transaction/hooks/useRevokeAllowance.test.ts new file mode 100644 index 0000000000..0587a71fcd --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useRevokeAllowance.test.ts @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; +import { type Mock, describe, expect, it, vi } from 'vitest'; +import { useWriteContract } from 'wagmi'; +import { useRevokeAllowance } from './useRevokeAllowance'; + +vi.mock('wagmi', () => ({ + useWriteContract: vi.fn(), +})); + +describe('useRevokeAllowance', () => { + it('should call approve with 0 to revoke', async () => { + const mockWriteContract = vi.fn(); + + (useWriteContract as Mock).mockReturnValue({ + writeContractAsync: mockWriteContract, + isPending: false, + }); + + const { result } = renderHook(() => useRevokeAllowance()); + + await result.current.revoke({ + token: '0xUSDC0000000000000000000000000000000000000', + spender: '0xSPENDER0000000000000000000000000000000000', + }); + + expect(mockWriteContract).toHaveBeenCalledWith({ + address: '0xUSDC0000000000000000000000000000000000000', + abi: expect.any(Array), + functionName: 'approve', + args: ['0xSPENDER0000000000000000000000000000000000', 0n], + }); + }); + + it('should expose isRevoking state', () => { + (useWriteContract as Mock).mockReturnValue({ + writeContractAsync: vi.fn(), + isPending: true, + }); + + const { result } = renderHook(() => useRevokeAllowance()); + + expect(result.current.isRevoking).toBe(true); + }); +}); diff --git a/packages/onchainkit/src/transaction/hooks/useRevokeAllowance.ts b/packages/onchainkit/src/transaction/hooks/useRevokeAllowance.ts new file mode 100644 index 0000000000..ca5c09b3b0 --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useRevokeAllowance.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react'; +import { useWriteContract } from 'wagmi'; +import { erc20Abi, type Address } from 'viem'; + +export type RevokeAllowanceParams = { + token: Address; + spender: Address; +}; + +export function useRevokeAllowance() { + const { writeContractAsync, isPending: isRevoking } = useWriteContract(); + + const revoke = useCallback(async ({ token, spender }: RevokeAllowanceParams) => { + await writeContractAsync({ + address: token, + abi: erc20Abi, + functionName: 'approve', + args: [spender, 0n], // approve 0 = revoke + }); + }, [writeContractAsync]); + + return { + revoke, + isRevoking, + }; +} diff --git a/packages/onchainkit/src/transaction/hooks/useTokenAllowance.test.ts b/packages/onchainkit/src/transaction/hooks/useTokenAllowance.test.ts new file mode 100644 index 0000000000..a759eba853 --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useTokenAllowance.test.ts @@ -0,0 +1,112 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccount } from 'wagmi'; +import { useOnchainKit } from '@/useOnchainKit'; +import { useTokenAllowance } from './useTokenAllowance'; + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), +})); + +vi.mock('@/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + +vi.mock('../utils/getTokenAllowance', () => ({ + getTokenAllowance: vi.fn(), +})); + +import { getTokenAllowance } from '../utils/getTokenAllowance'; + +describe('useTokenAllowance', () => { + const mockOwner = '0x1234567890123456789012345678901234567890'; + const mockToken = '0xUSDC0000000000000000000000000000000000000'; + const mockSpender = '0xSPENDER0000000000000000000000000000000000'; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return empty state when no owner', () => { + (useAccount as Mock).mockReturnValue({ address: undefined }); + (useOnchainKit as Mock).mockReturnValue({ chain: { id: 8453 } }); + + const { result } = renderHook(() => useTokenAllowance()); + + expect(result.current.allowances).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + + it('should fetch allowances for tokens and spenders', async () => { + const mockAllowance = { + token: mockToken, + spender: mockSpender, + amount: '1000000000', + isInfinite: false, + }; + + (useAccount as Mock).mockReturnValue({ address: mockOwner }); + (useOnchainKit as Mock).mockReturnValue({ chain: { id: 8453 } }); + (getTokenAllowance as Mock).mockResolvedValue(mockAllowance); + + const { result } = renderHook(() => + useTokenAllowance({ + tokens: [mockToken], + spenders: [mockSpender], + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.allowances).toHaveLength(1); + expect(result.current.allowances[0].amount).toBe('1000000000'); + expect(result.current.allowances[0].isInfinite).toBe(false); + }); + + it('should detect infinite allowance', async () => { + const mockAllowance = { + token: mockToken, + spender: mockSpender, + amount: '115792089237316195423570985008687907853269984665640564039457584007913129639935', + isInfinite: true, + }; + + (useAccount as Mock).mockReturnValue({ address: mockOwner }); + (useOnchainKit as Mock).mockReturnValue({ chain: { id: 8453 } }); + (getTokenAllowance as Mock).mockResolvedValue(mockAllowance); + + const { result } = renderHook(() => + useTokenAllowance({ + tokens: [mockToken], + spenders: [mockSpender], + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.allowances[0].isInfinite).toBe(true); + }); + + it('should handle API error', async () => { + const mockError = { + code: 'TmTA01', + error: 'Read error', + message: 'Failed to read allowance', + }; + + (useAccount as Mock).mockReturnValue({ address: mockOwner }); + (useOnchainKit as Mock).mockReturnValue({ chain: { id: 8453 } }); + (getTokenAllowance as Mock).mockResolvedValue(mockError); + + const { result } = renderHook(() => + useTokenAllowance({ + tokens: [mockToken], + spenders: [mockSpender], + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toEqual(mockError); + }); +}); diff --git a/packages/onchainkit/src/transaction/hooks/useTokenAllowance.ts b/packages/onchainkit/src/transaction/hooks/useTokenAllowance.ts new file mode 100644 index 0000000000..30cdecfb6b --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useTokenAllowance.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { useOnchainKit } from '@/useOnchainKit'; +import { getTokenAllowance } from '../utils/getTokenAllowance'; +import type { TokenAllowance } from '../utils/getTokenAllowance'; +import type { APIError } from '@/api/types'; +import type { Address } from 'viem'; + +export type UseTokenAllowanceParams = { + owner?: Address; + tokens?: Address[]; // specific tokens to check + spenders?: Address[]; // specific spenders to check +}; + +export function useTokenAllowance({ + owner: propOwner, + tokens, + spenders, +}: UseTokenAllowanceParams = {}) { + const { address: connectedAddress } = useAccount(); + const { chain } = useOnchainKit(); + + const owner = propOwner || connectedAddress; + + const [allowances, setAllowances] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchAllowances = useCallback(async () => { + if (!owner || !tokens || tokens.length === 0) { + setAllowances([]); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + const results: TokenAllowance[] = []; + const errors: APIError[] = []; + + // If no spenders specified, we can't query all (would need indexer) + // For now, require spenders or return empty + if (!spenders || spenders.length === 0) { + setAllowances([]); + setIsLoading(false); + return; + } + + for (const token of tokens) { + for (const spender of spenders!) { + const result = await getTokenAllowance({ + owner, + token, + spender, + chainId: chain.id, + }); + + if ('code' in result) { + errors.push(result as APIError); + } else { + results.push(result); + } + } + } + + setAllowances(results); + if (errors.length > 0) { + setError(errors[0]); // Return first error + } + setIsLoading(false); + }, [owner, tokens, spenders, chain.id]); + + useEffect(() => { + fetchAllowances(); + }, [fetchAllowances]); + + return { + allowances, + isLoading, + error, + refetch: fetchAllowances, + }; +} diff --git a/packages/onchainkit/src/transaction/utils/getTokenAllowance.ts b/packages/onchainkit/src/transaction/utils/getTokenAllowance.ts new file mode 100644 index 0000000000..65fe405615 --- /dev/null +++ b/packages/onchainkit/src/transaction/utils/getTokenAllowance.ts @@ -0,0 +1,54 @@ +import { type Address, erc20Abi } from 'viem'; +import { readContract } from '@wagmi/core'; +import type { APIError } from '@/api/types'; +import { buildErrorStruct } from '@/api/utils/buildErrorStruct'; +import { ApiErrorCode } from '@/api/constants'; + +export type TokenAllowance = { + token: Address; + tokenName?: string; + tokenSymbol?: string; + spender: Address; + amount: string; + isInfinite: boolean; +}; + +export type GetTokenAllowanceParams = { + owner: Address; + token: Address; + spender: Address; + chainId?: number; +}; + +export async function getTokenAllowance({ + owner, + token, + spender, + chainId, +}: GetTokenAllowanceParams): Promise { + try { + const amount = await readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [owner, spender], + chainId, + }); + + const MAX_UINT256 = '115792089237316195423570985008687907853269984665640564039457584007913129639935'; + const isInfinite = amount.toString() === MAX_UINT256; + + return { + token, + spender, + amount: amount.toString(), + isInfinite, + }; + } catch (error) { + return buildErrorStruct({ + code: ApiErrorCode.AMGTa02, + error: JSON.stringify(error), + message: `Failed to read allowance for ${token}`, + }); + } +}