diff --git a/__mocks__/viem.js b/__mocks__/viem.js
index 4461d3ac..9e93f053 100644
--- a/__mocks__/viem.js
+++ b/__mocks__/viem.js
@@ -1,3 +1,33 @@
+const mockReceipt = {
+ status: 'success',
+ blockNumber: BigInt(18000000),
+ transactionHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
+ blockHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
+ contractAddress: null,
+ cumulativeGasUsed: BigInt(100000),
+ gasUsed: BigInt(50000),
+ logs: [],
+ logsBloom: '0x0000000000000000000000000000000000000000000000000000000000000000',
+ from: '0x0000000000000000000000000000000000000000',
+ to: '0x0000000000000000000000000000000000000000',
+ effectiveGasPrice: BigInt(20000000000),
+ type: 'eip1559',
+};
+
+const mockClient = {
+ getTransactionReceipt: jest.fn().mockRejectedValue(new Error('receipt not found')),
+ waitForTransactionReceipt: jest.fn().mockRejectedValue(new Error('timeout')),
+};
+
module.exports = {
recoverMessageAddress: jest.fn(() => Promise.resolve('0x123')),
+ createPublicClient: jest.fn(() => mockClient),
+ http: jest.fn(() => 'http://mock-transport'),
+ fallback: jest.fn((transports) => transports[0]),
+ isAddress: jest.fn((addr) => /^0x[a-fA-F0-9]{40}$/.test(addr)),
+ getAddress: jest.fn((addr) => addr),
+ isHex: jest.fn(() => true),
+ formatEther: jest.fn((wei) => Number(wei) / 1e18),
+ parseEther: jest.fn((eth) => BigInt(Math.floor(Number(eth) * 1e18))),
+ parseUnits: jest.fn((val, decimals) => BigInt(Number(val) * Math.pow(10, decimals))),
};
\ No newline at end of file
diff --git a/__mocks__/viem/chains.js b/__mocks__/viem/chains.js
index 4c39de3e..8d36f560 100644
--- a/__mocks__/viem/chains.js
+++ b/__mocks__/viem/chains.js
@@ -1,5 +1,8 @@
module.exports = {
mainnet: { id: 1, name: 'Ethereum' },
+ sepolia: { id: 11155111, name: 'Sepolia' },
polygon: { id: 137, name: 'Polygon' },
+ polygonMumbai: { id: 80001, name: 'Polygon Mumbai' },
bsc: { id: 56, name: 'BSC' },
+ bscTestnet: { id: 97, name: 'BSC Testnet' },
};
\ No newline at end of file
diff --git a/package.json b/package.json
index 644b53ae..d169c475 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"lint": "eslint . --max-warnings=0",
"perf:budgets": "node scripts/check-performance-budgets.mjs",
"perf:ci": "npm run build && npm run perf:budgets",
+ "security:check-globals": "node scripts/check-exposed-globals.mjs",
"validate:env": "node scripts/validate-env.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
diff --git a/scripts/check-exposed-globals.mjs b/scripts/check-exposed-globals.mjs
new file mode 100644
index 00000000..aef1dfe0
--- /dev/null
+++ b/scripts/check-exposed-globals.mjs
@@ -0,0 +1,37 @@
+import { readFileSync, existsSync } from 'fs';
+import { glob } from 'glob';
+
+const SENSITIVE_PATTERNS = [
+ /__[A-Z][A-Z_]+__/g,
+];
+
+async function main() {
+ const files = await glob('src/**/*.{ts,tsx,js,jsx}', {
+ ignore: ['src/**/*.test.*', 'src/**/__tests__/**', 'node_modules/**'],
+ });
+
+ let hasError = false;
+
+ for (const file of files) {
+ if (!existsSync(file)) continue;
+ const content = readFileSync(file, 'utf-8');
+ const matches = content.match(SENSITIVE_PATTERNS[0]);
+ if (matches) {
+ for (const match of matches) {
+ console.error(`[FAIL] Found exposed global '${match}' in ${file}`);
+ hasError = true;
+ }
+ }
+ }
+
+ if (hasError) {
+ process.exit(1);
+ }
+
+ console.log('[PASS] No exposed globals found.');
+}
+
+main().catch((err) => {
+ console.error('Script failed:', err);
+ process.exit(1);
+});
diff --git a/src/app/api/security/address-check/route.ts b/src/app/api/security/address-check/route.ts
new file mode 100644
index 00000000..e934afe4
--- /dev/null
+++ b/src/app/api/security/address-check/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const address = searchParams.get('address')?.trim();
+
+ if (!address) {
+ return NextResponse.json({ error: 'Address parameter required' }, { status: 400 });
+ }
+
+ if (!/^0x[a-fA-F0-9]{40}$/.test(address)) {
+ return NextResponse.json({ error: 'Invalid Ethereum address' }, { status: 400 });
+ }
+
+ const apiKey = process.env.CHAINALYSIS_API_KEY;
+
+ if (!apiKey) {
+ return NextResponse.json({
+ address,
+ risk_score: 50,
+ categories: ['unknown'],
+ description: 'Risk check unavailable (service not configured)',
+ });
+ }
+
+ try {
+ const response = await fetch(
+ `https://api.chainalysis.com/api/v2/address/${address}`,
+ {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ signal: AbortSignal.timeout(10000),
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ return NextResponse.json(
+ { error: `Upstream service error: ${response.status}`, detail: errorText },
+ { status: response.status }
+ );
+ }
+
+ const data = await response.json();
+ return NextResponse.json(data);
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Unknown error' },
+ { status: 502 }
+ );
+ }
+}
diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx
index e04abd79..20b00868 100644
--- a/src/components/ui/chart.tsx
+++ b/src/components/ui/chart.tsx
@@ -78,28 +78,27 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
return null
}
- return (
-
}
const ChartTooltip = Tooltip
diff --git a/src/lib/__tests__/batchTransaction.test.ts b/src/lib/__tests__/batchTransaction.test.ts
new file mode 100644
index 00000000..fcf7b2eb
--- /dev/null
+++ b/src/lib/__tests__/batchTransaction.test.ts
@@ -0,0 +1,121 @@
+import type { CartItem } from '@/types/cart';
+
+const mockProperty = (overrides = {}) => ({
+ id: 'prop-1',
+ title: 'Test Property',
+ tokenInfo: { available: 100, price: 0.1 },
+ status: 'active',
+ ...overrides,
+});
+
+const validItem: CartItem = {
+ id: 'item-1',
+ property: mockProperty(),
+ quantity: 1,
+ addedAt: new Date().toISOString(),
+};
+
+describe('BatchTransactionService', () => {
+ const walletAddress = '0x1234567890123456789012345678901234567890';
+
+ beforeEach(() => {
+ delete process.env.NEXT_PUBLIC_DEMO_TX;
+ jest.resetModules();
+ });
+
+ describe('executeBatchPurchase', () => {
+ it('returns validation error when item quantity exceeds available', async () => {
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const overPurchased: CartItem = {
+ ...validItem,
+ property: mockProperty({ tokenInfo: { available: 1, price: 0.1 } }),
+ quantity: 5,
+ };
+
+ const result = await BatchTransactionService.executeBatchPurchase(
+ [overPurchased],
+ walletAddress
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('Validation failed');
+ expect(result.results[0].error).toContain('Insufficient tokens');
+ });
+
+ it('returns validation error when property is inactive', async () => {
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const inactiveItem: CartItem = {
+ ...validItem,
+ property: mockProperty({ status: 'inactive' }),
+ };
+
+ const result = await BatchTransactionService.executeBatchPurchase(
+ [inactiveItem],
+ walletAddress
+ );
+
+ expect(result.success).toBe(false);
+ });
+
+ it('throws when no items provided and catches error', async () => {
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const result = await BatchTransactionService.executeBatchPurchase([], walletAddress);
+
+ expect(result.success).toBe(false);
+ expect(result.results).toHaveLength(0);
+ });
+
+ it('uses demo mode when NEXT_PUBLIC_DEMO_TX is true', async () => {
+ process.env.NEXT_PUBLIC_DEMO_TX = 'true';
+ jest.resetModules();
+
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const result = await BatchTransactionService.executeBatchPurchase(
+ [validItem],
+ walletAddress
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.transactionHash).toMatch(/^0x[a-f0-9]{64}$/);
+ expect(result.totalGasUsed).toBeGreaterThan(0);
+ });
+ });
+
+ describe('estimateGas', () => {
+ it('returns base gas for empty items', async () => {
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const gas = BatchTransactionService.estimateGas([]);
+ expect(gas).toBe(0.005);
+ });
+
+ it('calculates gas proportionally to item count', async () => {
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const gas1 = BatchTransactionService.estimateGas([validItem]);
+ const gas3 = BatchTransactionService.estimateGas([validItem, validItem, validItem]);
+ expect(gas3).toBeGreaterThan(gas1);
+ });
+ });
+
+ describe('getTransactionStatus', () => {
+ it('returns pending when receipt is not available', async () => {
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const result = await BatchTransactionService.getTransactionStatus(
+ '0x0000000000000000000000000000000000000000000000000000000000000000'
+ );
+
+ expect(result.status).toBe('pending');
+ });
+ });
+
+ describe('waitForConfirmation', () => {
+ it('returns timeout when transaction is not found', async () => {
+ const { BatchTransactionService } = await import('../batchTransaction');
+ const result = await BatchTransactionService.waitForConfirmation(
+ '0x0000000000000000000000000000000000000000000000000000000000000000',
+ 100
+ );
+
+ expect(result.status).toBe('timeout');
+ });
+ });
+});
diff --git a/src/lib/batchTransaction.ts b/src/lib/batchTransaction.ts
index 9b66376d..12d33e81 100644
--- a/src/lib/batchTransaction.ts
+++ b/src/lib/batchTransaction.ts
@@ -1,8 +1,10 @@
import type { CartItem } from '@/types/cart';
import type { BatchTransactionResult } from '@/types/cart';
import { logger } from '@/utils/logger';
+import { publicClient } from '@/lib/viem-client';
+
+const DEMO_MODE = process.env.NEXT_PUBLIC_DEMO_TX === 'true';
-// Mock multicall implementation - in production, this would use actual smart contracts
export class BatchTransactionService {
/**
* Execute batch token purchase using multicall
@@ -30,39 +32,14 @@ export class BatchTransactionService {
};
}
- // Simulate blockchain transaction delay
- await new Promise(resolve => setTimeout(resolve, 2000));
-
- // Generate mock transaction hash
- const transactionHash = `0x${Array.from({length: 64}, () =>
- Math.floor(Math.random() * 16).toString(16)).join('')}`;
-
- // Simulate individual transaction results
- const results = items.map(item => ({
- propertyId: item.property.id,
- success: Math.random() > 0.1, // 90% success rate for demo
- transactionHash: Math.random() > 0.1 ? transactionHash : undefined,
- error: Math.random() > 0.1 ? undefined : 'Transaction failed: Insufficient gas'
- }));
-
- const allSuccessful = results.every(result => result.success);
- const totalGasUsed = items.length * 0.0025 + 0.005; // Base gas + per transaction
-
- logger.info('Batch transaction completed', {
- success: allSuccessful,
- transactionHash,
- totalGasUsed,
- itemsProcessed: items.length
- });
-
- return {
- success: allSuccessful,
- transactionHash: allSuccessful ? transactionHash : undefined,
- results,
- totalGasUsed,
- error: allSuccessful ? undefined : 'Some transactions failed'
- };
+ if (DEMO_MODE) {
+ return this.executeDemoBatchPurchase(items);
+ }
+ // In production, this would submit a multicall transaction to the contract
+ // and wait for the receipt using viem's waitForTransactionReceipt.
+ // The actual contract interaction is chain-specific and requires a wallet client.
+ throw new Error('Production batch execution requires a configured wallet client');
} catch (error) {
logger.error('Batch transaction failed:', error);
return {
@@ -77,12 +54,44 @@ export class BatchTransactionService {
}
}
+ private static async executeDemoBatchPurchase(
+ items: CartItem[]
+ ): Promise {
+ // Simulate blockchain transaction delay
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ const transactionHash = `0x${Array.from({length: 64}, () =>
+ Math.floor(Math.random() * 16).toString(16)).join('')}`;
+
+ const results = items.map(item => ({
+ propertyId: item.property.id,
+ success: true,
+ transactionHash,
+ }));
+
+ const totalGasUsed = items.length * 0.0025 + 0.005;
+
+ logger.info('Demo batch transaction completed', {
+ success: true,
+ transactionHash,
+ totalGasUsed,
+ itemsProcessed: items.length
+ });
+
+ return {
+ success: true,
+ transactionHash,
+ results,
+ totalGasUsed,
+ };
+ }
+
/**
* Estimate gas for batch transaction
*/
static estimateGas(items: CartItem[]): number {
- const BASE_GAS = 0.005; // Base gas for batch transaction
- const GAS_PER_TRANSACTION = 0.0025; // Gas per individual transaction
+ const BASE_GAS = 0.005;
+ const GAS_PER_TRANSACTION = 0.0025;
return BASE_GAS + (items.length * GAS_PER_TRANSACTION);
}
@@ -90,77 +99,57 @@ export class BatchTransactionService {
* Validate if purchase can be executed
*/
private static validatePurchase(item: CartItem): boolean {
- return item.quantity > 0 &&
+ return item.quantity > 0 &&
item.quantity <= item.property.tokenInfo.available &&
item.property.status === 'active';
}
/**
- * Get transaction status
+ * Get transaction status using viem publicClient
*/
static async getTransactionStatus(transactionHash: string): Promise<{
status: 'pending' | 'confirmed' | 'failed';
blockNumber?: number;
confirmations?: number;
}> {
- // Mock transaction status check
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // Simulate different statuses
- const random = Math.random();
- if (random < 0.7) {
- return {
- status: 'confirmed',
- blockNumber: Math.floor(Math.random() * 1000000) + 18000000,
- confirmations: Math.floor(Math.random() * 50) + 1
- };
- } else if (random < 0.9) {
- return {
- status: 'pending'
- };
- } else {
+ try {
+ const receipt = await publicClient.getTransactionReceipt({
+ hash: transactionHash as `0x${string}`,
+ });
+
return {
- status: 'failed'
+ status: receipt.status === 'success' ? 'confirmed' : 'failed',
+ blockNumber: Number(receipt.blockNumber),
+ confirmations: 1,
};
+ } catch {
+ return { status: 'pending' };
}
}
/**
- * Wait for transaction confirmation
+ * Wait for transaction confirmation using viem's waitForTransactionReceipt
*/
static async waitForConfirmation(
transactionHash: string,
- maxWaitTime: number = 300000 // 5 minutes
+ maxWaitTime: number = 300000
): Promise<{
status: 'confirmed' | 'failed' | 'timeout';
blockNumber?: number;
confirmations?: number;
}> {
- const startTime = Date.now();
-
- while (Date.now() - startTime < maxWaitTime) {
- const status = await this.getTransactionStatus(transactionHash);
-
- if (status.status === 'confirmed') {
- return {
- status: 'confirmed',
- blockNumber: status.blockNumber,
- confirmations: status.confirmations
- };
- }
-
- if (status.status === 'failed') {
- return {
- status: 'failed'
- };
- }
-
- // Wait 5 seconds before checking again
- await new Promise(resolve => setTimeout(resolve, 5000));
+ try {
+ const receipt = await publicClient.waitForTransactionReceipt({
+ hash: transactionHash as `0x${string}`,
+ timeout: maxWaitTime,
+ });
+
+ return {
+ status: receipt.status === 'success' ? 'confirmed' : 'failed',
+ blockNumber: Number(receipt.blockNumber),
+ };
+ } catch {
+ return { status: 'timeout' };
}
-
- return {
- status: 'timeout'
- };
}
}
diff --git a/src/utils/security/__tests__/blockchainSecurity.test.ts b/src/utils/security/__tests__/blockchainSecurity.test.ts
index 54b19492..f9c8dbab 100644
--- a/src/utils/security/__tests__/blockchainSecurity.test.ts
+++ b/src/utils/security/__tests__/blockchainSecurity.test.ts
@@ -6,9 +6,9 @@ global.fetch = jest.fn();
describe('BlockchainSecurityService', () => {
let service: BlockchainSecurityService;
const mockConfig: SecurityServiceConfig = {
- baseUrl: 'https://api.test.com',
+ baseUrl: 'http://localhost:3000',
timeout: 5000,
- apiKey: 'test-api-key'
+ apiKey: undefined
};
beforeEach(() => {
@@ -89,8 +89,33 @@ describe('BlockchainSecurityService', () => {
expect(result.riskScore).toBeGreaterThan(0);
});
+ it('should call the local proxy endpoint', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ risk_score: 30, categories: ['low_risk'], labels: [], description: 'Normal' })
+ });
+
+ await service.checkAddressRisk(testAddress);
+
+ const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0];
+ expect(fetchUrl).toContain('/api/security/address-check');
+ expect(fetchUrl).toContain(encodeURIComponent(testAddress));
+ });
+
+ it('should fall back to simulation when proxy returns non-ok', async () => {
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: false,
+ status: 502,
+ json: async () => ({ error: 'Bad gateway' })
+ });
+
+ const result = await service.checkAddressRisk(testAddress);
+ expect(result.riskScore).toBeGreaterThanOrEqual(0);
+ expect(result.riskScore).toBeLessThanOrEqual(100);
+ });
+
it('should return default risk score on API failure', async () => {
- (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
+ (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
const result = await service.checkAddressRisk(testAddress);
expect(result).toEqual({
diff --git a/src/utils/security/__tests__/transactionSecurity.test.ts b/src/utils/security/__tests__/transactionSecurity.test.ts
index da3014bb..ec162264 100644
--- a/src/utils/security/__tests__/transactionSecurity.test.ts
+++ b/src/utils/security/__tests__/transactionSecurity.test.ts
@@ -1,6 +1,10 @@
-import { decideStepUpSecurity, ethToWei } from '../transactionSecurity';
+import { decideStepUpSecurity, ethToWei, getSecurityDeviceId } from '../transactionSecurity';
describe('transactionSecurity helpers', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('requires step-up for values at or above the configured threshold', () => {
const decision = decideStepUpSecurity({
valueWei: ethToWei(3),
@@ -39,5 +43,82 @@ describe('transactionSecurity helpers', () => {
expect(decision.requiresStepUp).toBe(false);
expect(decision.reason).toBeNull();
});
+
+ describe('getSecurityDeviceId', () => {
+ let mockLocalStorage: Record;
+ let mockSessionStorage: Record;
+
+ beforeEach(() => {
+ mockLocalStorage = {};
+ mockSessionStorage = {};
+ jest.spyOn(globalThis, 'crypto', 'get').mockReturnValue({
+ randomUUID: () => '550e8400-e29b-41d4-a716-446655440000',
+ subtle: {} as SubtleCrypto,
+ getRandomValues: (arr: Uint8Array) => arr,
+ } as unknown as Crypto);
+ jest.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({
+ getItem: (key: string) => mockLocalStorage[key] ?? null,
+ setItem: (key: string, value: string) => { mockLocalStorage[key] = value; },
+ removeItem: (key: string) => { delete mockLocalStorage[key]; },
+ clear: () => { mockLocalStorage = {}; },
+ get length() { return Object.keys(mockLocalStorage).length; },
+ key: (index: number) => Object.keys(mockLocalStorage)[index] ?? null,
+ } as Storage);
+ jest.spyOn(globalThis, 'sessionStorage', 'get').mockReturnValue({
+ getItem: (key: string) => mockSessionStorage[key] ?? null,
+ setItem: (key: string, value: string) => { mockSessionStorage[key] = value; },
+ removeItem: (key: string) => { delete mockSessionStorage[key]; },
+ clear: () => { mockSessionStorage = {}; },
+ get length() { return Object.keys(mockSessionStorage).length; },
+ key: (index: number) => Object.keys(mockSessionStorage)[index] ?? null,
+ } as Storage);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('returns "server-device" when window is undefined', () => {
+ const windowSpy = jest.spyOn(globalThis, 'window', 'get').mockReturnValue(undefined as any);
+ expect(getSecurityDeviceId()).toBe('server-device');
+ windowSpy.mockRestore();
+ });
+
+ it('generates and stores a salted hash on first call', () => {
+ const result = getSecurityDeviceId();
+
+ expect(result).toBe('550e8400-e29b-41d4-a716-446655440000');
+ expect(mockSessionStorage['propchain-session-salt']).toBeDefined();
+ expect(mockLocalStorage['propchain-security-device-id-hash']).toBeDefined();
+ expect(mockLocalStorage['propchain-security-device-id-hash']).not.toBe(result);
+ });
+
+ it('reuses existing hash from localStorage on subsequent calls', () => {
+ const existingHash = 'a1b2c3d4';
+ mockLocalStorage['propchain-security-device-id-hash'] = existingHash;
+
+ const result = getSecurityDeviceId();
+
+ expect(result).toBe(existingHash);
+ expect(mockSessionStorage['propchain-session-salt']).toBeUndefined();
+ });
+
+ it('reuses session salt across calls within the same session', () => {
+ const sessionSalt = 'test-session-salt';
+ mockSessionStorage['propchain-session-salt'] = sessionSalt;
+
+ getSecurityDeviceId();
+
+ expect(mockSessionStorage['propchain-session-salt']).toBe(sessionSalt);
+ });
+
+ it('stores a hashed value, not the raw UUID', () => {
+ getSecurityDeviceId();
+
+ const storedValue = mockLocalStorage['propchain-security-device-id-hash'];
+ expect(storedValue).not.toBe('550e8400-e29b-41d4-a716-446655440000');
+ expect(storedValue).toMatch(/^[0-9a-f]{8,}$/);
+ });
+ });
});
diff --git a/src/utils/security/blockchainSecurity.ts b/src/utils/security/blockchainSecurity.ts
index f022d259..a1cb7490 100644
--- a/src/utils/security/blockchainSecurity.ts
+++ b/src/utils/security/blockchainSecurity.ts
@@ -74,35 +74,42 @@ export class BlockchainSecurityService {
if (cached) return cached;
try {
- // Try calling a remote API if available. If `fetch` returns a Promise
- // (for example when tests mock it), await it and use the response.
- // Otherwise, fall back to the local simulation to preserve test behavior.
- const fetchResult = typeof fetch === 'function' ? (fetch as any)(`${this.config.baseUrl}/address/${address}`, {
- headers: this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}
- }) : null;
-
- if (fetchResult && typeof fetchResult.then === 'function') {
- const response = await fetchResult;
- if (response && response.ok) {
- const body = await response.json();
- const score = typeof body.risk_score === 'number' ? body.risk_score : 50;
- const categories = Array.isArray(body.categories) ? body.categories : [];
- const result: AddressRiskScore = {
- address,
- riskScore: score,
- riskLevel: this.getRiskLevel(score),
- categories,
- labels: Array.isArray(body.labels) ? body.labels : [],
- description: body.description || ''
- };
- this.setCache(cacheKey, result);
- return result;
- }
- // If response not ok, throw to be caught below and return default
- throw new Error('Remote service returned non-OK response');
+ // Call the local API proxy route which securely holds the API key server-side.
+ // This avoids exposing the key in the client bundle.
+ const baseUrl = typeof window !== 'undefined'
+ ? window.location.origin
+ : this.config.baseUrl;
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
+
+ let response;
+ try {
+ response = await fetch(
+ `${baseUrl}/api/security/address-check?address=${encodeURIComponent(address)}`,
+ { signal: controller.signal }
+ );
+ } finally {
+ clearTimeout(timeoutId);
}
- // No remote fetch available — use the internal simulation
+ if (response && response.ok) {
+ const body = await response.json();
+ const score = typeof body.risk_score === 'number' ? body.risk_score : 50;
+ const categories = Array.isArray(body.categories) ? body.categories : [];
+ const result: AddressRiskScore = {
+ address,
+ riskScore: score,
+ riskLevel: this.getRiskLevel(score),
+ categories,
+ labels: Array.isArray(body.labels) ? body.labels : [],
+ description: body.description || ''
+ };
+ 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 = {
@@ -494,10 +501,11 @@ export class BlockchainSecurityService {
// Default configuration for development
const defaultConfig: SecurityServiceConfig = {
- baseUrl: 'https://api.chainalysis.com/api/v2',
+ baseUrl: 'http://localhost:3000',
timeout: 10000,
- // In production, this would be set via environment variables
- apiKey: typeof window !== 'undefined' ? (window as any).__CHAINALYSIS_API_KEY__ : undefined
+ // API key is now configured only on the server side via CHAINALYSIS_API_KEY env var.
+ // The browser never has access to this key.
+ apiKey: undefined
};
// Export singleton instance
diff --git a/src/utils/security/transactionSecurity.ts b/src/utils/security/transactionSecurity.ts
index c5ce3d55..b18d1205 100644
--- a/src/utils/security/transactionSecurity.ts
+++ b/src/utils/security/transactionSecurity.ts
@@ -75,17 +75,40 @@ export function createTrustedDeviceId(): string {
return `trusted_${crypto.randomUUID()}`;
}
+function simpleHash(input: string): string {
+ let hash = 0;
+ for (let i = 0; i < input.length; i++) {
+ const char = input.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash |= 0;
+ }
+ return Math.abs(hash).toString(16).padStart(8, '0');
+}
+
+const SESSION_SALT_KEY = 'propchain-session-salt';
+
+function getSessionSalt(): string {
+ let salt = window.sessionStorage.getItem(SESSION_SALT_KEY);
+ if (!salt) {
+ salt = crypto.randomUUID();
+ window.sessionStorage.setItem(SESSION_SALT_KEY, salt);
+ }
+ return salt;
+}
+
export function getSecurityDeviceId(): string {
if (typeof window === 'undefined') {
return 'server-device';
}
- const key = 'propchain-security-device-id';
+ const key = 'propchain-security-device-id-hash';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const deviceId = crypto.randomUUID();
- window.localStorage.setItem(key, deviceId);
+ const salt = getSessionSalt();
+ const hashedId = simpleHash(`${deviceId}:${salt}`);
+ window.localStorage.setItem(key, hashedId);
return deviceId;
}