diff --git a/packages/onchainkit/src/transaction/hooks/useWalletRisk.test.ts b/packages/onchainkit/src/transaction/hooks/useWalletRisk.test.ts new file mode 100644 index 0000000000..f55edadfd7 --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useWalletRisk.test.ts @@ -0,0 +1,110 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOnchainKit } from '@/useOnchainKit'; +import { useWalletRisk } from './useWalletRisk'; + +vi.mock('@/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + +vi.mock('../utils/getWalletRisk', () => ({ + getWalletRisk: vi.fn(), +})); + +import { getWalletRisk } from '../utils/getWalletRisk'; + +describe('useWalletRisk', () => { + const mockAddress = '0x1234567890123456789012345678901234567890'; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return default state when no address', () => { + (useOnchainKit as Mock).mockReturnValue({ apiKey: null }); + + const { result } = renderHook(() => useWalletRisk()); + + expect(result.current.risk).toBe('low'); + expect(result.current.flags).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + + it('should analyze new wallet (high risk)', async () => { + const mockRisk = { + risk: 'high' as const, + flags: ['new_wallet', 'never_received'], + isContract: false, + txCount: 0, + }; + + (useOnchainKit as Mock).mockReturnValue({ apiKey: null }); + (getWalletRisk as Mock).mockResolvedValue(mockRisk); + + const { result } = renderHook(() => useWalletRisk({ address: mockAddress })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.risk).toBe('high'); + expect(result.current.flags).toContain('new_wallet'); + expect(result.current.isContract).toBe(false); + }); + + it('should analyze active wallet (low risk)', async () => { + const mockRisk = { + risk: 'low' as const, + flags: [], + isContract: false, + txCount: 150, + firstTxDate: '2023-01-01T00:00:00Z', + lastTxDate: '2024-01-01T00:00:00Z', + }; + + (useOnchainKit as Mock).mockReturnValue({ apiKey: 'test-key' }); + (getWalletRisk as Mock).mockResolvedValue(mockRisk); + + const { result } = renderHook(() => useWalletRisk({ address: mockAddress })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.risk).toBe('low'); + expect(result.current.txCount).toBe(150); + expect(getWalletRisk).toHaveBeenCalledWith(mockAddress, 'test-key'); + }); + + it('should detect contract wallet', async () => { + const mockRisk = { + risk: 'medium' as const, + flags: ['smart_contract'], + isContract: true, + txCount: 50, + }; + + (useOnchainKit as Mock).mockReturnValue({ apiKey: null }); + (getWalletRisk as Mock).mockResolvedValue(mockRisk); + + const { result } = renderHook(() => useWalletRisk({ address: mockAddress })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isContract).toBe(true); + expect(result.current.flags).toContain('smart_contract'); + }); + + it('should handle API error', async () => { + const mockError = { + code: 'TmWR01', + error: 'API Error', + message: 'Failed to analyze wallet', + }; + + (useOnchainKit as Mock).mockReturnValue({ apiKey: null }); + (getWalletRisk as Mock).mockResolvedValue(mockError); + + const { result } = renderHook(() => useWalletRisk({ address: mockAddress })); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toEqual(mockError); + }); +}); diff --git a/packages/onchainkit/src/transaction/hooks/useWalletRisk.ts b/packages/onchainkit/src/transaction/hooks/useWalletRisk.ts new file mode 100644 index 0000000000..23002699e8 --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useWalletRisk.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useOnchainKit } from '@/useOnchainKit'; +import { getWalletRisk } from '../utils/getWalletRisk'; +import type { WalletRisk } from '../utils/getWalletRisk'; +import type { APIError } from '@/api/types'; +import type { Address } from 'viem'; + +export type UseWalletRiskParams = { + address?: Address; +}; + +export function useWalletRisk({ + address, +}: UseWalletRiskParams = {}) { + const { apiKey } = useOnchainKit(); + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const analyze = useCallback(async () => { + if (!address) { + setData(null); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + const result = await getWalletRisk(address, apiKey || undefined); + + if ('code' in result) { + setError(result as APIError); + setData(null); + } else { + setData(result); + } + + setIsLoading(false); + }, [address, apiKey]); + + useEffect(() => { + analyze(); + }, [analyze]); + + return { + risk: data?.risk ?? 'low', + flags: data?.flags ?? [], + isContract: data?.isContract ?? false, + txCount: data?.txCount ?? 0, + firstTxDate: data?.firstTxDate ?? null, + lastTxDate: data?.lastTxDate ?? null, + isLoading, + error, + refresh: analyze, + }; +} diff --git a/packages/onchainkit/src/transaction/utils/getWalletRisk.ts b/packages/onchainkit/src/transaction/utils/getWalletRisk.ts new file mode 100644 index 0000000000..d4aa27a968 --- /dev/null +++ b/packages/onchainkit/src/transaction/utils/getWalletRisk.ts @@ -0,0 +1,138 @@ +import { type Address, isAddress } from 'viem'; +import type { APIError } from '@/api/types'; +import { buildErrorStruct } from '@/api/utils/buildErrorStruct'; +import { ApiErrorCode } from '@/api/constants'; + +const BASESCAN_API_URL = 'https://api.basescan.org/api'; + +export type WalletRisk = { + risk: 'low' | 'medium' | 'high'; + flags: string[]; + isContract: boolean; + txCount: number; + firstTxDate?: string; + lastTxDate?: string; + totalReceived?: string; + totalSent?: string; +}; + +type BaseScanTxResponse = { + status: string; + message: string; + result: Array<{ + hash: string; + timeStamp: string; + from: string; + to: string; + value: string; + txreceipt_status: string; + }>; +}; + +export async function getWalletRisk( + address: string, + apiKey?: string, +): Promise { + if (!isAddress(address)) { + return buildErrorStruct({ + code: ApiErrorCode.AMGTa01, + error: 'Invalid address', + message: 'Address must be a valid Ethereum address', + }); + } + + try { + // Fetch transaction history + const params = new URLSearchParams({ + module: 'account', + action: 'txlist', + address, + startblock: '0', + endblock: '99999999', + sort: 'asc', + ...(apiKey && { apikey: apiKey }), + }); + + const response = await fetch(`${BASESCAN_API_URL}?${params}`); + const data: BaseScanTxResponse = await response.json(); + + const flags: string[] = []; + let risk: 'low' | 'medium' | 'high' = 'low'; + + // Analyze transaction history + if (data.status !== '1' || !data.result) { + flags.push('unknown_history'); + risk = 'medium'; + } else { + const txs = data.result; + + if (txs.length === 0) { + flags.push('new_wallet'); + flags.push('never_received'); + risk = 'high'; + } else { + const received = txs.filter(tx => tx.to.toLowerCase() === address.toLowerCase()); + const sent = txs.filter(tx => tx.from.toLowerCase() === address.toLowerCase()); + + if (received.length === 0) { + flags.push('never_received'); + risk = 'high'; + } + + if (txs.length < 5) { + flags.push('low_activity'); + risk = Math.max(risk === 'low' ? 0 : risk === 'medium' ? 1 : 2, 1) as any; + } + + // Check for high volume (potential mixer/scam) + const totalVolume = txs.reduce((sum, tx) => sum + BigInt(tx.value), 0n); + if (totalVolume > parseEther('1000')) { + flags.push('high_volume'); + } + } + } + + // Check if contract + const isContract = await checkIsContract(address); + + return { + risk, + flags, + isContract, + txCount: data.result?.length ?? 0, + firstTxDate: data.result?.[0]?.timeStamp + ? new Date(parseInt(data.result[0].timeStamp) * 1000).toISOString() + : undefined, + lastTxDate: data.result?.[data.result.length - 1]?.timeStamp + ? new Date(parseInt(data.result[data.result.length - 1].timeStamp) * 1000).toISOString() + : undefined, + }; + } catch (error) { + return buildErrorStruct({ + code: ApiErrorCode.AMGTa02, + error: JSON.stringify(error), + message: 'Failed to analyze wallet risk', + }); + } +} + +async function checkIsContract(address: string): Promise { + try { + const params = new URLSearchParams({ + module: 'contract', + action: 'getabi', + address, + }); + + const response = await fetch(`${BASESCAN_API_URL}?${params}`); + const data = await response.json(); + + return data.status === '1' && data.result !== 'Contract source code not verified'; + } catch { + return false; + } +} + +function parseEther(value: string): bigint { + return BigInt(Math.floor(parseFloat(value) * 1e18)); +}