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
110 changes: 110 additions & 0 deletions packages/onchainkit/src/transaction/hooks/useWalletRisk.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 58 additions & 0 deletions packages/onchainkit/src/transaction/hooks/useWalletRisk.ts
Original file line number Diff line number Diff line change
@@ -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<WalletRisk | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<APIError | null>(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,
};
}
138 changes: 138 additions & 0 deletions packages/onchainkit/src/transaction/utils/getWalletRisk.ts
Original file line number Diff line number Diff line change
@@ -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<WalletRisk | APIError> {
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<boolean> {
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));
}