Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions packages/onchainkit/src/transaction/hooks/useRevokeAllowance.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
112 changes: 112 additions & 0 deletions packages/onchainkit/src/transaction/hooks/useTokenAllowance.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
84 changes: 84 additions & 0 deletions packages/onchainkit/src/transaction/hooks/useTokenAllowance.ts
Original file line number Diff line number Diff line change
@@ -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<TokenAllowance[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<APIError | null>(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,
};
}
54 changes: 54 additions & 0 deletions packages/onchainkit/src/transaction/utils/getTokenAllowance.ts
Original file line number Diff line number Diff line change
@@ -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<TokenAllowance | APIError> {
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}`,
});
}
}