From e8ca6173f3e345d08dd85ab26ef232e2f2ee757f Mon Sep 17 00:00:00 2001 From: Ranjeet2063 Date: Sun, 20 Sep 2026 12:19:14 +0545 Subject: [PATCH 1/3] feat(explorer): implement x402 payment explorer with receipts table, filters, and modal (closes #51) --- __tests__/explorer/receipts.test.ts | 210 ++++++++++++ app/api/explorer/receipts/route.ts | 28 +- components/explorer/receipt-table.tsx | 466 ++++++++++++++++++++------ lib/protocols/x402-receipt-store.ts | 52 ++- 4 files changed, 646 insertions(+), 110 deletions(-) create mode 100644 __tests__/explorer/receipts.test.ts diff --git a/__tests__/explorer/receipts.test.ts b/__tests__/explorer/receipts.test.ts new file mode 100644 index 00000000..2eaecedf --- /dev/null +++ b/__tests__/explorer/receipts.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + createX402Quote, + listX402ExplorerReceipts, + settleX402, + type X402ExplorerReceipt, +} from '@/lib/protocols/x402' +import { saveX402Receipt } from '@/lib/protocols/x402-receipt-store' + +describe('x402 payment explorer acceptance suite (#51)', () => { + // Test 1: la respuesta no incluye campos sensibles + it('la respuesta no incluye campos sensibles (zero operator keys, secrets, or internal endpoints)', () => { + const maliciousPayload = { + id: `rcpt_sec_test_${Date.now()}`, + quoteId: 'q_sec_123', + paymentRef: 'sec-svc:stellar:123', + settledAt: new Date().toISOString(), + txHash: `0x${'f'.repeat(64)}`, + chain: 'stellar' as const, + amountUsd: 1.0, + amountUnits: '10000000', + explorerUrl: `https://stellar.expert/explorer/testnet/tx/0x${'f'.repeat(64)}`, + agentId: 'secret-agent', + agent: 'secret-agent', + service: 'sec-svc', + serviceId: 'sec-svc', + amount: '10.0000000 XLM', + passportVerified: true, + reputationTier: 'gold', + accepted: true, + // Injected sensitive fields: + privateKey: 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + secretKey: 'sec_test_secret_key_never_leak', + seed: 'twelve word mnemonic seed phrase that should never be public', + secret: 'super_secret_operator_password', + operatorKey: 'op_key_9999', + internalEndpoint: 'https://internal-admin.stellar.local/v1/keys', + apiKey: 'sk_live_123456789', + authSecret: 'jwt_secret_token', + } + + saveX402Receipt(maliciousPayload as unknown as X402ExplorerReceipt) + + const explorer = listX402ExplorerReceipts({ q: 'sec-svc' }) + expect(explorer.receipts.length).toBeGreaterThanOrEqual(1) + + const found = explorer.receipts.find((r) => r.id === maliciousPayload.id) + expect(found).toBeDefined() + + // Assert sensitive fields are completely omitted/sanitized + const rawKeys = Object.keys((found ?? {}) as unknown as Record) + expect(rawKeys).not.toContain('privateKey') + expect(rawKeys).not.toContain('secretKey') + expect(rawKeys).not.toContain('seed') + expect(rawKeys).not.toContain('secret') + expect(rawKeys).not.toContain('operatorKey') + expect(rawKeys).not.toContain('internalEndpoint') + expect(rawKeys).not.toContain('apiKey') + expect(rawKeys).not.toContain('authSecret') + + // Confirm public fields are preserved + expect(found?.id).toBe(maliciousPayload.id) + expect(found?.agent).toBe('secret-agent') + expect(found?.amount).toBe('10.0000000 XLM') + }) + + // Test 2: la búsqueda por hash encuentra + it('la búsqueda por hash encuentra la transacción exacta', () => { + const uniqueTxHash = '0x112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00' + const uniqueId = `rcpt_hash_test_${Date.now()}` + + saveX402Receipt({ + id: uniqueId, + quoteId: 'q_hash_test', + paymentRef: 'hash-test-svc:stellar:123', + settledAt: new Date().toISOString(), + txHash: uniqueTxHash, + chain: 'stellar', + amountUsd: 0.25, + amountUnits: '2500000', + explorerUrl: `https://stellar.expert/explorer/testnet/tx/${uniqueTxHash}`, + agentId: 'hash-hunter', + agent: 'hash-hunter', + service: 'hash-service', + serviceId: 'hash-service', + amount: '2.5 XLM', + passportVerified: true, + reputationTier: 'standard', + accepted: true, + }) + + // Search by full hash + const fullSearch = listX402ExplorerReceipts({ q: uniqueTxHash }) + expect(fullSearch.total).toBeGreaterThanOrEqual(1) + expect(fullSearch.receipts.some((r) => r.txHash === uniqueTxHash)).toBe(true) + + // Search by substring of hash + const partialSearch = listX402ExplorerReceipts({ q: 'aabbccddeeff' }) + expect(partialSearch.total).toBeGreaterThanOrEqual(1) + expect(partialSearch.receipts.some((r) => r.id === uniqueId)).toBe(true) + }) + + // Test 3: la paginación corta + it('la paginación corta (page size limit and slice offsets are enforced)', () => { + const page1 = listX402ExplorerReceipts({ page: 1, pageSize: 2 }) + expect(page1.page).toBe(1) + expect(page1.pageSize).toBe(2) + expect(page1.receipts.length).toBeLessThanOrEqual(2) + + if (page1.total > 2) { + const page2 = listX402ExplorerReceipts({ page: 2, pageSize: 2 }) + expect(page2.page).toBe(2) + expect(page2.pageSize).toBe(2) + expect(page2.receipts.length).toBeLessThanOrEqual(2) + + // Confirm disjoint items across pages + const idsPage1 = new Set(page1.receipts.map((r) => r.id)) + const idsPage2 = new Set(page2.receipts.map((r) => r.id)) + for (const id of idsPage2) { + expect(idsPage1.has(id)).toBe(false) + } + } + }) + + // Test 4: un monto grande se muestra sin redondear + it('un monto grande se muestra sin redondear (exact precision preservation)', () => { + const largeAmountExact = '1000000.1234567 XLM' + const largeId = `rcpt_large_amount_${Date.now()}` + + saveX402Receipt({ + id: largeId, + quoteId: 'q_large_quote', + paymentRef: 'whale-svc:stellar:123', + settledAt: new Date().toISOString(), + txHash: `0x${'9'.repeat(64)}`, + chain: 'stellar', + amountUsd: 100000.012345, + amountUnits: '10000001234567', + explorerUrl: `https://stellar.expert/explorer/testnet/tx/0x${'9'.repeat(64)}`, + agentId: 'whale-agent', + agent: 'whale-agent', + service: 'whale-service', + serviceId: 'whale-service', + amount: largeAmountExact, + passportVerified: true, + reputationTier: 'gold', + accepted: true, + }) + + const result = listX402ExplorerReceipts({ q: largeId }) + expect(result.receipts.length).toBe(1) + const receipt = result.receipts[0] + + // Assert exact preservation without floating-point truncation + expect(receipt.amount).toBe(largeAmountExact) + expect(receipt.amount).toContain('1000000.1234567') + }) + + // Test 5: End-to-end Settlement Flow and Stellar Explorer URL + it('records accepted settlement and generates verifiable Stellar Expert link', () => { + const quote = createX402Quote({ + serviceId: 'oracle-service', + chain: 'stellar', + payer: 'Agent-Zero', + units: 2, + unitPriceUsd: 0.1, + }) + + const txHash = `0x${'c'.repeat(64)}` + const settlement = settleX402({ + paymentRef: quote.paymentRef, + chain: quote.chain, + txHash, + paidBy: quote.payer, + }) + + expect(settlement.ok).toBe(true) + + const query = listX402ExplorerReceipts({ q: 'Agent-Zero', chain: 'stellar' }) + expect(query.total).toBeGreaterThanOrEqual(1) + const matching = query.receipts.find((r) => r.paymentRef === quote.paymentRef) + + expect(matching).toBeDefined() + expect(matching?.chain).toBe('stellar') + expect(matching?.explorerUrl).toBe( + `https://stellar.expert/explorer/testnet/tx/${txHash}` + ) + expect(matching?.passportVerified).toBe(true) + expect(query.stats.totalPayments).toBeGreaterThanOrEqual(1) + }) + + // Test 6: Date range filtering + it('filters receipts within specified date range', () => { + const now = new Date() + const yesterday = new Date(now.getTime() - 86400000).toISOString() + const tomorrow = new Date(now.getTime() + 86400000).toISOString() + + const results = listX402ExplorerReceipts({ + startDate: yesterday, + endDate: tomorrow, + }) + + expect(results.receipts.length).toBeGreaterThanOrEqual(1) + for (const r of results.receipts) { + const settled = new Date(r.settledAt).getTime() + expect(settled).toBeGreaterThanOrEqual(new Date(yesterday).getTime()) + expect(settled).toBeLessThanOrEqual(new Date(tomorrow).getTime()) + } + }) +}) diff --git a/app/api/explorer/receipts/route.ts b/app/api/explorer/receipts/route.ts index bed40a32..133080b6 100644 --- a/app/api/explorer/receipts/route.ts +++ b/app/api/explorer/receipts/route.ts @@ -3,15 +3,31 @@ import { listX402ExplorerReceipts, type SettlementChain } from '@/lib/protocols/ export async function GET(req: Request) { const { searchParams } = new URL(req.url) - const rawChain = searchParams.get('chain') || 'all' - const chain: SettlementChain | 'all' = rawChain === 'stellar' || rawChain === 'bnb' ? rawChain : 'all' + const rawChain = (searchParams.get('chain') || 'all').toLowerCase() + const chain: SettlementChain | 'all' = + rawChain === 'stellar' || rawChain === 'bnb' || rawChain === 'base' ? rawChain : 'all' + + const q = searchParams.get('q') || undefined + const agent = searchParams.get('agent') || undefined + const service = searchParams.get('service') || undefined + const startDate = searchParams.get('startDate') || searchParams.get('from') || undefined + const endDate = searchParams.get('endDate') || searchParams.get('to') || undefined + + const rawPage = parseInt(searchParams.get('page') || '1', 10) + const rawPageSize = parseInt(searchParams.get('pageSize') || '50', 10) + + const page = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1 + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.min(100, rawPageSize) : 50 const data = listX402ExplorerReceipts({ - q: searchParams.get('q') || undefined, - service: searchParams.get('service') || undefined, + q, + agent, + service, chain, - page: Number(searchParams.get('page') || 1), - pageSize: Number(searchParams.get('pageSize') || 50), + startDate, + endDate, + page, + pageSize, }) return NextResponse.json({ ok: true, ...data }) diff --git a/components/explorer/receipt-table.tsx b/components/explorer/receipt-table.tsx index b3300305..a3d450a1 100644 --- a/components/explorer/receipt-table.tsx +++ b/components/explorer/receipt-table.tsx @@ -12,155 +12,417 @@ interface ReceiptExplorerPayload { stats: { totalPayments: number totalUsd: number + totalXlm?: string uniqueAgents: number services: number } } function shortHash(hash: string) { + if (!hash) return '' if (hash.length <= 16) return hash return `${hash.slice(0, 8)}...${hash.slice(-6)}` } -function formatUsd(n: number) { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: n > 0 && n < 0.01 ? 4 : 2, - maximumFractionDigits: n > 0 && n < 0.01 ? 4 : 2, - }).format(n) +function formatRelativeTime(dateStr: string): string { + try { + const d = new Date(dateStr) + const now = Date.now() + const diffSec = Math.floor((now - d.getTime()) / 1000) + if (diffSec < 60) return `${Math.max(1, diffSec)}s ago` + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHours = Math.floor(diffMin / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + if (diffDays < 30) return `${diffDays}d ago` + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + } catch { + return dateStr + } +} + +function getChainBadgeStyle(chain: string) { + switch (chain?.toLowerCase()) { + case 'stellar': + return 'border-cyan-400/30 bg-cyan-500/10 text-cyan-300' + case 'base': + return 'border-blue-400/30 bg-blue-500/10 text-blue-300' + case 'bnb': + return 'border-amber-400/30 bg-amber-500/10 text-amber-300' + default: + return 'border-slate-700 bg-slate-800 text-slate-300' + } } export function ReceiptTable({ initialData }: { initialData: ReceiptExplorerPayload }) { const [query, setQuery] = useState('') + const [serviceFilter, setServiceFilter] = useState('') const [chain, setChain] = useState('all') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const pageSize = 50 const [selected, setSelected] = useState(null) + const [copied, setCopied] = useState(false) + + const handleCopyJson = (obj: unknown) => { + try { + void navigator.clipboard.writeText(JSON.stringify(obj, null, 2)) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // ignore + } + } + + const resetFilters = () => { + setQuery('') + setServiceFilter('') + setChain('all') + setStartDate('') + setEndDate('') + setCurrentPage(1) + } - const receipts = useMemo(() => { + const filteredReceipts = useMemo(() => { const needle = query.trim().toLowerCase() + const svcNeedle = serviceFilter.trim().toLowerCase() + const fromTime = startDate ? new Date(startDate).getTime() : null + const toTime = endDate ? new Date(endDate).getTime() : null + return initialData.receipts.filter((receipt) => { - if (chain !== 'all' && receipt.chain !== chain) return false + if (chain !== 'all' && receipt.chain?.toLowerCase() !== chain.toLowerCase()) return false + if (svcNeedle && !receipt.service?.toLowerCase().includes(svcNeedle) && !receipt.serviceId?.toLowerCase().includes(svcNeedle)) { + return false + } + if (fromTime !== null && !isNaN(fromTime)) { + if (new Date(receipt.settledAt).getTime() < fromTime) return false + } + if (toTime !== null && !isNaN(toTime)) { + // match up to end of selected day (23:59:59) + const endOfDay = toTime + 86400000 - 1 + if (new Date(receipt.settledAt).getTime() > endOfDay) return false + } if (!needle) return true return [ receipt.id, receipt.agent, + receipt.agentId, receipt.serviceId, + receipt.service, receipt.txHash, receipt.paymentRef, - ].join(' ').toLowerCase().includes(needle) + receipt.amount, + ].filter(Boolean).join(' ').toLowerCase().includes(needle) }) - }, [chain, initialData.receipts, query]) + }, [chain, endDate, initialData.receipts, query, serviceFilter, startDate]) + + const totalFiltered = filteredReceipts.length + const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize)) + const paginatedReceipts = useMemo(() => { + const start = (currentPage - 1) * pageSize + return filteredReceipts.slice(start, start + pageSize) + }, [currentPage, filteredReceipts, pageSize]) return ( -
-
- - - - +
+ {/* Summary stats cards */} +
+ + + +
-
- setQuery(event.target.value)} - placeholder="Search receipts, agents, services, or hashes" - className="min-h-10 flex-1 rounded-lg border border-slate-700 bg-slate-900 px-3 font-mono text-sm text-slate-100 outline-none focus:border-cyan-400" - /> - + {/* Filters bar */} +
+
+
+ { + setQuery(e.target.value) + setCurrentPage(1) + }} + placeholder="Search by agent name, service, hash, or receipt ID..." + className="w-full min-h-10 rounded-lg border border-slate-700 bg-slate-900 px-3 font-mono text-sm text-slate-100 outline-none transition focus:border-cyan-400" + /> +
+ + { + setServiceFilter(e.target.value) + setCurrentPage(1) + }} + placeholder="Filter by service..." + className="min-h-10 rounded-lg border border-slate-700 bg-slate-900 px-3 font-mono text-sm text-slate-100 outline-none transition focus:border-cyan-400 md:w-48" + /> + + +
+ +
+
+ Date Range: + { + setStartDate(e.target.value) + setCurrentPage(1) + }} + className="rounded border border-slate-700 bg-slate-900 px-2 py-1 font-mono text-slate-200 outline-none focus:border-cyan-400" + /> + to + { + setEndDate(e.target.value) + setCurrentPage(1) + }} + className="rounded border border-slate-700 bg-slate-900 px-2 py-1 font-mono text-slate-200 outline-none focus:border-cyan-400" + /> +
+ + {(query || serviceFilter || chain !== 'all' || startDate || endDate) && ( + + )} +
+ {/* Receipts Table */}
- - - - - - - - - - - - - - {receipts.length === 0 ? ( +
+
ReceiptAgentServiceAmountChainTXTime
+ - + + + + + + + - ) : ( - receipts.map((receipt) => ( - setSelected(receipt)} - className="cursor-pointer border-t border-slate-800 text-slate-200 transition hover:bg-slate-900/70" - > - - - - - - + + {paginatedReceipts.length === 0 ? ( + + - - )) - )} - -
- No x402 receipts match the current filters. - Receipt IDAgentServiceAmountTX HashChainTime
{receipt.id}{receipt.agent}{receipt.serviceId}{formatUsd(receipt.amountUsd)}{receipt.chain} - {receipt.explorerUrl ? ( - e.stopPropagation()} - className="text-cyan-400 underline-offset-2 hover:underline" - > - {shortHash(receipt.txHash)} - - ) : ( - shortHash(receipt.txHash) - )} +
+
+
🔍
+
+ No x402 receipts match the current filters. +
+
+ Try adjusting your search terms, clearing date bounds, or selecting another chain. +
+ {(query || serviceFilter || chain !== 'all' || startDate || endDate) && ( + + )} +
{new Date(receipt.settledAt).toLocaleString()}
+ ) : ( + paginatedReceipts.map((receipt) => { + const displayAmount = receipt.amount || `${receipt.amountUsd} USD` + return ( + setSelected(receipt)} + className="cursor-pointer text-slate-200 transition hover:bg-slate-900/70" + > + + {receipt.id} + + + {receipt.agent || receipt.agentId || 'anonymous'} + + + {receipt.service || receipt.serviceId} + + + {displayAmount} + + + {receipt.explorerUrl ? ( + e.stopPropagation()} + className="inline-flex items-center gap-1 text-cyan-400 underline-offset-2 hover:underline" + title={receipt.txHash} + > + {shortHash(receipt.txHash)} + ↗ + + ) : ( + {shortHash(receipt.txHash)} + )} + + + + {receipt.chain} + + + + {formatRelativeTime(receipt.settledAt)} + + + ) + }) + )} + + +
+ + {/* Pagination bar */} + {totalPages > 1 && ( +
+
+ Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalFiltered)} of {totalFiltered} receipts +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )}
+ {/* Receipt detail modal */} {selected && ( -
-
-

Receipt JSON

-
- {selected.explorerUrl && ( +
setSelected(null)} + > +
e.stopPropagation()} + > +
+
+
+ x402 Verified Receipt +
+

+ {selected.id} +

+
+ +
+ + {/* Metadata badges */} +
+
+
Passport Status
+
+ {selected.passportVerified ? 'ZK verified? ✓' : 'Unverified'} +
+
+
+
Reputation Tier
+
+ {selected.reputationTier || 'standard'} +
+
+
+
Settlement Chain
+
+ {selected.chain} +
+
+
+ + {/* Explorer button */} + {selected.explorerUrl && ( +
- View on explorer ↗ + Verify Transaction on {selected.chain === 'stellar' ? 'Stellar Expert' : 'Block Explorer'} + ↗ - )} - +
+ )} + + {/* JSON Viewer */} +
+
+ Receipt Payload (JSON) + +
+
+                {JSON.stringify(selected, null, 2)}
+              
-
-            {JSON.stringify(selected, null, 2)}
-          
)}
@@ -170,8 +432,8 @@ export function ReceiptTable({ initialData }: { initialData: ReceiptExplorerPayl function Stat({ label, value }: { label: string; value: string }) { return (
-
{label}
-
{value}
+
{label}
+
{value}
) } diff --git a/lib/protocols/x402-receipt-store.ts b/lib/protocols/x402-receipt-store.ts index b7672b1b..da87b5ae 100644 --- a/lib/protocols/x402-receipt-store.ts +++ b/lib/protocols/x402-receipt-store.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from ' import { dirname, join } from 'node:path' import { cwd } from 'node:process' import type { SettlementChain, X402ExplorerReceipt } from '@/lib/protocols/x402' +export type { X402ExplorerReceipt } export interface X402ReceiptQuery { agent?: string @@ -10,6 +11,10 @@ export interface X402ReceiptQuery { chain?: SettlementChain | 'all' page?: number pageSize?: number + startDate?: string + endDate?: string + from?: string + to?: string } export interface X402ReceiptPage { @@ -21,6 +26,7 @@ export interface X402ReceiptPage { stats: { totalPayments: number totalUsd: number + totalXlm: string uniqueAgents: number services: number } @@ -73,6 +79,31 @@ export function getX402Receipt(receiptId: string): X402ExplorerReceipt | undefin return readReceipts().find((receipt) => receipt.id === receiptId) } +function extractXlmAmount(receipt: X402ExplorerReceipt): number { + if (receipt.chain !== 'stellar') return 0 + if (receipt.amount) { + const m = receipt.amount.match(/^([\d.]+)\s*XLM/i) + if (m) return parseFloat(m[1]) + } + if (receipt.amountUnits && !isNaN(Number(receipt.amountUnits))) { + return Number(receipt.amountUnits) / 10_000_000 + } + if (receipt.amountUsd) { + return receipt.amountUsd / 0.1 // standard fallback rate + } + return 0 +} + +export function sanitizeReceiptForExplorer(receipt: X402ExplorerReceipt): X402ExplorerReceipt { + // Public-facing contract: guarantees zero operator keys or sensitive credentials exposed + const clean = { ...receipt } + const sensitiveKeys = ['privateKey', 'secretKey', 'seed', 'secret', 'operatorKey', 'internalEndpoint', 'apiKey', 'authSecret'] + for (const k of sensitiveKeys) { + delete (clean as Record)[k] + } + return clean +} + export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPage { const pageSize = Math.max(1, Math.min(50, Math.floor(filters.pageSize ?? 50))) const page = Math.max(1, Math.floor(filters.page ?? 1)) @@ -82,10 +113,21 @@ export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPag const chain = filters.chain && filters.chain !== 'all' ? filters.chain : null const allReceipts = readReceipts() + const fromTime = filters.startDate || filters.from ? new Date(filters.startDate || filters.from!).getTime() : null + const toTime = filters.endDate || filters.to ? new Date(filters.endDate || filters.to!).getTime() : null + const filtered = allReceipts.filter((receipt) => { if (chain && receipt.chain !== chain) return false if (agent && receipt.agentId.toLowerCase() !== agent && receipt.agent.toLowerCase() !== agent) return false if (service && receipt.serviceId.toLowerCase() !== service && receipt.service.toLowerCase() !== service) return false + + if (fromTime !== null && !isNaN(fromTime)) { + if (new Date(receipt.settledAt).getTime() < fromTime) return false + } + if (toTime !== null && !isNaN(toTime)) { + if (new Date(receipt.settledAt).getTime() > toTime) return false + } + if (q) { const haystack = [ receipt.id, @@ -105,7 +147,12 @@ export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPag const total = filtered.length const start = (page - 1) * pageSize - const receipts = filtered.slice(start, start + pageSize) + const receipts = filtered.slice(start, start + pageSize).map(sanitizeReceiptForExplorer) + + const totalXlmSum = allReceipts.reduce((sum, r) => sum + extractXlmAmount(r), 0) + const totalXlmStr = totalXlmSum > 0 + ? `${totalXlmSum.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 7 })} XLM` + : '0.00 XLM' return { receipts, @@ -116,8 +163,9 @@ export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPag stats: { totalPayments: allReceipts.length, totalUsd: Number(allReceipts.reduce((sum, receipt) => sum + receipt.amountUsd, 0).toFixed(6)), + totalXlm: totalXlmStr, uniqueAgents: new Set(allReceipts.map((receipt) => receipt.agentId)).size, - services: new Set(allReceipts.map((receipt) => receipt.service)).size, + services: new Set(allReceipts.map((receipt) => receipt.serviceId || receipt.service)).size, }, } } From 7bb172afed4cf9f13ec464b6c8ac9dd99c991b57 Mon Sep 17 00:00:00 2001 From: Ranjeet2063 Date: Sun, 20 Sep 2026 12:31:05 +0545 Subject: [PATCH 2/3] fix(explorer): resolve SonarCloud accessibility dialog bug and code smells --- __tests__/explorer/receipts.test.ts | 2 +- app/api/explorer/receipts/route.ts | 4 +- components/explorer/receipt-table.tsx | 24 +++++++---- lib/protocols/x402-receipt-store.ts | 59 +++++++++++++++------------ 4 files changed, 53 insertions(+), 36 deletions(-) diff --git a/__tests__/explorer/receipts.test.ts b/__tests__/explorer/receipts.test.ts index 2eaecedf..8f36730b 100644 --- a/__tests__/explorer/receipts.test.ts +++ b/__tests__/explorer/receipts.test.ts @@ -148,7 +148,7 @@ describe('x402 payment explorer acceptance suite (#51)', () => { }) const result = listX402ExplorerReceipts({ q: largeId }) - expect(result.receipts.length).toBe(1) + expect(result.receipts).toHaveLength(1) const receipt = result.receipts[0] // Assert exact preservation without floating-point truncation diff --git a/app/api/explorer/receipts/route.ts b/app/api/explorer/receipts/route.ts index 133080b6..342248aa 100644 --- a/app/api/explorer/receipts/route.ts +++ b/app/api/explorer/receipts/route.ts @@ -13,8 +13,8 @@ export async function GET(req: Request) { const startDate = searchParams.get('startDate') || searchParams.get('from') || undefined const endDate = searchParams.get('endDate') || searchParams.get('to') || undefined - const rawPage = parseInt(searchParams.get('page') || '1', 10) - const rawPageSize = parseInt(searchParams.get('pageSize') || '50', 10) + const rawPage = Number.parseInt(searchParams.get('page') || '1', 10) + const rawPageSize = Number.parseInt(searchParams.get('pageSize') || '50', 10) const page = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1 const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.min(100, rawPageSize) : 50 diff --git a/components/explorer/receipt-table.tsx b/components/explorer/receipt-table.tsx index a3d450a1..0aceab72 100644 --- a/components/explorer/receipt-table.tsx +++ b/components/explorer/receipt-table.tsx @@ -96,10 +96,10 @@ export function ReceiptTable({ initialData }: { initialData: ReceiptExplorerPayl if (svcNeedle && !receipt.service?.toLowerCase().includes(svcNeedle) && !receipt.serviceId?.toLowerCase().includes(svcNeedle)) { return false } - if (fromTime !== null && !isNaN(fromTime)) { + if (fromTime !== null && !Number.isNaN(fromTime)) { if (new Date(receipt.settledAt).getTime() < fromTime) return false } - if (toTime !== null && !isNaN(toTime)) { + if (toTime !== null && !Number.isNaN(toTime)) { // match up to end of selected day (23:59:59) const endOfDay = toTime + 86400000 - 1 if (new Date(receipt.settledAt).getTime() > endOfDay) return false @@ -343,19 +343,29 @@ export function ReceiptTable({ initialData }: { initialData: ReceiptExplorerPayl
setSelected(null)} + aria-labelledby="receipt-modal-title" + tabIndex={-1} + onKeyDown={(e) => { + if (e.key === 'Escape') setSelected(null) + }} + className="fixed inset-0 z-50 flex items-center justify-center p-4" > +