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
30 changes: 30 additions & 0 deletions __mocks__/viem.js
Original file line number Diff line number Diff line change
@@ -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))),
};
3 changes: 3 additions & 0 deletions __mocks__/viem/chains.js
Original file line number Diff line number Diff line change
@@ -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' },
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions scripts/check-exposed-globals.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
54 changes: 54 additions & 0 deletions src/app/api/security/address-check/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
43 changes: 21 additions & 22 deletions src/components/ui/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,28 +78,27 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
return null
}

return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
const cssContent = Object.entries(THEMES)
.map(([theme, prefix]) => {
const rules = colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.filter(Boolean)
.join("\n")
return rules ? `${prefix} [data-chart=${id}] {\n${rules}\n}` : ""
})
.filter(Boolean)
.join("\n")

if (!cssContent) {
return null
}

return <style>{cssContent}</style>
}

const ChartTooltip = Tooltip
Expand Down
121 changes: 121 additions & 0 deletions src/lib/__tests__/batchTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading