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
98 changes: 61 additions & 37 deletions src/app/compare/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,45 +151,69 @@ function ComparePage() {
const printWindow = window.open('', '_blank');
if (!printWindow) return;

const html = `
<!DOCTYPE html>
<html>
<head>
<title>Property Comparison</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #f5f5f5; font-weight: bold; }
h1 { color: #333; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<h1>Property Comparison Report</h1>
<p>Generated on ${new Date().toLocaleDateString()}</p>
<table>
<thead>
<tr>
<th>Metric</th>
${properties.map(p => `<th>${p.name}</th>`).join('')}
</tr>
</thead>
<tbody>
${comparisonMetrics.map(metric => `
<tr>
<td>${metric.label}</td>
${properties.map(p => `<td>${metric.format(getNestedValue(p, metric.key), p)}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
</body>
</html>
// Build document via safe DOM APIs instead of document.write to avoid
// CSP bypass and script re-execution risks.
const doc = printWindow.document;

// Create title
const title = doc.createElement('title');
title.textContent = 'Property Comparison';
doc.head.appendChild(title);

// Create styles
const style = doc.createElement('style');
style.textContent = `
body { font-family: Arial, sans-serif; padding: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #f5f5f5; font-weight: bold; }
h1 { color: #333; }
@media print { body { padding: 0; } }
`;
doc.head.appendChild(style);

// Build body
const h1 = doc.createElement('h1');
h1.textContent = 'Property Comparison Report';
doc.body.appendChild(h1);

const dateP = doc.createElement('p');
dateP.textContent = `Generated on ${new Date().toLocaleDateString()}`;
doc.body.appendChild(dateP);

// Build table
const table = doc.createElement('table');
const thead = doc.createElement('thead');
const headerRow = doc.createElement('tr');
const metricTh = doc.createElement('th');
metricTh.textContent = 'Metric';
headerRow.appendChild(metricTh);
properties.forEach(p => {
const th = doc.createElement('th');
th.textContent = p.name;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);

const tbody = doc.createElement('tbody');
comparisonMetrics.forEach(metric => {
const row = doc.createElement('tr');
const labelTd = doc.createElement('td');
labelTd.textContent = metric.label;
row.appendChild(labelTd);
properties.forEach(p => {
const td = doc.createElement('td');
td.textContent = metric.format(getNestedValue(p, metric.key), p);
row.appendChild(td);
});
tbody.appendChild(row);
});
table.appendChild(tbody);
doc.body.appendChild(table);

printWindow.document.write(html);
printWindow.document.close();
// Close the document stream so browsers finish rendering before printing
doc.close();
printWindow.print();
};

Expand Down
20 changes: 8 additions & 12 deletions src/components/ui/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ function buildChartCSS(id: string, config: ChartConfig): string {

if (!colorConfig.length) return ''

return Object.entries(THEMES)
.map(([theme, prefix]) => `
// Build CSS rules safely as a string (only uses known-safe values:
// theme keys, color strings from config, and CSS variable names)
const cssText = Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
Expand All @@ -90,18 +93,11 @@ ${colorConfig
.filter(Boolean)
.join("\n")}
}
`)
`
)
.join("\n")
}

const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const css = React.useMemo(() => buildChartCSS(id, config), [id, config])

if (!css) return null

const sanitizedCss = DOMPurify.sanitize(css)

return <style dangerouslySetInnerHTML={{ __html: sanitizedCss }} />
return <style>{cssText}</style>
}

const ChartTooltip = Tooltip
Expand Down
10 changes: 9 additions & 1 deletion src/lib/propertyCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import {
// Event listeners
const eventListeners: Set<CacheEventListener> = new Set();

// Interval handle for cleanup timer (stored at module level to allow cleanup on re-init)
let cleanupIntervalHandle: ReturnType<typeof setInterval> | null = null;

// Cache statistics
let cacheStats: CacheStats = {
totalEntries: 0,
Expand Down Expand Up @@ -851,9 +854,14 @@ export const initPropertyCache = async (): Promise<void> => {
// Clean up expired entries on init
await cleanupExpiredEntries();

// Clear any existing cleanup interval (prevents accumulation on HMR / re-imports)
if (cleanupIntervalHandle !== null) {
clearInterval(cleanupIntervalHandle);
}

// Set up periodic cleanup
const config = getCacheConfig();
setInterval(cleanupExpiredEntries, config.cleanupInterval);
cleanupIntervalHandle = setInterval(cleanupExpiredEntries, config.cleanupInterval);

logger.info('Property cache initialized');
} catch (error) {
Expand Down
53 changes: 39 additions & 14 deletions src/lib/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,45 @@ interface RateLimitResult {
const ipStore: RateLimitStore = {};
const walletStore: RateLimitStore = {};

// Clean up expired entries periodically
setInterval(() => {
const now = Date.now();
Object.keys(ipStore).forEach(key => {
if (ipStore[key].resetTime <= now) {
delete ipStore[key];
}
});
Object.keys(walletStore).forEach(key => {
if (walletStore[key].resetTime <= now) {
delete walletStore[key];
}
});
}, 60000); // Clean up every minute
// Interval handle for cleanup timer
let cleanupIntervalHandle: ReturnType<typeof setInterval> | null = null;

/**
* Start the periodic cleanup of expired rate-limit entries.
* Safe to call multiple times β€” clears any existing interval first.
*/
function startCleanupTimer(): void {
if (cleanupIntervalHandle !== null) {
clearInterval(cleanupIntervalHandle);
}

cleanupIntervalHandle = setInterval(() => {
const now = Date.now();
Object.keys(ipStore).forEach(key => {
if (ipStore[key].resetTime <= now) {
delete ipStore[key];
}
});
Object.keys(walletStore).forEach(key => {
if (walletStore[key].resetTime <= now) {
delete walletStore[key];
}
});
}, 60000); // Clean up every minute
}

// Start the cleanup timer on module load
startCleanupTimer();

/**
* Stops the cleanup timer. Useful for tests and HMR teardown.
*/
export function stopRateLimitCleanup(): void {
if (cleanupIntervalHandle !== null) {
clearInterval(cleanupIntervalHandle);
cleanupIntervalHandle = null;
}
}

function getRateLimitData(store: RateLimitStore, key: string, windowMs: number): {
count: number;
Expand Down
89 changes: 85 additions & 4 deletions src/utils/security/blockchainSecurity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logger } from '@/utils/logger';
import { parseEther } from 'viem';

export interface SecurityServiceConfig {
apiKey?: string;
baseUrl: string;
Expand Down Expand Up @@ -38,6 +39,86 @@ export interface SecurityAlert {
timestamp: number;
}

/**
* Normalises a transaction value string into a BigInt.
* Handles hex strings (0x-prefixed), scientific notation, and decimal strings.
* Throws a typed error for unparseable values.
*/
function normalizeToBigInt(value: string): bigint {
if (typeof value !== 'string' || value.length === 0) {
throw new BlockchainSecurityError(
'Invalid value: must be a non-empty string',
'INVALID_VALUE'
);
}

let normalised = value.trim();

// Handle hex (0x-prefixed) β€” viem-style wei values
if (normalised.startsWith('0x') || normalised.startsWith('0X')) {
try {
return BigInt(normalised);
} catch {
throw new BlockchainSecurityError(
`Unable to parse hex value: "${normalised}"`,
'INVALID_HEX_VALUE'
);
}
}

// Handle scientific notation (e.g. "1e18", "1.5e-3")
if (/[eE]/.test(normalised)) {
const asNumber = Number(normalised);
if (!Number.isFinite(asNumber)) {
throw new BlockchainSecurityError(
`Scientific notation overflow: "${normalised}"`,
'VALUE_OVERFLOW'
);
}
// Convert to a whole-number string suitable for BigInt
try {
return BigInt(Math.round(asNumber));
} catch {
throw new BlockchainSecurityError(
`Unable to parse scientific notation: "${normalised}"`,
'INVALID_SCI_VALUE'
);
}
}

// Handle fractional decimal (e.g. "1.5" β€” treat as ether value)
if (normalised.includes('.')) {
try {
return parseEther(normalised as `${number}`);
} catch {
throw new BlockchainSecurityError(
`Unable to parse decimal ether value: "${normalised}"`,
'INVALID_ETHER_VALUE'
);
}
}

// Plain decimal string (wei)
try {
return BigInt(normalised);
} catch {
throw new BlockchainSecurityError(
`Unable to parse value: "${normalised}"`,
'INVALID_VALUE'
);
}
}

export class BlockchainSecurityError extends Error {
public readonly code: string;

constructor(message: string, code: string) {
super(message);
this.name = 'BlockchainSecurityError';
this.code = code;
}
}

export class BlockchainSecurityService {
// Singleton instance β€” ensures only one service instance exists across the app
private static instance: BlockchainSecurityService;
Expand Down Expand Up @@ -259,9 +340,9 @@ export class BlockchainSecurityService {
blocks.push('Recipient address is on sanctions list');
}

// BigInt comparison: 1 ETH = 10^18 wei; flag high-value sends to risky recipients
const valueBN = BigInt(value);
if (valueBN >= BigInt('1000000000000000000') && recipientRisk.riskScore > 50) { // >= 1 ETH
// Check for high-value transaction to risky address
const valueBN = normalizeToBigInt(value);
if (valueBN > BigInt('1000000000000000000') && recipientRisk.riskScore > 50) { // > 1 ETH
warnings.push('High-value transaction to risky address');
}

Expand Down
8 changes: 6 additions & 2 deletions src/utils/structuredLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,12 @@ class StructuredLogger {

export const structuredLogger = new StructuredLogger();

export const createStructuredLogger = (config?: Partial<StructuredLoggerConfig>) =>
new StructuredLogger(config);
// Note: structuredLogger.destroy() should be called manually for cleanup
// in test teardowns or before recreating the singleton.

// ============================================================================
// Performance Monitoring Helper
// ============================================================================

export const createPerformanceTracker = (operation: string, metadata?: Partial<StructuredLogEntry>) => {
const start = performance.now();
Expand Down