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
117 changes: 117 additions & 0 deletions packages/onchainkit/src/transaction/components/SecurityCheck.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { render, waitFor } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { SecurityCheck } from './SecurityCheck';
import { useTransactionContext } from './TransactionProvider';

vi.mock('./TransactionProvider', () => ({
useTransactionContext: vi.fn(),
}));

describe('SecurityCheck', () => {
beforeEach(() => {
vi.resetAllMocks();
global.fetch = vi.fn();
});

it('should show loading state when checking', () => {
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0x1234567890123456789012345678901234567890' }],
});

const mockFetch = vi.fn(() => new Promise(() => {})); // never resolves
global.fetch = mockFetch;

const { getByTestId } = render(<SecurityCheck />);
expect(getByTestId('ock-security-check-loading')).toBeInTheDocument();
});

it('should show verified contract as safe', async () => {
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0x1234567890123456789012345678901234567890' }],
});

const mockFetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({
status: '1',
message: 'OK',
result: [{
ABI: '[{"type":"function"}]',
SourceCode: 'pragma solidity ^0.8.0;',
ContractName: 'MyToken',
Proxy: '0',
Implementation: '',
}],
}),
})
);
global.fetch = mockFetch;

const { getByText } = render(<SecurityCheck />);

await waitFor(() => expect(getByText('Contract verified')).toBeInTheDocument());
});

it('should show unverified contract as danger', async () => {
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0x1234567890123456789012345678901234567890' }],
});

const mockFetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({
status: '1',
message: 'OK',
result: [{
ABI: 'Contract source code not verified',
SourceCode: '',
ContractName: '',
Proxy: '0',
Implementation: '',
}],
}),
})
);
global.fetch = mockFetch;

const { getByText } = render(<SecurityCheck />);

await waitFor(() => expect(getByText('Contract not verified')).toBeInTheDocument());
});

it('should show proxy contract warning', async () => {
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0x1234567890123456789012345678901234567890' }],
});

const mockFetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({
status: '1',
message: 'OK',
result: [{
ABI: '[{"type":"function"}]',
SourceCode: 'pragma solidity ^0.8.0;',
ContractName: 'Proxy',
Proxy: '1',
Implementation: '0x9999999999999999999999999999999999999999',
}],
}),
})
);
global.fetch = mockFetch;

const { getByText } = render(<SecurityCheck />);

await waitFor(() => expect(getByText('Proxy contract')).toBeInTheDocument());
});

it('should return null when no calls', () => {
(useTransactionContext as Mock).mockReturnValue({
calls: [],
});

const { container } = render(<SecurityCheck />);
expect(container.firstChild).toBeNull();
});
});
169 changes: 169 additions & 0 deletions packages/onchainkit/src/transaction/components/SecurityCheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use client';

import { useCallback, useEffect, useState } from 'react';
import { type Address } from 'viem';
import { useTransactionContext } from './TransactionProvider';
import { cn, text, border, pressable } from '@/styles/theme';

type SecurityStatus = {
label: string;
status: 'safe' | 'warning' | 'danger';
detail?: string;
};

type ContractVerificationData = {
isVerified: boolean;
isProxy: boolean;
proxyTarget: string | null;
risk: 'low' | 'medium' | 'high';
warnings: string[];
};

const BASESCAN_API_URL = 'https://api.basescan.org/api';

async function fetchContractVerification(address: string): Promise<ContractVerificationData | null> {
try {
const params = new URLSearchParams({
module: 'contract',
action: 'getsourcecode',
address,
});
const res = await fetch(`${BASESCAN_API_URL}?${params}`);
const data = await res.json();

if (data.status !== '1' || !data.result?.[0]) return null;

const info = data.result[0];
const isVerified = info.ABI !== 'Contract source code not verified';
const isProxy = info.Proxy === '1';

const warnings: string[] = [];
let risk: 'low' | 'medium' | 'high' = 'low';

if (!isVerified) {
risk = 'high';
warnings.push('Contract not verified');
}
if (isProxy && !info.Implementation) {
risk = 'high';
warnings.push('Proxy without implementation');
}

return {
isVerified,
isProxy,
proxyTarget: isProxy ? info.Implementation : null,
risk,
warnings,
};
} catch {
return null;
}
}

export function SecurityCheck({ className }: { className?: string }) {
const { calls } = useTransactionContext();
const [checks, setChecks] = useState<SecurityStatus[]>([]);
const [isLoading, setIsLoading] = useState(false);

const runChecks = useCallback(async () => {
if (!calls || calls.length === 0) {
setChecks([]);
return;
}

setIsLoading(true);
const results: SecurityStatus[] = [];

// Extract contract addresses from calls
const addresses = calls
.map((call) => call.to)
.filter((addr): addr is Address => !!addr);

// Remove duplicates
const uniqueAddresses = [...new Set(addresses)];

for (const address of uniqueAddresses) {
const verification = await fetchContractVerification(address);

if (!verification) {
results.push({
label: 'Contract check failed',
status: 'warning',
detail: `Could not verify ${address.slice(0, 6)}...${address.slice(-4)}`,
});
continue;
}

if (verification.isVerified) {
results.push({
label: 'Contract verified',
status: 'safe',
detail: `${address.slice(0, 6)}...${address.slice(-4)}`,
});
} else {
results.push({
label: 'Contract not verified',
status: 'danger',
detail: `${address.slice(0, 6)}...${address.slice(-4)} — ${verification.warnings[0]}`,
});
}

if (verification.isProxy) {
results.push({
label: 'Proxy contract',
status: verification.proxyTarget ? 'warning' : 'danger',
detail: verification.proxyTarget
? `Implementation: ${verification.proxyTarget.slice(0, 6)}...${verification.proxyTarget.slice(-4)}`
: 'Implementation unknown',
});
}
}

setChecks(results);
setIsLoading(false);
}, [calls]);

useEffect(() => {
runChecks();
}, [runChecks]);

if (isLoading) {
return (
<div className={cn('flex items-center gap-2 py-2', className)} data-testid="ock-security-check-loading">
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
<span className={cn(text.label2)}>Checking contract security...</span>
</div>
);
}

if (checks.length === 0) return null;

return (
<div className={cn('flex flex-col gap-2 py-2', className)} data-testid="ock-security-check">
{checks.map((check, i) => (
<div
key={i}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md',
check.status === 'safe' && 'bg-green-50 text-green-700',
check.status === 'warning' && 'bg-yellow-50 text-yellow-700',
check.status === 'danger' && 'bg-red-50 text-red-700',
)}
>
<span className="text-lg">
{check.status === 'safe' && '✅'}
{check.status === 'warning' && '⚠️'}
{check.status === 'danger' && '🔴'}
</span>
<div className="flex flex-col">
<span className={cn(text.label2, 'font-medium')}>{check.label}</span>
{check.detail && (
<span className={cn(text.body, 'opacity-75')}>{check.detail}</span>
)}
</div>
</div>
))}
</div>
);
}