diff --git a/src/components/TransactionConfirmation.tsx b/src/components/TransactionConfirmation.tsx
index f65628ad..2532d84a 100644
--- a/src/components/TransactionConfirmation.tsx
+++ b/src/components/TransactionConfirmation.tsx
@@ -457,10 +457,12 @@ export const TransactionConfirmation: React.FC<
)}
- {validation.riskScore >= 50 ? (
+ {!validation.riskVerified ? (
+
+ ) : validation.riskScore >= 50 ? (
@@ -472,18 +474,22 @@ export const TransactionConfirmation: React.FC<
Security Assessment
- {getRiskLevelText(validation.riskScore)}
+ {validation.riskVerified
+ ? getRiskLevelText(validation.riskScore)
+ : "Not verified"}
- Risk Score: {validation.riskScore}/100
+ {validation.riskVerified
+ ? `Risk Score: ${validation.riskScore}/100`
+ : "Address risk screening is unavailable, so no risk score is available. Verify the recipient address manually."}
diff --git a/src/hooks/useSecurity.ts b/src/hooks/useSecurity.ts
index 0e9a5de3..8fc1c3c7 100644
--- a/src/hooks/useSecurity.ts
+++ b/src/hooks/useSecurity.ts
@@ -29,6 +29,11 @@ export interface TransactionValidation {
warnings: string[];
blocks: string[];
requiresConfirmation: boolean;
+ /**
+ * False when address-risk screening could not run. The UI must then show an
+ * explicit "not verified" state rather than presenting riskScore as fact.
+ */
+ riskVerified: boolean;
details: any;
}
@@ -93,6 +98,9 @@ export const useSecurity = () => {
// Blockchain security check
const addressRisk = await blockchainSecurity.checkAddressRisk(walletAddress);
+ if (!addressRisk.verified) {
+ warnings.push('Address risk screening unavailable - address could not be verified');
+ }
if (addressRisk.riskLevel === 'critical') {
blocks.push('Address has critical security risk');
} else if (addressRisk.riskLevel === 'high') {
@@ -142,6 +150,7 @@ export const useSecurity = () => {
warnings: ['Wallet not connected'],
blocks: ['Wallet must be connected'],
requiresConfirmation: false,
+ riskVerified: false,
details: null
};
}
@@ -162,6 +171,7 @@ export const useSecurity = () => {
warnings,
blocks,
requiresConfirmation: false,
+ riskVerified: false,
details: null
};
}
@@ -208,6 +218,7 @@ export const useSecurity = () => {
warnings,
blocks,
requiresConfirmation,
+ riskVerified: securityValidation.verified,
details: {
transactionValidation,
securityValidation,
@@ -224,6 +235,7 @@ export const useSecurity = () => {
warnings,
blocks,
requiresConfirmation: false,
+ riskVerified: false,
details: null
};
}
diff --git a/src/utils/security/__tests__/blockchainSecurity.test.ts b/src/utils/security/__tests__/blockchainSecurity.test.ts
index 4c3c5d48..ba3b2f86 100644
--- a/src/utils/security/__tests__/blockchainSecurity.test.ts
+++ b/src/utils/security/__tests__/blockchainSecurity.test.ts
@@ -1,9 +1,17 @@
import { BlockchainSecurityService, SecurityServiceConfig } from '../blockchainSecurity';
+// Mock the canonical logger so importing blockchainSecurity does not pull in the
+// logger → csrfClient → walletStore → chains module graph (which needs browser globals).
jest.mock('@/utils/logger', () => ({
- logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ },
}));
+// Mock fetch for API calls
global.fetch = jest.fn();
const mockConfig: SecurityServiceConfig = {
@@ -31,65 +39,204 @@ describe('BlockchainSecurityService', () => {
});
describe('checkAddressRisk', () => {
- const address = '0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45';
+ const testAddress = '0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45';
+
+ it('should return cached result when available and not expired', async () => {
+ const cachedResult = {
+ address: testAddress,
+ riskScore: 25,
+ riskLevel: 'low' as const,
+ categories: ['low_risk'],
+ labels: ['monitor'],
+ description: 'Address appears to have normal activity',
+ verified: true
+ };
+
+ // Manually set cache
+ service['cache'].set(`address_${testAddress}`, {
+ data: cachedResult,
+ timestamp: Date.now()
+ });
+
+ const result = await service.checkAddressRisk(testAddress);
+ expect(result).toEqual(cachedResult);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('should fetch new data when cache is expired', async () => {
+ const cachedResult = {
+ address: testAddress,
+ riskScore: 25,
+ riskLevel: 'low' as const,
+ categories: ['low_risk'],
+ labels: ['monitor'],
+ description: 'Address appears to have normal activity',
+ verified: true
+ };
+
+ // Set expired cache (5 minutes + 1 second ago)
+ const expiredTime = Date.now() - (5 * 60 * 1000) - 1000;
+ service['cache'].set(`address_${testAddress}`, {
+ data: cachedResult,
+ timestamp: expiredTime
+ });
it('returns risk data with level and score', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
- json: async () => ({ risk_score: 30, categories: ['low_risk'], labels: ['monitor'], description: 'Clean' }),
+ /** Mock proxy JSON response with a verified numeric score. */
+ json: async () => ({ risk_score: 50, categories: ['medium_risk'] })
});
- const result = await service.checkAddressRisk(address);
- expect(result).toHaveProperty('address', address);
- expect(result).toHaveProperty('riskScore');
- expect(result).toHaveProperty('riskLevel');
- expect(['low', 'medium', 'high', 'critical']).toContain(result.riskLevel);
- expect(result.riskScore).toBeGreaterThanOrEqual(0);
- expect(result.riskScore).toBeLessThanOrEqual(100);
+ const result = await service.checkAddressRisk(testAddress);
+ expect(fetch).toHaveBeenCalled();
+ expect(result.riskScore).toBeGreaterThan(0);
+ expect(result.verified).toBe(true);
});
it('caches results for 5 minutes', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
- json: async () => ({ risk_score: 20, categories: [], labels: [], description: '' }),
+ /** Mock proxy JSON response with a verified numeric score. */
+ json: async () => ({ risk_score: 30, categories: ['low_risk'], labels: [], description: 'Normal' })
});
await service.checkAddressRisk(address);
expect(global.fetch).toHaveBeenCalledTimes(1);
- const result2 = await service.checkAddressRisk(address);
- expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(result2.riskScore).toBe(20);
+ it('should return an unverified result when proxy returns non-ok', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: false,
+ status: 502,
+ /** Mock proxy JSON error response. */
+ json: async () => ({ error: 'Bad gateway' })
+ });
- jest.advanceTimersByTime(5 * 60 * 1000 - 1);
- await service.checkAddressRisk(address);
- expect(global.fetch).toHaveBeenCalledTimes(1);
+ const result = await service.checkAddressRisk(testAddress);
+ expect(result.verified).toBe(false);
+ expect(result.categories).toEqual(['unknown']);
+ expect(result.labels).toEqual(['unable_to_verify']);
+ });
+
+ it('should return an unverified result when proxy response has no numeric score', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ /** Mock proxy JSON response missing a numeric score. */
+ json: async () => ({ categories: ['low_risk'] })
+ });
+
+ const result = await service.checkAddressRisk(testAddress);
+ expect(result.verified).toBe(false);
+ expect(result.categories).toEqual(['unknown']);
+ });
+
+ it('should return an unverified result when proxy explicitly reports verified:false', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ /** Mock proxy JSON response explicitly reporting unverified. */
+ json: async () => ({ risk_score: 10, verified: false })
+ });
+
+ const result = await service.checkAddressRisk(testAddress);
+ expect(result.verified).toBe(false);
});
it('uses simulated data when API fails', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
- const result = await service.checkAddressRisk(address);
- expect(result).toHaveProperty('riskScore');
- expect(result.riskScore).toBeGreaterThanOrEqual(0);
- expect(result.riskScore).toBeLessThanOrEqual(100);
- expect(result.categories).toBeDefined();
- expect(result.labels).toBeDefined();
+ const result = await service.checkAddressRisk(testAddress);
+ expect(result).toEqual({
+ address: testAddress,
+ riskScore: 50,
+ riskLevel: 'medium',
+ categories: ['unknown'],
+ labels: ['unable_to_verify'],
+ description: 'Unable to verify address risk due to service unavailability',
+ verified: false
+ });
});
- it('uses simulated data when API returns non-ok', async () => {
- (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500 });
+ it('should map proxy risk scores to risk levels and mark verified', async () => {
+ // Mock different risk scores
+ const testCases = [
+ { score: 10, expectedLevel: 'low' },
+ { score: 40, expectedLevel: 'medium' },
+ { score: 60, expectedLevel: 'high' },
+ { score: 85, expectedLevel: 'critical' }
+ ];
+
+ for (const { score, expectedLevel } of testCases) {
+ // Clear cache and mock the proxy to return specific score
+ service.clearCache();
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ /** Mock proxy JSON response with a verified score for the test case. */
+ json: async () => ({ risk_score: score, categories: [`${expectedLevel}_risk`] })
+ });
+
+ const result = await service.checkAddressRisk(testAddress);
+ expect(result.riskLevel).toBe(expectedLevel);
+ expect(result.verified).toBe(true);
+ }
+ });
+ });
- const result = await service.checkAddressRisk(address);
- expect(result).toHaveProperty('riskScore');
- expect(result.riskScore).toBeGreaterThanOrEqual(0);
+ describe('checkTransactionRisk', () => {
+ const testHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
+
+ it('should return cached transaction risk when available', async () => {
+ const cachedResult = {
+ hash: testHash,
+ riskScore: 30,
+ riskLevel: 'medium' as const,
+ alerts: ['Test alert'],
+ sanctions: false,
+ mixer: false,
+ gambling: false,
+ scam: false,
+ verified: true
+ };
+
+ service['cache'].set(`tx_${testHash}`, {
+ data: cachedResult,
+ timestamp: Date.now()
+ });
+
+ const result = await service.checkTransactionRisk(testHash);
+ expect(result).toEqual(cachedResult);
+ });
+
+ it('should return an unverified default (no fabricated score from the hash)', async () => {
+ const result = await service.checkTransactionRisk(testHash);
+ expect(result).toEqual({
+ hash: testHash,
+ riskScore: 50,
+ riskLevel: 'medium',
+ alerts: ['Unable to verify transaction risk'],
+ sanctions: false,
+ mixer: false,
+ gambling: false,
+ scam: false,
+ verified: false
+ });
});
});
- describe('validateTransaction', () => {
- const from = '0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45';
- const to = '0x1234567890123456789012345678901234567890';
- const value = '1000000000000000000';
+ describe('checkSanctions', () => {
+ it('should return true when address is sanctioned', async () => {
+ jest.spyOn(service, 'checkAddressRisk').mockResolvedValueOnce({
+ address: '0x123',
+ riskScore: 90,
+ riskLevel: 'critical',
+ categories: ['sanctions'],
+ labels: [],
+ description: 'Sanctioned',
+ verified: true
+ });
+
+ const result = await service.checkSanctions('0x123');
+ expect(result).toBe(true);
+ });
it('returns valid for clean transaction', async () => {
jest.spyOn(service, 'checkAddressRisk').mockResolvedValue({
@@ -99,11 +246,35 @@ describe('BlockchainSecurityService', () => {
categories: ['low_risk'],
labels: [],
description: 'Clean',
+ verified: true
});
- const result = await service.validateTransaction(from, to, value);
- expect(result.isValid).toBe(true);
- expect(result.blocks).toHaveLength(0);
+ const result = await service.checkSanctions('0x123');
+ expect(result).toBe(false);
+ });
+
+ it('should return false on error', async () => {
+ jest.spyOn(service, 'checkAddressRisk').mockRejectedValueOnce(new Error('API Error'));
+
+ const result = await service.checkSanctions('0x123');
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('checkMixer', () => {
+ it('should return true when address is associated with mixer', async () => {
+ jest.spyOn(service, 'checkAddressRisk').mockResolvedValueOnce({
+ address: '0x123',
+ riskScore: 70,
+ riskLevel: 'high',
+ categories: ['mixer'],
+ labels: [],
+ description: 'Mixer',
+ verified: true
+ });
+
+ const result = await service.checkMixer('0x123');
+ expect(result).toBe(true);
});
it('blocks sanctioned addresses', async () => {
@@ -113,13 +284,39 @@ describe('BlockchainSecurityService', () => {
riskLevel: 'low',
categories: ['sanctions'],
labels: [],
- description: 'Sanctioned',
+ description: 'Clean',
+ verified: true
});
- const result = await service.validateTransaction(from, to, value);
- expect(result.isValid).toBe(false);
- expect(result.blocks).toContain('Sender address is on sanctions list');
- expect(result.blocks).toContain('Recipient address is on sanctions list');
+ const result = await service.checkMixer('0x123');
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('getSecurityAlerts', () => {
+ it('should return security alerts for address', async () => {
+ jest.spyOn(service, 'checkAddressRisk').mockResolvedValueOnce({
+ address: '0x123',
+ riskScore: 80,
+ riskLevel: 'high',
+ categories: ['scam', 'mixer'],
+ labels: ['suspicious'],
+ description: 'High risk address',
+ verified: true
+ });
+
+ const alerts = await service.getSecurityAlerts('0x123');
+ expect(alerts).toHaveLength(2);
+ expect(alerts[0].type).toBe('scam');
+ expect(alerts[0].severity).toBe('high');
+ expect(alerts[1].type).toBe('mixer');
+ });
+
+ it('should return empty array on error', async () => {
+ jest.spyOn(service, 'checkAddressRisk').mockRejectedValueOnce(new Error('API Error'));
+
+ const alerts = await service.getSecurityAlerts('0x123');
+ expect(alerts).toEqual([]);
});
it('warns about mixer interactions', async () => {
@@ -129,12 +326,31 @@ describe('BlockchainSecurityService', () => {
riskLevel: 'low',
categories: ['mixer'],
labels: [],
- description: 'Mixer associated',
- });
+ description: 'Clean address',
+ verified: true
+ }));
const result = await service.validateTransaction(from, to, value);
expect(result.isValid).toBe(true);
- expect(result.warnings).toContain('Transaction involves mixer-associated address');
+ expect(result.riskScore).toBe(10);
+ expect(result.warnings).toHaveLength(0);
+ expect(result.blocks).toHaveLength(0);
+ expect(result.verified).toBe(true);
+ });
+
+ it('should report verified:false when screening did not run', async () => {
+ jest.spyOn(service, 'checkAddressRisk').mockImplementation(async (address) => ({
+ address,
+ riskScore: 50,
+ riskLevel: 'medium',
+ categories: ['unknown'],
+ labels: ['unable_to_verify'],
+ description: 'Unable to verify address risk due to service unavailability',
+ verified: false
+ }));
+
+ const result = await service.validateTransaction(fromAddress, toAddress, value);
+ expect(result.verified).toBe(false);
});
it('blocks critical risk addresses', async () => {
@@ -144,7 +360,8 @@ describe('BlockchainSecurityService', () => {
riskLevel: 'critical' as const,
categories: ['high_risk'],
labels: [],
- description: 'Critical',
+ description: address === fromAddress ? 'Critical risk' : 'Clean',
+ verified: true
}));
const result = await service.validateTransaction(from, to, value);
@@ -160,7 +377,8 @@ describe('BlockchainSecurityService', () => {
riskLevel: 'low',
categories: [],
labels: [],
- description: '',
+ description: 'Sanctioned',
+ verified: true
});
const result = await service.validateTransaction(from, to, '0x1');
@@ -174,10 +392,13 @@ describe('BlockchainSecurityService', () => {
riskLevel: 'low',
categories: [],
labels: [],
- description: '',
- });
+ description: address === toAddress ? 'Risky recipient' : 'Clean sender',
+ verified: true
+ }));
- const result = await service.validateTransaction(from, to, '1000000000000000000');
+ // Value must exceed the 1 ETH threshold for the high-value warning to fire.
+ const highValue = '2000000000000000000'; // 2 ETH
+ const result = await service.validateTransaction(fromAddress, toAddress, highValue);
expect(result.isValid).toBe(true);
});
@@ -188,25 +409,21 @@ describe('BlockchainSecurityService', () => {
riskLevel: 'low',
categories: [],
labels: [],
- description: '',
+ description: 'Mixer',
+ verified: true
});
const result = await service.validateTransaction(from, to, '1.5');
expect(result.isValid).toBe(true);
});
- it('handles scientific notation value format', async () => {
- jest.spyOn(service, 'checkAddressRisk').mockResolvedValue({
- address: 'test',
- riskScore: 5,
- riskLevel: 'low',
- categories: [],
- labels: [],
- description: '',
- });
+ it('should handle validation errors gracefully and report unverified', async () => {
+ jest.spyOn(service, 'checkAddressRisk').mockRejectedValue(new Error('API Error'));
- const result = await service.validateTransaction(from, to, '1e18');
- expect(result.isValid).toBe(true);
+ const result = await service.validateTransaction(fromAddress, toAddress, value);
+ expect(result.isValid).toBe(true); // Should not block on errors
+ expect(result.warnings).toContain('Unable to complete security validation');
+ expect(result.verified).toBe(false);
});
});
@@ -232,5 +449,12 @@ describe('BlockchainSecurityService', () => {
await service.checkAddressRisk(address);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
+
+ describe('no simulated checks remain', () => {
+ it('does not expose simulateAddressRiskCheck or simulateTransactionRiskCheck', () => {
+ expect((service as any).simulateAddressRiskCheck).toBeUndefined();
+ expect((service as any).simulateTransactionRiskCheck).toBeUndefined();
+ });
+ });
});
});
diff --git a/src/utils/security/__tests__/transactionMonitor.test.ts b/src/utils/security/__tests__/transactionMonitor.test.ts
new file mode 100644
index 00000000..c99586ff
--- /dev/null
+++ b/src/utils/security/__tests__/transactionMonitor.test.ts
@@ -0,0 +1,57 @@
+import { TransactionMonitor } from '../transactionMonitor';
+
+// Mock the canonical logger to avoid pulling in the logger → walletStore → chains graph.
+jest.mock('@/utils/logger', () => ({
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+describe('TransactionMonitor (honest recipient checks)', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (TransactionMonitor as any).instance = null;
+ });
+
+ it('no longer exposes the placeholder isSuspiciousAddress check', () => {
+ expect((TransactionMonitor.prototype as any).isSuspiciousAddress).toBeUndefined();
+ });
+
+ it('does not fabricate a "flagged in security database" recipient anomaly', () => {
+ const monitor = TransactionMonitor.getInstance();
+ const wallet = '0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45';
+ const now = Date.now();
+
+ for (let i = 0; i < 8; i++) {
+ monitor.addTransaction(wallet, {
+ hash: `0x${i.toString(16)}`,
+ to: `0x000000000000000000000000000000000000000${i}`,
+ value: '1',
+ timestamp: now - 1000 * (8 - i),
+ });
+ }
+
+ const anomalies = monitor.getWalletAnomalies(wallet);
+ const fabricated = anomalies.filter(
+ (a) => a.details && a.details.reason === 'Address flagged in security database'
+ );
+ expect(fabricated).toHaveLength(0);
+ });
+
+ it('still records transactions and produces metrics', () => {
+ const monitor = TransactionMonitor.getInstance();
+ const wallet = '0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45';
+
+ monitor.addTransaction(wallet, {
+ hash: '0x1',
+ to: '0xabc',
+ value: '0',
+ timestamp: Date.now(),
+ });
+
+ expect(monitor.getWalletMetrics(wallet)).not.toBeNull();
+ });
+});
diff --git a/src/utils/security/__tests__/walletValidator.test.ts b/src/utils/security/__tests__/walletValidator.test.ts
index 62b1d294..7a85173d 100644
--- a/src/utils/security/__tests__/walletValidator.test.ts
+++ b/src/utils/security/__tests__/walletValidator.test.ts
@@ -1,5 +1,16 @@
import { WalletValidator, AddressValidationResult } from '../walletValidator';
+// Mock the canonical logger so importing walletValidator does not pull in the
+// logger → csrfClient → walletStore → chains module graph (which needs browser globals).
+jest.mock('@/utils/logger', () => ({
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
// Mock viem functions
jest.mock('viem', () => ({
isAddress: jest.fn(),
@@ -24,9 +35,13 @@ jest.mock('@/lib/viem-client', () => ({
}));
describe('WalletValidator', () => {
+ // Snapshot the static blocklist so tests that mutate it do not leak state.
+ const originalRiskyWallets = [...(WalletValidator as any).RISKY_WALLETS];
+
beforeEach(() => {
jest.clearAllMocks();
-
+ (WalletValidator as any).RISKY_WALLETS = [...originalRiskyWallets];
+
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
@@ -37,6 +52,10 @@ describe('WalletValidator', () => {
});
});
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
describe('validateWalletAddressInput', () => {
const mockPublicClient = require('@/lib/viem-client').publicClient;
const { isAddress, getAddress } = require('viem');
@@ -738,17 +757,28 @@ describe('WalletValidator', () => {
});
describe('hasSimilarityToKnownAddresses', () => {
- it('should return false (placeholder implementation)', () => {
+ it('should return false for addresses not similar to known risky addresses', () => {
const result = (WalletValidator as any).hasSimilarityToKnownAddresses('0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45');
expect(result).toBe(false);
});
+
+ it('should return true for addresses similar to a known risky address', () => {
+ // 0x0000000000000000000000000000000000000001 has >80 % similarity with the null address
+ const result = (WalletValidator as any).hasSimilarityToKnownAddresses('0x0000000000000000000000000000000000000001');
+ expect(result).toBe(true);
+ });
});
describe('isKnownScamContract', () => {
- it('should return false (placeholder implementation)', () => {
+ it('should return false for addresses not in the blocklist', () => {
const result = (WalletValidator as any).isKnownScamContract('0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45');
expect(result).toBe(false);
});
+
+ it('should return true for null address (in blocklist)', () => {
+ const result = (WalletValidator as any).isKnownScamContract('0x0000000000000000000000000000000000000000');
+ expect(result).toBe(true);
+ });
});
describe('isSuspiciousMethod', () => {
diff --git a/src/utils/security/blockchainSecurity.ts b/src/utils/security/blockchainSecurity.ts
index e4ed04e7..d4de4098 100644
--- a/src/utils/security/blockchainSecurity.ts
+++ b/src/utils/security/blockchainSecurity.ts
@@ -1,4 +1,5 @@
import { parseEther } from 'viem';
+import { logger } from '@/utils/logger';
export interface SecurityServiceConfig {
apiKey?: string;
@@ -13,6 +14,12 @@ export interface AddressRiskScore {
categories: string[];
labels: string[];
description: string;
+ /**
+ * True only when the score came from a real screening provider. False when
+ * the provider is unconfigured/unavailable — callers must not present the
+ * score as a real risk signal in that case.
+ */
+ verified: boolean;
}
export interface TransactionRisk {
@@ -24,6 +31,10 @@ export interface TransactionRisk {
mixer: boolean;
gambling: boolean;
scam: boolean;
+ /**
+ * True only when a real transaction-risk provider produced this result.
+ */
+ verified: boolean;
}
export interface SecurityAlert {
@@ -147,7 +158,10 @@ export class BlockchainSecurityService {
}
/**
- * Checks address risk score using Chainalysis-like service
+ * Checks address risk score using a Chainalysis-like service.
+ *
+ * Returns an "unable to verify" result (`verified: false`) whenever the
+ * service is not configured or unavailable, instead of fabricating a score.
*/
async checkAddressRisk(address: string): Promise
{
const cacheKey = `address_${address}`;
@@ -176,7 +190,16 @@ export class BlockchainSecurityService {
if (response && response.ok) {
const body = await response.json();
- const score = typeof body.risk_score === 'number' ? body.risk_score : 50;
+ const score = typeof body.risk_score === 'number' ? body.risk_score : null;
+ const verified = score !== null && body.verified !== false;
+
+ // No trustworthy score: report honestly rather than guessing.
+ if (!verified || score === null) {
+ const result = this.getDefaultRiskScore(address);
+ this.setCache(cacheKey, result);
+ return result;
+ }
+
const categories = Array.isArray(body.categories) ? body.categories : [];
const result: AddressRiskScore = {
address,
@@ -184,24 +207,15 @@ export class BlockchainSecurityService {
riskLevel: this.getRiskLevel(score),
categories,
labels: Array.isArray(body.labels) ? body.labels : [],
- description: body.description || ''
+ description: body.description || '',
+ verified: true
};
this.setCache(cacheKey, result);
return result;
}
- // If the proxy returned an error or is unavailable, fall back to simulated check
- const riskScore = await this.simulateAddressRiskCheck(address);
-
- const result: AddressRiskScore = {
- address,
- riskScore: riskScore.score,
- riskLevel: this.getRiskLevel(riskScore.score),
- categories: riskScore.categories,
- labels: riskScore.labels,
- description: riskScore.description
- };
-
+ // Proxy unavailable: no verified risk data exists, so report honestly.
+ const result = this.getDefaultRiskScore(address);
this.setCache(cacheKey, result);
return result;
@@ -212,35 +226,19 @@ export class BlockchainSecurityService {
}
/**
- * Checks transaction risk
+ * Checks transaction risk.
+ *
+ * No real transaction-risk data source is integrated yet, so this returns the
+ * honest "unable to verify" result instead of deriving a score from the hash.
*/
async checkTransactionRisk(hash: string): Promise {
const cacheKey = `tx_${hash}`;
const cached = this.getFromCache(cacheKey);
if (cached) return cached;
- try {
- // Simulate transaction risk check
- const riskData = await this.simulateTransactionRiskCheck(hash);
-
- const result: TransactionRisk = {
- hash,
- riskScore: riskData.score,
- riskLevel: this.getRiskLevel(riskData.score),
- alerts: riskData.alerts,
- sanctions: riskData.sanctions,
- mixer: riskData.mixer,
- gambling: riskData.gambling,
- scam: riskData.scam
- };
-
- this.setCache(cacheKey, result);
- return result;
-
- } catch (error) {
- logger.error('Failed to check transaction risk:', error);
- return this.getDefaultTransactionRisk(hash);
- }
+ const result = this.getDefaultTransactionRisk(hash);
+ this.setCache(cacheKey, result);
+ return result;
}
/**
@@ -306,14 +304,17 @@ export class BlockchainSecurityService {
riskScore: number;
warnings: string[];
blocks: string[];
+ verified: boolean;
}> {
const warnings: string[] = [];
const blocks: string[] = [];
let totalRiskScore = 0;
+ let allVerified = true;
try {
// Check sender risk — use the highest risk score seen across all checks
const senderRisk = await this.checkAddressRisk(from);
+ allVerified = allVerified && senderRisk.verified;
totalRiskScore = Math.max(totalRiskScore, senderRisk.riskScore);
// Critical risk blocks the transaction; high risk only warns
@@ -325,6 +326,7 @@ export class BlockchainSecurityService {
// Check recipient risk independently — both parties must be evaluated
const recipientRisk = await this.checkAddressRisk(to);
+ allVerified = allVerified && recipientRisk.verified;
totalRiskScore = Math.max(totalRiskScore, recipientRisk.riskScore);
if (recipientRisk.riskLevel === 'critical') {
@@ -366,6 +368,7 @@ export class BlockchainSecurityService {
} catch (error) {
logger.error('Failed to validate transaction:', error);
// Degrade gracefully — warn rather than block on service failure
+ allVerified = false;
warnings.push('Unable to complete security validation');
}
@@ -373,95 +376,13 @@ export class BlockchainSecurityService {
isValid: blocks.length === 0,
riskScore: totalRiskScore,
warnings,
- blocks
+ blocks,
+ verified: allVerified
};
}
/**
- * Simulates address risk check (placeholder for real API)
- */
- private async simulateAddressRiskCheck(address: string): Promise<{
- score: number;
- categories: string[];
- labels: string[];
- description: string;
- }> {
- // Simulate API delay
- await new Promise(resolve => setTimeout(resolve, 100));
-
- // Derive a deterministic score from the address hex so the same address
- // always returns the same risk score (useful for testing/demo purposes)
- const addressHash = address.toLowerCase().replace('0x', '');
- // Parse first 8 hex chars as a 32-bit integer, then mod 100 for a 0–99 score
- const score = parseInt(addressHash.slice(0, 8), 16) % 100;
-
- const categories: string[] = [];
- const labels: string[] = [];
-
- // Bucket the score into risk tiers and assign matching categories/labels
- if (score > 80) {
- categories.push('high_risk');
- labels.push('suspicious_activity');
- } else if (score > 60) {
- categories.push('medium_risk');
- labels.push('requires_review');
- } else if (score > 40) {
- categories.push('low_risk');
- labels.push('monitor');
- }
-
- // Heuristic: addresses starting with 0x000 are likely contract addresses
- if (address.startsWith('0x000')) {
- categories.push('contract');
- labels.push('smart_contract');
- }
-
- // Map score quartile to a human-readable description
- const descriptions = [
- 'Address appears to have normal activity',
- 'Address shows some unusual patterns',
- 'Address has elevated risk factors',
- 'Address requires immediate investigation'
- ];
-
- const description = descriptions[Math.floor(score / 25)];
-
- return { score, categories, labels, description };
- }
-
- /**
- * Simulates transaction risk check (placeholder for real API)
- */
- private async simulateTransactionRiskCheck(hash: string): Promise<{
- score: number;
- alerts: string[];
- sanctions: boolean;
- mixer: boolean;
- gambling: boolean;
- scam: boolean;
- }> {
- // Simulate API delay
- await new Promise(resolve => setTimeout(resolve, 100));
-
- const hashShort = hash.slice(2, 10);
- const score = parseInt(hashShort, 16) % 100;
-
- const alerts: string[] = [];
- const sanctions = score > 90;
- const mixer = score > 70 && score < 80;
- const gambling = score > 60 && score < 70;
- const scam = score > 85;
-
- if (sanctions) alerts.push('Transaction involves sanctioned address');
- if (mixer) alerts.push('Transaction involves mixer service');
- if (gambling) alerts.push('Transaction involves gambling service');
- if (scam) alerts.push('Transaction involves known scam address');
-
- return { score, alerts, sanctions, mixer, gambling, scam };
- }
-
- /**
- * Gets default risk score for failed checks
+ * Gets the honest default for checks that could not be performed.
*/
private getDefaultRiskScore(address: string): AddressRiskScore {
return {
@@ -470,12 +391,13 @@ export class BlockchainSecurityService {
riskLevel: 'medium',
categories: ['unknown'],
labels: ['unable_to_verify'],
- description: 'Unable to verify address risk due to service unavailability'
+ description: 'Unable to verify address risk due to service unavailability',
+ verified: false
};
}
/**
- * Gets default transaction risk for failed checks
+ * Gets the honest default for transaction checks that could not be performed.
*/
private getDefaultTransactionRisk(hash: string): TransactionRisk {
return {
@@ -486,7 +408,8 @@ export class BlockchainSecurityService {
sanctions: false,
mixer: false,
gambling: false,
- scam: false
+ scam: false,
+ verified: false
};
}
@@ -600,16 +523,19 @@ export async function checkAddressRiskViaProxy(address: string): Promise tx.to).filter(Boolean));
if (!previousRecipients.has(recipient) && history.length > 5) {
@@ -476,15 +459,6 @@ export class TransactionMonitor {
};
}
- /**
- * Checks if an address is suspicious
- */
- private isSuspiciousAddress(address: string): boolean {
- // This would integrate with external security databases
- // For now, return false as placeholder
- return false;
- }
-
/**
* Formats ether values
*/
diff --git a/src/utils/security/walletValidator.ts b/src/utils/security/walletValidator.ts
index 6ed60527..4d08894c 100644
--- a/src/utils/security/walletValidator.ts
+++ b/src/utils/security/walletValidator.ts
@@ -54,12 +54,6 @@ export class WalletValidator {
// Add more known scam addresses as needed
];
- private static readonly KNOWN_SCAM_ADDRESSES: string[] = [
- // Known scam addresses (to be updated regularly)
- '0x1234567890123456789012345678901234567890', // Example scam address
- // Add more known scam addresses
- ];
-
// Ethereum address regex pattern
private static readonly ETHEREUM_ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/;
@@ -192,8 +186,7 @@ export class WalletValidator {
// Blacklist check
if (checkBlacklist) {
const normalizedAddress = address.toLowerCase();
- if (this.RISKY_WALLETS.includes(normalizedAddress) ||
- this.KNOWN_SCAM_ADDRESSES.includes(normalizedAddress)) {
+ if (this.RISKY_WALLETS.includes(normalizedAddress)) {
isBlacklisted = true;
errors.push('Address is flagged as known scam or compromised');
riskScore += 50;
@@ -456,23 +449,21 @@ export class WalletValidator {
}
/**
- * Checks if address has similarity to known addresses (placeholder implementation)
+ * Detects addresses that are close (edit-distance) to known risky addresses,
+ * catching typosquatting/impersonation attempts against the blocklist.
*/
private static hasSimilarityToKnownAddresses(address: string): boolean {
- // This would need a more sophisticated implementation
- // Could compare against known exchange addresses, project addresses, etc.
- return false;
+ const normalizedAddress = address.toLowerCase();
+ return this.RISKY_WALLETS.some(known =>
+ this.calculateSimilarity(normalizedAddress, known.toLowerCase()) > 0.8
+ );
}
/**
- * Checks if address is a known scam contract (placeholder implementation)
+ * Checks if address is a known scam/compromised contract against the blocklist.
*/
private static isKnownScamContract(address: string): boolean {
- // This would need to be maintained and updated regularly
- const knownScamContracts: string[] = [
- // Add known scam contract addresses
- ];
- return knownScamContracts.includes(address.toLowerCase());
+ return this.RISKY_WALLETS.includes(address.toLowerCase());
}
/**