From b977eb42ce1f02ac8488028e3881df301a3bf740 Mon Sep 17 00:00:00 2001 From: Henrique Breim Date: Sat, 18 Apr 2026 00:25:34 +0200 Subject: [PATCH 01/11] feat(bazaar): add public dashboard and enriched discovery endpoints --- packages/relay/public/dashboard.html | 931 ++++++++++++++++++ .../relay/src/bazaar/aggregations.test.ts | 86 ++ packages/relay/src/bazaar/aggregations.ts | 577 +++++++++++ packages/relay/src/bazaar/bazaar.routes.ts | 151 +++ packages/relay/src/bazaar/cache.ts | 26 + packages/relay/src/index.ts | 8 +- packages/relay/src/shared/rate-limit.ts | 6 + 7 files changed, 1784 insertions(+), 1 deletion(-) create mode 100644 packages/relay/public/dashboard.html create mode 100644 packages/relay/src/bazaar/aggregations.test.ts create mode 100644 packages/relay/src/bazaar/aggregations.ts create mode 100644 packages/relay/src/bazaar/cache.ts diff --git a/packages/relay/public/dashboard.html b/packages/relay/public/dashboard.html new file mode 100644 index 0000000..735427a --- /dev/null +++ b/packages/relay/public/dashboard.html @@ -0,0 +1,931 @@ + + + + + + + 402md Bazaar — Facilitator activity + + + + + + + +
+
+
402md — bazaar
+ +
+ +
+

Architecture

+
+
+
Chains supported
+
9
+
EVM + Solana + Stellar
+
+
+
Routes active
+
+
cross-chain
+
+
+
Protocols
+
x402 + MPP
+
Dual-protocol facilitator
+
+
+
+ +
+
+

Activity window

+
+ + + +
+
+
+ +
+

Per chain

+
+
+ +
+

Route matrix

+
+

Loading routes…

+
+
+ +
+
+

Top resources

+
+ + +
+
+
+

Loading resources…

+
+
+ +
+
+

Top sellers

+
+ + +
+
+
+

Loading sellers…

+
+
+ +
+

Recent transactions

+
+

Loading transactions…

+
+
+ +
+

Cross-chain cost comparison

+
+

Loading cost comparison…

+
+
+ +
+
402md facilitator
+
Data refreshes every 30s
+
+
+ + + + diff --git a/packages/relay/src/bazaar/aggregations.test.ts b/packages/relay/src/bazaar/aggregations.test.ts new file mode 100644 index 0000000..7863ef8 --- /dev/null +++ b/packages/relay/src/bazaar/aggregations.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test' +import '@/shared/test-helpers' +import { parseWindow, getCostComparison, RouteNotConfiguredError } from './aggregations' + +describe('parseWindow', () => { + test('accepts 1d / 7d / 30d', () => { + expect(parseWindow('1d')).toBe('1d') + expect(parseWindow('7d')).toBe('7d') + expect(parseWindow('30d')).toBe('30d') + }) + + test('defaults to 7d for unknown or missing', () => { + expect(parseWindow(undefined)).toBe('7d') + expect(parseWindow('')).toBe('7d') + expect(parseWindow('42h')).toBe('7d') + expect(parseWindow('2d')).toBe('7d') + }) +}) + +describe('getCostComparison', () => { + test('returns three tiers with CCTP allowance from gas schedule', () => { + const result = getCostComparison({ + buyerNetwork: 'eip155:8453', + sellerNetwork: 'stellar:pubnet', + }) + + expect(result.buyerNetwork).toBe('eip155:8453') + expect(result.sellerNetwork).toBe('stellar:pubnet') + expect(result.tiers).toHaveLength(3) + expect(result.tiers.map((t) => t.amount)).toEqual(['100000', '1000000', '100000000']) + + for (const tier of result.tiers) { + expect(typeof tier.cctpAllowance).toBe('string') + expect(typeof tier.percentAlternative).toBe('string') + expect(typeof tier.savingsVsPercent).toBe('string') + // savings = percentAlternative - cctpAllowance + const expected = (BigInt(tier.percentAlternative) - BigInt(tier.cctpAllowance)).toString() + expect(tier.savingsVsPercent).toBe(expected) + } + }) + + test('percentAlternative respects the $0.10 floor for small amounts', () => { + const result = getCostComparison({ + buyerNetwork: 'eip155:8453', + sellerNetwork: 'stellar:pubnet', + }) + const smallest = result.tiers[0] + // $0.10 amount * 1% = $0.001, but the floor is $0.10 → percentAlternative = 100_000 micro-USDC + expect(smallest.percentAlternative).toBe('100000') + }) + + test('percentAlternative scales at 1% above the floor', () => { + const result = getCostComparison({ + buyerNetwork: 'eip155:8453', + sellerNetwork: 'stellar:pubnet', + }) + // $100 amount * 1% = $1.00 = 1_000_000 micro-USDC + expect(result.tiers[2].percentAlternative).toBe('1000000') + }) + + test('includes documentation notes', () => { + const result = getCostComparison({ + buyerNetwork: 'eip155:8453', + sellerNetwork: 'stellar:pubnet', + }) + expect(result.notes.cctpSource).toBe('gas-schedule.ts') + expect(result.notes.percentAssumption).toContain('1%') + expect(result.notes.percentAssumption).toContain('$0.10') + }) + + test('throws RouteNotConfiguredError for unsupported network', () => { + expect(() => + getCostComparison({ buyerNetwork: 'eip155:999999', sellerNetwork: 'stellar:pubnet' }), + ).toThrow(RouteNotConfiguredError) + }) +}) + +describe('RouteNotConfiguredError', () => { + test('has code and statusCode fields for error-handling middleware', () => { + const err = new RouteNotConfiguredError('eip155:8453', 'eip155:999999') + expect(err.code).toBe('ROUTE_NOT_CONFIGURED') + expect(err.statusCode).toBe(404) + expect(err.message).toContain('eip155:8453') + expect(err.message).toContain('eip155:999999') + }) +}) diff --git a/packages/relay/src/bazaar/aggregations.ts b/packages/relay/src/bazaar/aggregations.ts new file mode 100644 index 0000000..218e10f --- /dev/null +++ b/packages/relay/src/bazaar/aggregations.ts @@ -0,0 +1,577 @@ +import { sql, and, eq, like, desc, asc, inArray, type SQL } from 'drizzle-orm' +import { db, bazaarResources, sellers, transactions } from '@402md/shared/db' +import { networks, getGasAllowance, UnsupportedNetworkError } from '@402md/shared/networks' + +export type Window = '1d' | '7d' | '30d' + +export function parseWindow(raw: string | undefined): Window { + if (raw === '1d' || raw === '7d' || raw === '30d') return raw + return '7d' +} + +function windowInterval(w: Window): SQL { + if (w === '1d') return sql`NOW() - INTERVAL '1 day'` + if (w === '7d') return sql`NOW() - INTERVAL '7 days'` + return sql`NOW() - INTERVAL '30 days'` +} + +function normalizeType(raw: string): 'same_chain' | 'cross_chain' | string { + if (raw === 'SAME_CHAIN') return 'same_chain' + if (raw === 'BRIDGE_SETTLEMENT') return 'cross_chain' + return raw +} + +function denormalizeType(raw: string): string { + if (raw === 'same_chain') return 'SAME_CHAIN' + if (raw === 'cross_chain') return 'BRIDGE_SETTLEMENT' + return raw +} + +export class RouteNotConfiguredError extends Error { + readonly code = 'ROUTE_NOT_CONFIGURED' + readonly statusCode = 404 + constructor(from: string, to: string) { + super(`No gas schedule for route ${from} → ${to}`) + } +} + +// ---------------------------------------------------------------------------- +// 1. getStats +// ---------------------------------------------------------------------------- + +export interface StatsResponse { + window: Window + chainsActive: number + chainsSupported: number + uniqueRoutes: number + crossChainRoutes: number + sameChainRoutes: number + totalVolume: string + txCount: number + protocolSplit: Record + typeSplit: Record +} + +export async function getStats({ window }: { window: Window }): Promise { + const interval = windowInterval(window) + + const rows = await db.execute<{ + unique_routes: string + cross_chain_routes: string + same_chain_routes: string + tx_count: string + total_volume: string | null + chains_active: string + }>(sql` + SELECT + COUNT(DISTINCT (buyer_network || '|' || seller_network))::text AS unique_routes, + COUNT(DISTINCT CASE WHEN buyer_network <> seller_network THEN buyer_network || '|' || seller_network END)::text AS cross_chain_routes, + COUNT(DISTINCT CASE WHEN buyer_network = seller_network THEN buyer_network || '|' || seller_network END)::text AS same_chain_routes, + COUNT(*)::text AS tx_count, + COALESCE(SUM(gross_amount), 0)::text AS total_volume, + COUNT(DISTINCT x.net)::text AS chains_active + FROM transactions t, + LATERAL (VALUES (t.buyer_network), (t.seller_network)) AS x(net) + WHERE t.created_at > ${interval} AND t.status = 'settled' + `) + + const protocolRows = await db.execute<{ protocol: string; count: string }>(sql` + SELECT protocol, COUNT(*)::text AS count + FROM transactions + WHERE created_at > ${interval} AND status = 'settled' + GROUP BY protocol + `) + + const typeRows = await db.execute<{ type: string; count: string }>(sql` + SELECT type, COUNT(*)::text AS count + FROM transactions + WHERE created_at > ${interval} AND status = 'settled' + GROUP BY type + `) + + const head = rows[0] ?? { + unique_routes: '0', + cross_chain_routes: '0', + same_chain_routes: '0', + tx_count: '0', + total_volume: '0', + chains_active: '0', + } + + const protocolSplit: Record = {} + for (const r of protocolRows) protocolSplit[r.protocol] = Number(r.count) + + const typeSplit: Record = {} + for (const r of typeRows) typeSplit[normalizeType(r.type)] = Number(r.count) + + return { + window, + chainsActive: Number(head.chains_active), + chainsSupported: networks.length, + uniqueRoutes: Number(head.unique_routes), + crossChainRoutes: Number(head.cross_chain_routes), + sameChainRoutes: Number(head.same_chain_routes), + totalVolume: head.total_volume ?? '0', + txCount: Number(head.tx_count), + protocolSplit, + typeSplit, + } +} + +// ---------------------------------------------------------------------------- +// 2. getRoutes +// ---------------------------------------------------------------------------- + +export interface RouteRow { + buyerNetwork: string + sellerNetwork: string + txCount: number + volume: string + isCrossChain: boolean +} + +export interface RoutesResponse { + window: Window + routes: RouteRow[] +} + +export async function getRoutes({ window }: { window: Window }): Promise { + const interval = windowInterval(window) + + const rows = await db.execute<{ + buyer_network: string + seller_network: string + tx_count: string + volume: string | null + }>(sql` + SELECT buyer_network, seller_network, COUNT(*)::text AS tx_count, COALESCE(SUM(gross_amount), 0)::text AS volume + FROM transactions + WHERE created_at > ${interval} AND status = 'settled' + GROUP BY buyer_network, seller_network + ORDER BY SUM(gross_amount) DESC NULLS LAST + `) + + return { + window, + routes: rows.map((r) => ({ + buyerNetwork: r.buyer_network, + sellerNetwork: r.seller_network, + txCount: Number(r.tx_count), + volume: r.volume ?? '0', + isCrossChain: r.buyer_network !== r.seller_network, + })), + } +} + +// ---------------------------------------------------------------------------- +// 3. getRankedResources +// ---------------------------------------------------------------------------- + +export interface RankedResourceAccept { + scheme: string + network: string + payTo: string + amount: string +} + +export interface RankedResourceItem { + resource: string + type: 'http' + description: string | null + merchantId: string + accepts: RankedResourceAccept[] + useCount: number + totalVolume: string + windowedUseCount: number + windowedVolume: string + lastUsedAt: string | null +} + +export interface RankedResourcesResponse { + window: Window + items: RankedResourceItem[] + total: number +} + +export interface RankedResourcesParams { + window: Window + rank: 'uses' | 'volume' + network?: string + q?: string + limit: number + offset: number +} + +export async function getRankedResources( + params: RankedResourcesParams, +): Promise { + const { window, rank, network, q, limit, offset } = params + const interval = windowInterval(window) + + const conditions: SQL[] = [] + if (network) conditions.push(eq(bazaarResources.network, network)) + if (q) conditions.push(like(bazaarResources.resourceUrl, `%${q}%`)) + const where = conditions.length > 0 ? and(...conditions) : undefined + + const rows = await db + .select({ + id: bazaarResources.id, + resourceUrl: bazaarResources.resourceUrl, + description: bazaarResources.description, + merchantId: bazaarResources.merchantId, + network: bazaarResources.network, + payTo: bazaarResources.payTo, + amount: bazaarResources.amount, + scheme: bazaarResources.scheme, + useCount: bazaarResources.useCount, + totalVolume: bazaarResources.totalVolume, + lastUsedAt: bazaarResources.lastUsedAt, + }) + .from(bazaarResources) + .where(where) + .orderBy(desc(bazaarResources.useCount), desc(bazaarResources.lastUsedAt)) + + const windowed = await db.execute<{ + resource_url: string + use_count: string + volume: string | null + }>(sql` + SELECT b.resource_url, + COUNT(t.id)::text AS use_count, + COALESCE(SUM(t.gross_amount), 0)::text AS volume + FROM bazaar_resources b + LEFT JOIN transactions t + ON t.seller_id = b.seller_id + AND t.seller_network = b.network + AND t.created_at > ${interval} + AND t.status = 'settled' + GROUP BY b.resource_url + `) + + const windowedByUrl = new Map() + for (const w of windowed) { + windowedByUrl.set(w.resource_url, { + useCount: Number(w.use_count), + volume: w.volume ?? '0', + }) + } + + type GroupedEntry = { + resource: string + type: 'http' + description: string | null + merchantId: string + accepts: RankedResourceAccept[] + useCount: number + totalVolume: string + windowedUseCount: number + windowedVolume: string + lastUsedAt: string | null + } + + const grouped = new Map() + + for (const r of rows) { + const existing = grouped.get(r.resourceUrl) + const accept: RankedResourceAccept = { + scheme: r.scheme, + network: r.network, + payTo: r.payTo, + amount: r.amount, + } + + if (existing) { + existing.accepts.push(accept) + existing.useCount += r.useCount + existing.totalVolume = (BigInt(existing.totalVolume) + BigInt(r.totalVolume)).toString() + const lastIso = r.lastUsedAt?.toISOString() ?? null + if (lastIso && (!existing.lastUsedAt || lastIso > existing.lastUsedAt)) { + existing.lastUsedAt = lastIso + } + } else { + const w = windowedByUrl.get(r.resourceUrl) + grouped.set(r.resourceUrl, { + resource: r.resourceUrl, + type: 'http', + description: r.description, + merchantId: r.merchantId, + accepts: [accept], + useCount: r.useCount, + totalVolume: r.totalVolume ?? '0', + windowedUseCount: w?.useCount ?? 0, + windowedVolume: w?.volume ?? '0', + lastUsedAt: r.lastUsedAt?.toISOString() ?? null, + }) + } + } + + const items = [...grouped.values()] + + if (rank === 'volume') { + items.sort((a, b) => { + const diff = BigInt(b.windowedVolume) - BigInt(a.windowedVolume) + return diff > 0n ? 1 : diff < 0n ? -1 : 0 + }) + } else { + items.sort((a, b) => b.windowedUseCount - a.windowedUseCount) + } + + const paginated = items.slice(offset, offset + limit) + + return { window, items: paginated, total: items.length } +} + +// ---------------------------------------------------------------------------- +// 4. getRankedSellers +// ---------------------------------------------------------------------------- + +export interface RankedSellerItem { + merchantId: string + primaryNetwork: string + txCount: number + volume: string + resourceCount: number + firstSeenAt: string | null +} + +export interface RankedSellersResponse { + window: Window + items: RankedSellerItem[] + total: number +} + +export interface RankedSellersParams { + window: Window + rank: 'volume' | 'tx_count' + network?: string + limit: number + offset: number +} + +export async function getRankedSellers( + params: RankedSellersParams, +): Promise { + const { window, rank, network, limit, offset } = params + const interval = windowInterval(window) + + const orderColumn = rank === 'tx_count' ? sql`tx_count` : sql`volume` + + const networkFilter = network ? sql`AND s.network = ${network}` : sql`` + + const rows = await db.execute<{ + merchant_id: string + primary_network: string + first_seen_at: Date | null + tx_count: string + volume: string | null + resource_count: string + }>(sql` + SELECT s.merchant_id, + s.network AS primary_network, + s.created_at AS first_seen_at, + COUNT(t.id)::text AS tx_count, + COALESCE(SUM(t.gross_amount), 0)::text AS volume, + (SELECT COUNT(*)::text FROM bazaar_resources WHERE seller_id = s.id) AS resource_count + FROM sellers s + LEFT JOIN transactions t + ON t.seller_id = s.id + AND t.created_at > ${interval} + AND t.status = 'settled' + WHERE 1 = 1 ${networkFilter} + GROUP BY s.id + ORDER BY ${orderColumn} DESC NULLS LAST + LIMIT ${limit} OFFSET ${offset} + `) + + const totalRows = await db.execute<{ total: string }>(sql` + SELECT COUNT(*)::text AS total FROM sellers s WHERE 1 = 1 ${networkFilter} + `) + + const items: RankedSellerItem[] = rows.map((r) => ({ + merchantId: r.merchant_id, + primaryNetwork: r.primary_network, + txCount: Number(r.tx_count), + volume: r.volume ?? '0', + resourceCount: Number(r.resource_count), + firstSeenAt: r.first_seen_at?.toISOString() ?? null, + })) + + return { window, items, total: Number(totalRows[0]?.total ?? 0) } +} + +// ---------------------------------------------------------------------------- +// 5. getTransactions +// ---------------------------------------------------------------------------- + +export interface TransactionItem { + id: string + type: string + protocol: string | null + status: string + buyerNetwork: string + sellerNetwork: string + merchantId: string | null + grossAmount: string + netAmount: string | null + gasAllowance: string | null + pullTxHash: string | null + burnTxHash: string | null + mintTxHash: string | null + createdAt: string | null + settledAt: string | null +} + +export interface TransactionsResponse { + items: TransactionItem[] + total: number + limit: number + offset: number +} + +export interface TransactionsParams { + merchantId?: string + buyerNetwork?: string + sellerNetwork?: string + status?: string + protocol?: string + type?: string + limit: number + offset: number +} + +export async function getTransactions(params: TransactionsParams): Promise { + const { merchantId, buyerNetwork, sellerNetwork, status, protocol, type, limit, offset } = params + + const conditions: SQL[] = [] + if (merchantId) conditions.push(eq(sellers.merchantId, merchantId)) + if (buyerNetwork) conditions.push(eq(transactions.buyerNetwork, buyerNetwork)) + if (sellerNetwork) conditions.push(eq(transactions.sellerNetwork, sellerNetwork)) + if (status) conditions.push(eq(transactions.status, status)) + if (protocol) conditions.push(eq(transactions.protocol, protocol)) + if (type) conditions.push(eq(transactions.type, denormalizeType(type))) + + const where = conditions.length > 0 ? and(...conditions) : undefined + + const rows = await db + .select({ + id: transactions.id, + type: transactions.type, + protocol: transactions.protocol, + status: transactions.status, + buyerNetwork: transactions.buyerNetwork, + sellerNetwork: transactions.sellerNetwork, + merchantId: sellers.merchantId, + grossAmount: transactions.grossAmount, + netAmount: transactions.netAmount, + gasAllowance: transactions.gasAllowance, + pullTxHash: transactions.pullTxHash, + burnTxHash: transactions.burnTxHash, + mintTxHash: transactions.mintTxHash, + createdAt: transactions.createdAt, + settledAt: transactions.settledAt, + }) + .from(transactions) + .leftJoin(sellers, eq(sellers.id, transactions.sellerId)) + .where(where) + .orderBy(desc(transactions.createdAt)) + .limit(limit) + .offset(offset) + + const totalRows = await db + .select({ count: sql`COUNT(*)::text` }) + .from(transactions) + .leftJoin(sellers, eq(sellers.id, transactions.sellerId)) + .where(where) + + const items: TransactionItem[] = rows.map((r) => ({ + id: r.id, + type: normalizeType(r.type), + protocol: r.protocol, + status: r.status, + buyerNetwork: r.buyerNetwork, + sellerNetwork: r.sellerNetwork, + merchantId: r.merchantId, + grossAmount: r.grossAmount, + netAmount: r.netAmount, + gasAllowance: r.gasAllowance, + pullTxHash: r.pullTxHash, + burnTxHash: r.burnTxHash, + mintTxHash: r.mintTxHash, + createdAt: r.createdAt?.toISOString() ?? null, + settledAt: r.settledAt?.toISOString() ?? null, + })) + + return { items, total: Number(totalRows[0]?.count ?? '0'), limit, offset } +} + +// ---------------------------------------------------------------------------- +// 6. getCostComparison +// ---------------------------------------------------------------------------- + +export interface CostComparisonTier { + amount: string + cctpAllowance: string + percentAlternative: string + savingsVsPercent: string +} + +export interface CostComparisonResponse { + buyerNetwork: string + sellerNetwork: string + tiers: CostComparisonTier[] + notes: { + cctpSource: string + percentAssumption: string + } +} + +const COST_COMPARISON_TIERS = ['100000', '1000000', '100000000'] +const PERCENT_BPS = 100n // 1% = 100 bps +const PERCENT_MIN_FLOOR = 100000n // $0.10 baseline spread + +function computePercentAlternative(amount: string): string { + const gross = BigInt(amount) + const onePercent = (gross * PERCENT_BPS) / 10000n + return (onePercent > PERCENT_MIN_FLOOR ? onePercent : PERCENT_MIN_FLOOR).toString() +} + +export function getCostComparison(input: { + buyerNetwork: string + sellerNetwork: string +}): CostComparisonResponse { + const { buyerNetwork, sellerNetwork } = input + + let allowance: string + try { + allowance = getGasAllowance(buyerNetwork, sellerNetwork) + } catch (err) { + if (err instanceof UnsupportedNetworkError || err instanceof Error) { + throw new RouteNotConfiguredError(buyerNetwork, sellerNetwork) + } + throw err + } + + const tiers: CostComparisonTier[] = COST_COMPARISON_TIERS.map((amount) => { + const percent = computePercentAlternative(amount) + const savings = (BigInt(percent) - BigInt(allowance)).toString() + return { + amount, + cctpAllowance: allowance, + percentAlternative: percent, + savingsVsPercent: savings, + } + }) + + return { + buyerNetwork, + sellerNetwork, + tiers, + notes: { + cctpSource: 'gas-schedule.ts', + percentAssumption: '1% + $0.10 market-maker spread baseline', + }, + } +} + +// Re-export for callers that want to filter by merchantId via sellers join +export { inArray, asc } diff --git a/packages/relay/src/bazaar/bazaar.routes.ts b/packages/relay/src/bazaar/bazaar.routes.ts index 528a241..8f730d8 100644 --- a/packages/relay/src/bazaar/bazaar.routes.ts +++ b/packages/relay/src/bazaar/bazaar.routes.ts @@ -1,5 +1,24 @@ import { Elysia, t } from 'elysia' import { listResources } from './bazaar.service' +import { bazaarCacheKey, cached } from './cache' +import { + parseWindow, + getStats, + getRoutes, + getRankedResources, + getRankedSellers, + getTransactions, + getCostComparison, + RouteNotConfiguredError, +} from './aggregations' + +const CACHE_TTL = 60 + +function toLimitOffset(limitRaw: string | undefined, offsetRaw: string | undefined) { + const limit = Math.min(Math.max(limitRaw ? Number(limitRaw) : 20, 1), 100) + const offset = Math.max(offsetRaw ? Number(offsetRaw) : 0, 0) + return { limit, offset } +} export const bazaarRoutes = new Elysia({ prefix: '/discovery' }).get( '/resources', @@ -22,3 +41,135 @@ export const bazaarRoutes = new Elysia({ prefix: '/discovery' }).get( }), }, ) + +export const bazaarEnrichedRoutes = new Elysia({ prefix: '/bazaar' }) + .get( + '/stats', + async ({ query }) => { + const window = parseWindow(query.window) + const { value } = await cached(bazaarCacheKey('stats', { window }), CACHE_TTL, () => + getStats({ window }), + ) + return value + }, + { + query: t.Object({ window: t.Optional(t.String()) }), + }, + ) + .get( + '/routes', + async ({ query }) => { + const window = parseWindow(query.window) + const { value } = await cached(bazaarCacheKey('routes', { window }), CACHE_TTL, () => + getRoutes({ window }), + ) + return value + }, + { + query: t.Object({ window: t.Optional(t.String()) }), + }, + ) + .get( + '/resources', + async ({ query }) => { + const window = parseWindow(query.window) + const rank = query.rank === 'volume' ? 'volume' : 'uses' + const { limit, offset } = toLimitOffset(query.limit, query.offset) + const params = { window, rank, network: query.network, q: query.q, limit, offset } + const { value } = await cached(bazaarCacheKey('resources', params), CACHE_TTL, () => + getRankedResources(params), + ) + return value + }, + { + query: t.Object({ + window: t.Optional(t.String()), + rank: t.Optional(t.String()), + network: t.Optional(t.String()), + q: t.Optional(t.String()), + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + }), + }, + ) + .get( + '/sellers', + async ({ query }) => { + const window = parseWindow(query.window) + const rank = query.rank === 'tx_count' ? 'tx_count' : 'volume' + const { limit, offset } = toLimitOffset(query.limit, query.offset) + const params = { window, rank, network: query.network, limit, offset } + const { value } = await cached(bazaarCacheKey('sellers', params), CACHE_TTL, () => + getRankedSellers(params), + ) + return value + }, + { + query: t.Object({ + window: t.Optional(t.String()), + rank: t.Optional(t.String()), + network: t.Optional(t.String()), + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + }), + }, + ) + .get( + '/transactions', + async ({ query }) => { + const { limit, offset } = toLimitOffset(query.limit, query.offset) + const params = { + merchantId: query.merchantId, + buyerNetwork: query.buyerNetwork, + sellerNetwork: query.sellerNetwork, + status: query.status, + protocol: query.protocol, + type: query.type, + limit, + offset, + } + const { value } = await cached(bazaarCacheKey('transactions', params), CACHE_TTL, () => + getTransactions(params), + ) + return value + }, + { + query: t.Object({ + merchantId: t.Optional(t.String()), + buyerNetwork: t.Optional(t.String()), + sellerNetwork: t.Optional(t.String()), + status: t.Optional(t.String()), + protocol: t.Optional(t.String()), + type: t.Optional(t.String()), + limit: t.Optional(t.String()), + offset: t.Optional(t.String()), + }), + }, + ) + .get( + '/cost-comparison', + ({ query, set }) => { + if (!query.buyerNetwork || !query.sellerNetwork) { + set.status = 400 + return { error: 'MISSING_PARAMS', message: 'buyerNetwork and sellerNetwork are required' } + } + try { + return getCostComparison({ + buyerNetwork: query.buyerNetwork, + sellerNetwork: query.sellerNetwork, + }) + } catch (err) { + if (err instanceof RouteNotConfiguredError) { + set.status = 404 + return { error: err.code, message: err.message } + } + throw err + } + }, + { + query: t.Object({ + buyerNetwork: t.Optional(t.String()), + sellerNetwork: t.Optional(t.String()), + }), + }, + ) diff --git a/packages/relay/src/bazaar/cache.ts b/packages/relay/src/bazaar/cache.ts new file mode 100644 index 0000000..24e6780 --- /dev/null +++ b/packages/relay/src/bazaar/cache.ts @@ -0,0 +1,26 @@ +import { redis } from '@402md/shared/cache' + +export interface CachedResult { + value: T + hit: boolean +} + +export async function cached( + key: string, + ttlSeconds: number, + compute: () => Promise, +): Promise> { + const raw = await redis.get(key) + if (raw) return { value: JSON.parse(raw) as T, hit: true } + const value = await compute() + await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds) + return { value, hit: false } +} + +export function bazaarCacheKey(endpoint: string, params: Record): string { + const sorted = Object.keys(params) + .sort() + .map((k) => `${k}=${params[k] ?? ''}`) + .join('&') + return `402md:bazaar:${endpoint}:${sorted}` +} diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index c964c94..8baa182 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -17,7 +17,7 @@ import { sellersRoutes } from '@/sellers/sellers.routes' import { settlementsRoutes } from '@/settlements/settlements.routes' import { onrampRoutes } from '@/onramp/onramp.routes' import { stellarMppRoutes } from '@/mpp/stellar-mpp.routes' -import { bazaarRoutes } from '@/bazaar/bazaar.routes' +import { bazaarRoutes, bazaarEnrichedRoutes } from '@/bazaar/bazaar.routes' import { db } from '@402md/shared/db' import { redis } from '@402md/shared/cache' import { getTemporalClient } from '@/shared/temporal' @@ -50,9 +50,15 @@ export const app = new Elysia() .use(onrampRoutes) .use(stellarMppRoutes) .use(bazaarRoutes) + .use(bazaarEnrichedRoutes) .get('/', () => Bun.file(new URL('../public/index.html', import.meta.url).pathname), { detail: { hide: true }, }) + .get( + '/dashboard', + () => Bun.file(new URL('../public/dashboard.html', import.meta.url).pathname), + { detail: { hide: true } }, + ) .get('/cover.mp4', () => Bun.file(new URL('../public/cover.mp4', import.meta.url).pathname)) .get('/health', async () => { const checks = { diff --git a/packages/relay/src/shared/rate-limit.ts b/packages/relay/src/shared/rate-limit.ts index 30b7bf9..ffe4e8e 100644 --- a/packages/relay/src/shared/rate-limit.ts +++ b/packages/relay/src/shared/rate-limit.ts @@ -15,6 +15,12 @@ const RATE_LIMITS: Record = { '/mpp/config': { maxRequests: 100, windowSeconds: 60 }, '/mpp/charge': { maxRequests: 500, windowSeconds: 60 }, '/bazaar': { maxRequests: 100, windowSeconds: 60 }, + '/bazaar/stats': { maxRequests: 200, windowSeconds: 60 }, + '/bazaar/routes': { maxRequests: 200, windowSeconds: 60 }, + '/bazaar/resources': { maxRequests: 200, windowSeconds: 60 }, + '/bazaar/sellers': { maxRequests: 200, windowSeconds: 60 }, + '/bazaar/transactions': { maxRequests: 200, windowSeconds: 60 }, + '/bazaar/cost-comparison': { maxRequests: 200, windowSeconds: 60 }, } export async function checkRateLimit(path: string, ip: string): Promise { From b4a2cf60e4b2638638cc446b5a64b99810a7b3ab Mon Sep 17 00:00:00 2001 From: Henrique Breim Date: Sat, 18 Apr 2026 00:36:48 +0200 Subject: [PATCH 02/11] fix(bazaar): harden pagination, cache, and query paths --- packages/relay/drizzle/0003_cooing_sentry.sql | 2 + .../relay/drizzle/meta/0003_snapshot.json | 546 ++++++++++++++++++ packages/relay/drizzle/meta/_journal.json | 7 + packages/relay/public/dashboard.html | 2 +- .../relay/src/bazaar/aggregations.test.ts | 34 ++ packages/relay/src/bazaar/aggregations.ts | 78 +-- packages/relay/src/bazaar/bazaar.routes.ts | 16 +- packages/relay/src/bazaar/cache.ts | 9 +- packages/shared/src/db/schema.ts | 2 + 9 files changed, 654 insertions(+), 42 deletions(-) create mode 100644 packages/relay/drizzle/0003_cooing_sentry.sql create mode 100644 packages/relay/drizzle/meta/0003_snapshot.json diff --git a/packages/relay/drizzle/0003_cooing_sentry.sql b/packages/relay/drizzle/0003_cooing_sentry.sql new file mode 100644 index 0000000..60d1f4c --- /dev/null +++ b/packages/relay/drizzle/0003_cooing_sentry.sql @@ -0,0 +1,2 @@ +CREATE INDEX "idx_transactions_buyer_network" ON "transactions" USING btree ("buyer_network");--> statement-breakpoint +CREATE INDEX "idx_transactions_seller_network" ON "transactions" USING btree ("seller_network"); \ No newline at end of file diff --git a/packages/relay/drizzle/meta/0003_snapshot.json b/packages/relay/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..e40f95e --- /dev/null +++ b/packages/relay/drizzle/meta/0003_snapshot.json @@ -0,0 +1,546 @@ +{ + "id": "dec484ad-a09e-49f6-b3b2-0f1ccfbe6c1c", + "prevId": "489e6a6b-3e17-4921-8216-15d9f418c3f3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.bazaar_resources": { + "name": "bazaar_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resource_url": { + "name": "resource_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seller_id": { + "name": "seller_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merchant_id": { + "name": "merchant_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "pay_to": { + "name": "pay_to", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "scheme": { + "name": "scheme", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'exact'" + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "total_volume": { + "name": "total_volume", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_bazaar_resource_network": { + "name": "uniq_bazaar_resource_network", + "columns": [ + { + "expression": "resource_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bazaar_resources_seller": { + "name": "idx_bazaar_resources_seller", + "columns": [ + { + "expression": "seller_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bazaar_resources_base_url": { + "name": "idx_bazaar_resources_base_url", + "columns": [ + { + "expression": "base_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bazaar_resources_use_count": { + "name": "idx_bazaar_resources_use_count", + "columns": [ + { + "expression": "use_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bazaar_resources_seller_id_sellers_id_fk": { + "name": "bazaar_resources_seller_id_sellers_id_fk", + "tableFrom": "bazaar_resources", + "tableTo": "sellers", + "columnsFrom": ["seller_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sellers": { + "name": "sellers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merchant_id": { + "name": "merchant_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "wallet_address": { + "name": "wallet_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sellers_merchant": { + "name": "idx_sellers_merchant", + "columns": [ + { + "expression": "merchant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sellers_merchant_id_unique": { + "name": "sellers_merchant_id_unique", + "nullsNotDistinct": false, + "columns": ["merchant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "buyer_address": { + "name": "buyer_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "buyer_network": { + "name": "buyer_network", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "seller_address": { + "name": "seller_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "seller_network": { + "name": "seller_network", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "seller_id": { + "name": "seller_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gross_amount": { + "name": "gross_amount", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "net_amount": { + "name": "net_amount", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": false + }, + "platform_fee": { + "name": "platform_fee", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": false + }, + "gas_allowance": { + "name": "gas_allowance", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "stripe_fee": { + "name": "stripe_fee", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": false + }, + "pull_tx_hash": { + "name": "pull_tx_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "burn_tx_hash": { + "name": "burn_tx_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "mint_tx_hash": { + "name": "mint_tx_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bridge_provider": { + "name": "bridge_provider", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_transactions_seller": { + "name": "idx_transactions_seller", + "columns": [ + { + "expression": "seller_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transactions_status": { + "name": "idx_transactions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transactions_created": { + "name": "idx_transactions_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transactions_seller_date": { + "name": "idx_transactions_seller_date", + "columns": [ + { + "expression": "seller_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transactions_workflow": { + "name": "idx_transactions_workflow", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transactions_buyer": { + "name": "idx_transactions_buyer", + "columns": [ + { + "expression": "buyer_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transactions_buyer_network": { + "name": "idx_transactions_buyer_network", + "columns": [ + { + "expression": "buyer_network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_transactions_seller_network": { + "name": "idx_transactions_seller_network", + "columns": [ + { + "expression": "seller_network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_seller_id_sellers_id_fk": { + "name": "transactions_seller_id_sellers_id_fk", + "tableFrom": "transactions", + "tableTo": "sellers", + "columnsFrom": ["seller_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/relay/drizzle/meta/_journal.json b/packages/relay/drizzle/meta/_journal.json index 3eca88f..a8975b8 100644 --- a/packages/relay/drizzle/meta/_journal.json +++ b/packages/relay/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1775793460521, "tag": "0002_plain_sunfire", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1776465361800, + "tag": "0003_cooing_sentry", + "breakpoints": true } ] } diff --git a/packages/relay/public/dashboard.html b/packages/relay/public/dashboard.html index 735427a..6ff6a9a 100644 --- a/packages/relay/public/dashboard.html +++ b/packages/relay/public/dashboard.html @@ -827,7 +827,7 @@

Cross-chain cost comparison

getJson(`/bazaar/routes?window=${w}`), getJson(`/bazaar/resources?window=${w}&rank=${state.resourceRank}&limit=10`), getJson(`/bazaar/sellers?window=${w}&rank=${state.sellerRank}&limit=10`), - getJson(`/bazaar/transactions?limit=20`), + getJson(`/bazaar/transactions?window=${w}&limit=20`), ]) if (results[0].status === 'fulfilled') renderStats(results[0].value) diff --git a/packages/relay/src/bazaar/aggregations.test.ts b/packages/relay/src/bazaar/aggregations.test.ts index 7863ef8..bc48c38 100644 --- a/packages/relay/src/bazaar/aggregations.test.ts +++ b/packages/relay/src/bazaar/aggregations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import '@/shared/test-helpers' import { parseWindow, getCostComparison, RouteNotConfiguredError } from './aggregations' +import { toLimitOffset } from './bazaar.routes' describe('parseWindow', () => { test('accepts 1d / 7d / 30d', () => { @@ -84,3 +85,36 @@ describe('RouteNotConfiguredError', () => { expect(err.message).toContain('eip155:999999') }) }) + +describe('toLimitOffset', () => { + test('defaults when both undefined', () => { + expect(toLimitOffset(undefined, undefined)).toEqual({ limit: 20, offset: 0 }) + }) + + test('parses valid numeric strings', () => { + expect(toLimitOffset('50', '10')).toEqual({ limit: 50, offset: 10 }) + }) + + test('clamps limit to max 100', () => { + expect(toLimitOffset('500', '0')).toEqual({ limit: 100, offset: 0 }) + }) + + test('clamps limit to min 1', () => { + expect(toLimitOffset('0', '0')).toEqual({ limit: 1, offset: 0 }) + expect(toLimitOffset('-5', '0')).toEqual({ limit: 1, offset: 0 }) + }) + + test('clamps offset to min 0', () => { + expect(toLimitOffset('20', '-10')).toEqual({ limit: 20, offset: 0 }) + }) + + test('falls back to defaults when input is not a finite number', () => { + // This is the NaN regression: Number('abc') = NaN, must not reach SQL + expect(toLimitOffset('abc', 'xyz')).toEqual({ limit: 20, offset: 0 }) + expect(toLimitOffset('Infinity', 'NaN')).toEqual({ limit: 20, offset: 0 }) + }) + + test('handles empty string as missing (falls back to defaults)', () => { + expect(toLimitOffset('', '')).toEqual({ limit: 20, offset: 0 }) + }) +}) diff --git a/packages/relay/src/bazaar/aggregations.ts b/packages/relay/src/bazaar/aggregations.ts index 218e10f..4431af9 100644 --- a/packages/relay/src/bazaar/aggregations.ts +++ b/packages/relay/src/bazaar/aggregations.ts @@ -1,4 +1,4 @@ -import { sql, and, eq, like, desc, asc, inArray, type SQL } from 'drizzle-orm' +import { sql, and, eq, like, desc, type SQL } from 'drizzle-orm' import { db, bazaarResources, sellers, transactions } from '@402md/shared/db' import { networks, getGasAllowance, UnsupportedNetworkError } from '@402md/shared/networks' @@ -62,31 +62,36 @@ export async function getStats({ window }: { window: Window }): Promise | null + type_split: Record | null }>(sql` - SELECT - COUNT(DISTINCT (buyer_network || '|' || seller_network))::text AS unique_routes, - COUNT(DISTINCT CASE WHEN buyer_network <> seller_network THEN buyer_network || '|' || seller_network END)::text AS cross_chain_routes, - COUNT(DISTINCT CASE WHEN buyer_network = seller_network THEN buyer_network || '|' || seller_network END)::text AS same_chain_routes, - COUNT(*)::text AS tx_count, - COALESCE(SUM(gross_amount), 0)::text AS total_volume, - COUNT(DISTINCT x.net)::text AS chains_active - FROM transactions t, - LATERAL (VALUES (t.buyer_network), (t.seller_network)) AS x(net) - WHERE t.created_at > ${interval} AND t.status = 'settled' - `) - - const protocolRows = await db.execute<{ protocol: string; count: string }>(sql` - SELECT protocol, COUNT(*)::text AS count - FROM transactions - WHERE created_at > ${interval} AND status = 'settled' - GROUP BY protocol - `) - - const typeRows = await db.execute<{ type: string; count: string }>(sql` - SELECT type, COUNT(*)::text AS count - FROM transactions - WHERE created_at > ${interval} AND status = 'settled' - GROUP BY type + WITH base AS ( + SELECT * FROM transactions + WHERE created_at > ${interval} AND status = 'settled' + ), + stats AS ( + SELECT + COUNT(DISTINCT (buyer_network || '|' || seller_network))::text AS unique_routes, + COUNT(DISTINCT CASE WHEN buyer_network <> seller_network THEN buyer_network || '|' || seller_network END)::text AS cross_chain_routes, + COUNT(DISTINCT CASE WHEN buyer_network = seller_network THEN buyer_network || '|' || seller_network END)::text AS same_chain_routes, + COUNT(*)::text AS tx_count, + COALESCE(SUM(gross_amount), 0)::text AS total_volume + FROM base + ), + chains AS ( + SELECT COUNT(DISTINCT x.net)::text AS chains_active + FROM base t, LATERAL (VALUES (t.buyer_network), (t.seller_network)) AS x(net) + ), + protocols AS ( + SELECT COALESCE(jsonb_object_agg(protocol, cnt), '{}'::jsonb) AS protocol_split + FROM (SELECT protocol, COUNT(*)::int AS cnt FROM base GROUP BY protocol) p + ), + types AS ( + SELECT COALESCE(jsonb_object_agg(type, cnt), '{}'::jsonb) AS type_split + FROM (SELECT type, COUNT(*)::int AS cnt FROM base GROUP BY type) t + ) + SELECT stats.*, chains.chains_active, protocols.protocol_split, types.type_split + FROM stats, chains, protocols, types `) const head = rows[0] ?? { @@ -96,13 +101,17 @@ export async function getStats({ window }: { window: Window }): Promise = {} - for (const r of protocolRows) protocolSplit[r.protocol] = Number(r.count) + const protocolSplit: Record = { ...(head.protocol_split ?? {}) } + const rawTypeSplit = head.type_split ?? {} const typeSplit: Record = {} - for (const r of typeRows) typeSplit[normalizeType(r.type)] = Number(r.count) + for (const [rawType, count] of Object.entries(rawTypeSplit)) { + typeSplit[normalizeType(rawType)] = count + } return { window, @@ -429,6 +438,7 @@ export interface TransactionsResponse { } export interface TransactionsParams { + window?: Window merchantId?: string buyerNetwork?: string sellerNetwork?: string @@ -440,9 +450,11 @@ export interface TransactionsParams { } export async function getTransactions(params: TransactionsParams): Promise { - const { merchantId, buyerNetwork, sellerNetwork, status, protocol, type, limit, offset } = params + const { window, merchantId, buyerNetwork, sellerNetwork, status, protocol, type, limit, offset } = + params const conditions: SQL[] = [] + if (window) conditions.push(sql`${transactions.createdAt} > ${windowInterval(window)}`) if (merchantId) conditions.push(eq(sellers.merchantId, merchantId)) if (buyerNetwork) conditions.push(eq(transactions.buyerNetwork, buyerNetwork)) if (sellerNetwork) conditions.push(eq(transactions.sellerNetwork, sellerNetwork)) @@ -545,7 +557,10 @@ export function getCostComparison(input: { try { allowance = getGasAllowance(buyerNetwork, sellerNetwork) } catch (err) { - if (err instanceof UnsupportedNetworkError || err instanceof Error) { + if (err instanceof UnsupportedNetworkError) { + throw new RouteNotConfiguredError(buyerNetwork, sellerNetwork) + } + if (err instanceof Error && err.message.startsWith('No gas schedule for')) { throw new RouteNotConfiguredError(buyerNetwork, sellerNetwork) } throw err @@ -572,6 +587,3 @@ export function getCostComparison(input: { }, } } - -// Re-export for callers that want to filter by merchantId via sellers join -export { inArray, asc } diff --git a/packages/relay/src/bazaar/bazaar.routes.ts b/packages/relay/src/bazaar/bazaar.routes.ts index 8f730d8..e808d46 100644 --- a/packages/relay/src/bazaar/bazaar.routes.ts +++ b/packages/relay/src/bazaar/bazaar.routes.ts @@ -13,10 +13,17 @@ import { } from './aggregations' const CACHE_TTL = 60 +const DEFAULT_LIMIT = 20 +const DEFAULT_OFFSET = 0 +const MAX_LIMIT = 100 -function toLimitOffset(limitRaw: string | undefined, offsetRaw: string | undefined) { - const limit = Math.min(Math.max(limitRaw ? Number(limitRaw) : 20, 1), 100) - const offset = Math.max(offsetRaw ? Number(offsetRaw) : 0, 0) +export function toLimitOffset(limitRaw: string | undefined, offsetRaw: string | undefined) { + const parsedLimit = limitRaw ? Number(limitRaw) : DEFAULT_LIMIT + const limit = Number.isFinite(parsedLimit) + ? Math.min(Math.max(parsedLimit, 1), MAX_LIMIT) + : DEFAULT_LIMIT + const parsedOffset = offsetRaw ? Number(offsetRaw) : DEFAULT_OFFSET + const offset = Number.isFinite(parsedOffset) ? Math.max(parsedOffset, 0) : DEFAULT_OFFSET return { limit, offset } } @@ -118,7 +125,9 @@ export const bazaarEnrichedRoutes = new Elysia({ prefix: '/bazaar' }) '/transactions', async ({ query }) => { const { limit, offset } = toLimitOffset(query.limit, query.offset) + const window = query.window ? parseWindow(query.window) : undefined const params = { + window, merchantId: query.merchantId, buyerNetwork: query.buyerNetwork, sellerNetwork: query.sellerNetwork, @@ -135,6 +144,7 @@ export const bazaarEnrichedRoutes = new Elysia({ prefix: '/bazaar' }) }, { query: t.Object({ + window: t.Optional(t.String()), merchantId: t.Optional(t.String()), buyerNetwork: t.Optional(t.String()), sellerNetwork: t.Optional(t.String()), diff --git a/packages/relay/src/bazaar/cache.ts b/packages/relay/src/bazaar/cache.ts index 24e6780..54eb21e 100644 --- a/packages/relay/src/bazaar/cache.ts +++ b/packages/relay/src/bazaar/cache.ts @@ -18,9 +18,8 @@ export async function cached( } export function bazaarCacheKey(endpoint: string, params: Record): string { - const sorted = Object.keys(params) - .sort() - .map((k) => `${k}=${params[k] ?? ''}`) - .join('&') - return `402md:bazaar:${endpoint}:${sorted}` + const sortedKeys = Object.keys(params).sort() + const canonical: Record = {} + for (const k of sortedKeys) canonical[k] = params[k] ?? null + return `402md:bazaar:${endpoint}:${JSON.stringify(canonical)}` } diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index a1d3d98..e434cef 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -82,5 +82,7 @@ export const transactions = pgTable( index('idx_transactions_seller_date').on(table.sellerId, table.createdAt), index('idx_transactions_workflow').on(table.workflowId), index('idx_transactions_buyer').on(table.buyerAddress), + index('idx_transactions_buyer_network').on(table.buyerNetwork), + index('idx_transactions_seller_network').on(table.sellerNetwork), ], ) From 07690add74689bdf28d850c3c05096566d4e4014 Mon Sep 17 00:00:00 2001 From: Henrique Breim Date: Sat, 18 Apr 2026 00:42:46 +0200 Subject: [PATCH 03/11] feat(landing): add dashboard link to nav --- packages/relay/public/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/relay/public/index.html b/packages/relay/public/index.html index f50502f..fb456d6 100644 --- a/packages/relay/public/index.html +++ b/packages/relay/public/index.html @@ -1270,6 +1270,7 @@ How it works Pricing FAQ + Dashboard Register now From 9699de566ebf534deae0d2f5102f59aaa098c78f Mon Sep 17 00:00:00 2001 From: Henrique Breim Date: Sat, 18 Apr 2026 00:44:21 +0200 Subject: [PATCH 04/11] style(dashboard): reuse landing nav for visual consistency --- packages/relay/public/dashboard.html | 95 +++++++++++++++++++++------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/packages/relay/public/dashboard.html b/packages/relay/public/dashboard.html index 6ff6a9a..5f02a27 100644 --- a/packages/relay/public/dashboard.html +++ b/packages/relay/public/dashboard.html @@ -80,29 +80,72 @@ .container { max-width: 1200px; margin: 0 auto; - padding: var(--space-xl) var(--space-lg); + padding: 0 var(--space-lg); } - header.site { + /* Nav — must match packages/relay/public/index.html */ + .nav { + position: sticky; + top: 0; + z-index: 100; + background: oklch(0.98 0.003 250 / 0.88); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + height: 56px; + } + .nav-inner { display: flex; align-items: center; - justify-content: space-between; - margin-bottom: var(--space-2xl); + height: 100%; + gap: var(--space-lg); } - - header.site .brand { - font-family: 'Bricolage Grotesque', sans-serif; - font-weight: 600; - font-size: 1.25rem; - letter-spacing: -0.01em; + .nav-brand { + display: flex; + align-items: center; + gap: var(--space-sm); + font-family: 'JetBrains Mono', monospace; + font-weight: 500; + font-size: 0.875rem; + letter-spacing: -0.02em; } - - header.site nav { + .nav-links { display: flex; - gap: var(--space-lg); - font-size: 0.9rem; + align-items: center; + gap: var(--space-md); + margin-left: auto; + font-size: 0.75rem; color: var(--text-2); } + .nav-links a { + transition: color 150ms; + } + .nav-links a:hover { + color: var(--text); + } + .nav-links a[aria-current='page'] { + color: var(--text); + font-weight: 500; + } + .nav-cta { + display: inline-flex; + align-items: center; + padding: var(--space-sm) var(--space-md); + background: var(--text); + color: var(--bg); + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + transition: background-color 150ms; + } + .nav-cta:hover { + background: oklch(0.25 0.02 250); + text-decoration: none; + } + + main.dashboard { + padding: var(--space-xl) 0; + } h2 { font-family: 'Bricolage Grotesque', sans-serif; @@ -370,16 +413,20 @@ -
-
-
402md — bazaar
- -
+ +

Architecture

@@ -468,7 +515,7 @@

Cross-chain cost comparison

402md facilitator
Data refreshes every 30s
-
+
- - diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index 8baa182..f78c445 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -12,7 +12,9 @@ if (!envCheck.ok) { import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' import { swagger } from '@elysiajs/swagger' +import { html } from '@elysiajs/html' import { sql } from 'drizzle-orm' +import { DashboardPage } from '@/views/dashboard/page' import { sellersRoutes } from '@/sellers/sellers.routes' import { settlementsRoutes } from '@/settlements/settlements.routes' import { onrampRoutes } from '@/onramp/onramp.routes' @@ -25,6 +27,7 @@ import { checkRateLimit } from '@/shared/rate-limit' export const app = new Elysia() .use(cors()) + .use(html()) .use( swagger({ documentation: { @@ -54,11 +57,7 @@ export const app = new Elysia() .get('/', () => Bun.file(new URL('../public/index.html', import.meta.url).pathname), { detail: { hide: true }, }) - .get( - '/dashboard', - () => Bun.file(new URL('../public/dashboard.html', import.meta.url).pathname), - { detail: { hide: true } }, - ) + .get('/dashboard', () => DashboardPage(), { detail: { hide: true } }) .get('/cover.mp4', () => Bun.file(new URL('../public/cover.mp4', import.meta.url).pathname)) .get('/health', async () => { const checks = { diff --git a/packages/relay/src/views/dashboard/client-script.ts b/packages/relay/src/views/dashboard/client-script.ts new file mode 100644 index 0000000..fa7d8c0 --- /dev/null +++ b/packages/relay/src/views/dashboard/client-script.ts @@ -0,0 +1,384 @@ +// Dashboard polling + render script. Rendered as a raw + + +) diff --git a/packages/relay/src/views/dashboard/styles.ts b/packages/relay/src/views/dashboard/styles.ts new file mode 100644 index 0000000..8ef4a83 --- /dev/null +++ b/packages/relay/src/views/dashboard/styles.ts @@ -0,0 +1,251 @@ +export const dashboardStyles = /* css */ ` + main.dashboard { + padding: var(--space-xl) 0; + } + + h2 { + font-family: 'Bricolage Grotesque', sans-serif; + font-weight: 500; + font-size: 1.1rem; + letter-spacing: -0.01em; + margin-bottom: var(--space-md); + } + + section { + margin-bottom: var(--space-2xl); + } + + .snapshot { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-md); + } + + .stat { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-lg); + } + .stat .label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-3); + margin-bottom: var(--space-xs); + } + .stat .value { + font-family: 'Bricolage Grotesque', sans-serif; + font-size: 2rem; + font-weight: 500; + letter-spacing: -0.02em; + } + .stat .sub { + font-size: 0.8rem; + color: var(--text-2); + margin-top: var(--space-xs); + } + + .window-toggle { + display: inline-flex; + gap: 2px; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2px; + } + .window-toggle button { + background: transparent; + border: none; + font: inherit; + color: var(--text-2); + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + } + .window-toggle button.active { + background: var(--text); + color: var(--bg); + } + + .rank-toggle { + display: inline-flex; + gap: var(--space-sm); + font-size: 0.8rem; + color: var(--text-2); + } + .rank-toggle button { + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px 10px; + font: inherit; + color: var(--text-2); + cursor: pointer; + } + .rank-toggle button.active { + background: var(--text); + color: var(--bg); + border-color: var(--text); + } + + .section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-md); + } + .section-head h2 { + margin-bottom: 0; + } + + .chain-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--space-sm); + } + .chain-card { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-md); + } + .chain-card .chain-name { + font-weight: 500; + font-size: 0.9rem; + margin-bottom: var(--space-xs); + } + .chain-card .chain-metric { + font-size: 0.8rem; + color: var(--text-2); + } + .chain-card .chain-metric strong { + color: var(--text); + font-family: 'JetBrains Mono', monospace; + } + + .route-matrix { + overflow-x: auto; + } + table.matrix { + border-collapse: collapse; + font-size: 0.75rem; + } + table.matrix th, + table.matrix td { + border: 1px solid var(--border); + padding: 6px 8px; + text-align: center; + white-space: nowrap; + } + table.matrix th { + background: var(--bg-alt); + color: var(--text-2); + font-weight: 500; + } + table.matrix th.corner { + background: transparent; + border: none; + } + table.matrix td { + font-family: 'JetBrains Mono', monospace; + } + table.matrix td.same-chain { + border-style: dashed; + } + table.matrix td.empty { + color: var(--text-3); + } + + table.list { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + } + table.list th, + table.list td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + } + table.list th { + font-weight: 500; + color: var(--text-3); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + table.list td.num { + font-family: 'JetBrains Mono', monospace; + text-align: right; + } + table.list td.mono { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + } + + .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.02em; + } + .badge.same { background: var(--green-soft); color: var(--green); } + .badge.cross { background: var(--accent-soft); color: var(--accent); } + .badge.x402 { background: var(--purple-soft); color: var(--purple); } + .badge.mpp { background: var(--accent-soft); color: var(--accent); } + .badge.status-settled { background: var(--green-soft); color: var(--green); } + .badge.status-pending { background: var(--bg-alt); color: var(--text-2); } + .badge.status-failed { + background: oklch(0.96 0.03 20); + color: oklch(0.5 0.17 20); + } + + .panel { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-lg); + } + .cost-note { + font-size: 0.75rem; + color: var(--text-3); + margin-top: var(--space-md); + font-style: italic; + } + + .empty, + .error { + padding: var(--space-lg); + text-align: center; + color: var(--text-3); + font-size: 0.85rem; + background: var(--bg-alt); + border: 1px dashed var(--border-mid); + border-radius: var(--radius); + } + .error { + color: var(--warn); + border-color: var(--warn); + } + + footer.site { + margin-top: var(--space-2xl); + padding-top: var(--space-lg); + border-top: 1px solid var(--border); + font-size: 0.75rem; + color: var(--text-3); + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-sm); + } + + .loading { + color: var(--text-3); + font-size: 0.8rem; + font-style: italic; + } +` diff --git a/packages/relay/src/views/layout.tsx b/packages/relay/src/views/layout.tsx new file mode 100644 index 0000000..3c2b2bc --- /dev/null +++ b/packages/relay/src/views/layout.tsx @@ -0,0 +1,39 @@ +import { sharedStyles } from './shared-styles' + +interface LayoutProps { + title: string + description?: string + themeColor?: string + extraStyles?: string + children?: string | string[] +} + +export const Layout = ({ + title, + description, + themeColor = '#2a5cdb', + extraStyles, + children, +}: LayoutProps) => ( + <> + {''} + + + + + + {title} + {description && } + + + + + {extraStyles && } + + {children} + + +) diff --git a/packages/relay/src/views/nav.tsx b/packages/relay/src/views/nav.tsx new file mode 100644 index 0000000..e55bb57 --- /dev/null +++ b/packages/relay/src/views/nav.tsx @@ -0,0 +1,26 @@ +export type NavPage = 'dashboard' + +interface NavProps { + current?: NavPage +} + +export const Nav = ({ current }: NavProps) => ( + +) diff --git a/packages/relay/src/views/shared-styles.ts b/packages/relay/src/views/shared-styles.ts new file mode 100644 index 0000000..8859bd7 --- /dev/null +++ b/packages/relay/src/views/shared-styles.ts @@ -0,0 +1,123 @@ +// Base CSS tokens + global reset + container + nav styles. +// Shared across every page served by the relay. Kept as a string so it can be +// inlined once into — no external file fetch, no FOUC. +export const sharedStyles = /* css */ ` + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + :root { + --bg: oklch(0.98 0.003 250); + --bg-alt: oklch(0.96 0.006 250); + --text: oklch(0.15 0.02 250); + --text-2: oklch(0.45 0.02 250); + --text-3: oklch(0.55 0.015 250); + --border: oklch(0.91 0.008 250); + --border-mid: oklch(0.85 0.012 250); + --accent: oklch(0.48 0.2 250); + --accent-soft: oklch(0.93 0.04 250); + --green: oklch(0.55 0.16 145); + --green-soft: oklch(0.96 0.025 145); + --purple: oklch(0.55 0.18 300); + --purple-soft: oklch(0.96 0.025 300); + --warn: oklch(0.62 0.18 60); + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + --radius: 6px; + --radius-lg: 10px; + } + + html { + scroll-behavior: smooth; + } + + body { + font-family: 'Red Hat Text', system-ui, sans-serif; + color: var(--text); + background: var(--bg); + font-size: 0.95rem; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + } + + a { + color: var(--accent); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + + .container { + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--space-lg); + } + + .nav { + position: sticky; + top: 0; + z-index: 100; + background: oklch(0.98 0.003 250 / 0.88); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + height: 56px; + } + .nav-inner { + display: flex; + align-items: center; + height: 100%; + gap: var(--space-lg); + } + .nav-brand { + display: flex; + align-items: center; + gap: var(--space-sm); + font-family: 'JetBrains Mono', monospace; + font-weight: 500; + font-size: 0.875rem; + letter-spacing: -0.02em; + } + .nav-links { + display: flex; + align-items: center; + gap: var(--space-md); + margin-left: auto; + font-size: 0.75rem; + color: var(--text-2); + } + .nav-links a { + transition: color 150ms; + } + .nav-links a:hover { + color: var(--text); + } + .nav-links a[aria-current='page'] { + color: var(--text); + font-weight: 500; + } + .nav-cta { + display: inline-flex; + align-items: center; + padding: var(--space-sm) var(--space-md); + background: var(--text); + color: var(--bg); + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + transition: background-color 150ms; + } + .nav-cta:hover { + background: oklch(0.25 0.02 250); + text-decoration: none; + } +` diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json index 15a5e83..c8df646 100644 --- a/packages/relay/tsconfig.json +++ b/packages/relay/tsconfig.json @@ -7,6 +7,8 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", "paths": { "@/*": ["./src/*"] } }, "include": ["src"] From 0caa146edfd4302b3baaf597384a76879e2e2bb0 Mon Sep 17 00:00:00 2001 From: Henrique Breim Date: Sat, 18 Apr 2026 01:10:07 +0200 Subject: [PATCH 06/11] refactor(landing): render via JSX so nav is shared with dashboard --- packages/relay/public/index.html | 2145 ----------------- packages/relay/src/index.ts | 5 +- packages/relay/src/views/landing/assets.d.ts | 14 + packages/relay/src/views/landing/content.html | 564 +++++ packages/relay/src/views/landing/page.test.ts | 30 + packages/relay/src/views/landing/page.tsx | 17 + packages/relay/src/views/landing/script.txt | 290 +++ packages/relay/src/views/landing/styles.css | 1236 ++++++++++ 8 files changed, 2153 insertions(+), 2148 deletions(-) delete mode 100644 packages/relay/public/index.html create mode 100644 packages/relay/src/views/landing/assets.d.ts create mode 100644 packages/relay/src/views/landing/content.html create mode 100644 packages/relay/src/views/landing/page.test.ts create mode 100644 packages/relay/src/views/landing/page.tsx create mode 100644 packages/relay/src/views/landing/script.txt create mode 100644 packages/relay/src/views/landing/styles.css diff --git a/packages/relay/public/index.html b/packages/relay/public/index.html deleted file mode 100644 index fb456d6..0000000 --- a/packages/relay/public/index.html +++ /dev/null @@ -1,2145 +0,0 @@ - - - - - - - 402md Facilitator — Cross-chain USDC settlement for x402 and MPP - - - - - - - - - - - - - - -
- -
-
-
-

- Any chain in, - your chain out. -

- -

- Cross-chain USDC settlement for x402 (Coinbase's HTTP 402 protocol) and MPP (Machine - Payments Protocol). Buyer pays on Solana, seller receives on Stellar. No SDK, no - dashboard, no code changes. -

- - -
-
-
- - -
-
- -
-
-
- -
- terminal -
-
# 1. Register your wallet (one-time)
-curl -X POST https://facilitator.402.md/register \
-  -H "Content-Type: application/json" \
-  -d '{"wallet": "GSELLER...", "network": "stellar:pubnet"}'
-
-# 2. Use standard @x402/express — zero 402md code
-import { paymentMiddleware } from "@x402/express"
-
-app.use(paymentMiddleware({
-  "GET /your-endpoint": {
-    accepts: [
-      { scheme: "exact", network: "eip155:8453", payTo: "0x4C95...c3b9", price: "$0.001", extra: { merchantId: "7c-vQZHANEd5ICp" } },
-      { scheme: "exact", network: "solana:mainnet", payTo: "4r7q...BDP5", price: "$0.001", extra: { merchantId: "7c-vQZHANEd5ICp" } },
-      { scheme: "exact", network: "stellar:pubnet", payTo: "GAX5...5HZR", price: "$0.001", extra: { merchantId: "7c-vQZHANEd5ICp" } },
-    ],
-  },
-}, "https://facilitator.402.md"))
-
-# Buyers on Base, Solana, or Stellar can now pay you.
-
-
-
- - -
-
- -

One chain is not enough

-

- x402 lets any API charge for access. But every facilitator today locks sellers to a - single chain — and leaves money on the table. -

- -
-
-

Chain lock-in

-

- 30 x402 facilitators, all single-chain. Buyer on Solana, seller on Stellar? Payment - fails. Revenue lost silently. -

-
-
-

Wrong tokens, wrong path

-

- DEX-based bridges mean wrapped USDC, slippage, and risk. Native USDC via CCTP V2 is - the only clean path. -

-
-
-

Fragmented protocols

-

x402 and MPP serve different ecosystems. No one speaks both — until now.

-
-
-

Agents don't choose chains

-

- AI agents need to pay, not navigate blockchain infrastructure. The payment layer - should be invisible. -

-
-
-
-
- - -
-
- -

Three steps. That's it.

- -
-
-
Seller
-

One curl to start receiving

-
-
- 01 -
-

Register your wallet

-

- POST /register with your address and chain. Get - facilitator addresses on all supported chains. -

-
-
-
- 02 -
-

Use standard x402

-

- Add @x402/express middleware. Point - payTo to your facilitator address. Zero 402md - dependencies. -

-
-
-
- 03 -
-

Receive USDC on your chain

-

- Buyer pays on any chain. 402md settles via CCTP V2. Native USDC, directly to - your wallet. -

-
-
-
-
- -
-
Buyer / Agent
-

Pay from any chain

-
-
- 01 -
-

Discover the seller

-

- GET the API. Receive a 402 response with facilitator - addresses on your chain. -

-
-
-
- 02 -
-

Pay with USDC

-

- Sign a standard x402 payment on Base - Solana or - Stellar. No bridge, no swap. -

-
-
-
- 03 -
-

Get the resource

-

- Payment verified in milliseconds. Resource delivered instantly. Settlement - runs in the background. -

-
-
-
-
-
-
-
- - -
-
- -

Opinionated choices, for good reasons

-

- Every decision optimizes for one thing: the seller should do zero extra work to accept - payments from any chain. -

- -
-
-

Non-custodial

-

- CCTP V2 mints USDC directly to the seller's wallet. 402md never holds your funds. -

-
-
-

Free

-

- 0% platform fee. Only network gas deducted from settlement. Fixed schedule, no - surprises. -

-
-
-

Zero dependencies

-

- Sellers use standard @x402/express from Coinbase. No 402md SDK, no custom packages. -

-
-
- -
-
-

Dual protocol

-

x402 and MPP through one facilitator. Two ecosystems, one registration.

-
-
-

Native USDC only

-

Circle CCTP V2 burns and mints real USDC. No wrapped tokens, no slippage.

-
-
-

Three chains

-

Base, Solana, Stellar. Adding a new EVM chain is a config change, not a deploy.

-
-
- - -
-
- - -
-
- -

Free. Seriously.

-

- Zero platform fee. The only cost is a fixed gas allowance per route, deducted from the - seller's payout. Here's what that looks like on a $1.00 payment. -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RouteSeller receivesGas allowance
- Stellar → - Stellar - $0.99999$0.00001 0.001%
- Solana → - Solana - $0.9990$0.0010 0.10%
- Base → - Base - $0.9980$0.0020 0.20%
- Stellar → - Solana - $0.9985$0.0015 0.15%
- Solana → - Stellar - $0.9975$0.0025 0.25%
- Stellar → - Base - $0.9975$0.0025 0.25%
- Base → - Stellar - $0.9968$0.0032 0.32%
- Base → - Solana - $0.9965$0.0035 0.35%
- Solana → - Base - $0.9965$0.0035 0.35%
-
-
-
- - -
-
- -

Common questions

- -
-
- What is Facilitator by 402md? -
- A cross-chain USDC settlement facilitator built by 402md for the x402 and MPP - protocols. Sellers register once and receive payments from buyers on any supported - chain — Base, Solana, or Stellar. -
-
- -
- How is this different from other x402 facilitators? -
- Existing facilitators are single-chain. If a buyer is on Solana and the seller is on - Stellar, the payment fails. 402md bridges that gap using Circle CCTP V2 — atomic, - non-custodial, native USDC. No wrapped tokens, no slippage. -
-
- -
- What chains are supported? -
- Base (EVM), Solana, and Stellar — all connected via Circle CCTP V2. Adding new EVM - chains requires only a config update, no contract deployments. -
-
- -
- How much does it cost? -
- Zero platform fee. The only deduction is a fixed gas allowance per route — for - example, $0.0032 for Base→Stellar on a $1.00 payment. Same-chain payments cost - even less ($0.00001 for Stellar→Stellar). -
-
- -
- Is it custodial? -
- No. Cross-chain settlements use Circle CCTP V2, which mints USDC directly to the - seller's wallet. 402md never holds seller funds. The facilitator only retains the - gas allowance needed to execute the settlement. -
-
- -
- How long does settlement take? -
- Same-chain: under 5 seconds. Stellar or Solana cross-chain: 5-30 seconds. EVM - cross-chain: 15-19 minutes, limited by block finality. The buyer gets the resource - instantly — settlement happens in the background. -
-
- -
- Do I need to install any 402md package? -
- No. Sellers use the standard @x402/express SDK from Coinbase. The only - 402md-specific step is a one-time POST /register call — after that, everything works - with standard x402 tooling. -
-
- -
- What about same-chain payments? -
- If buyer and seller are on the same chain, 402md does a direct USDC transfer — no - bridge involved. Faster settlement, lower gas, same API. -
-
- -
- What is MPP? -
- Machine Payments Protocol, developed by Stellar and used by services like OpenAI, - Anthropic, and Dune. 402md supports MPP alongside x402, enabling AI agents from both - ecosystems to transact through one facilitator. -
-
-
-
-
- - -
-
- -

One curl. All chains.

-

- Register your wallet and start accepting cross-chain USDC in under a minute. No - dashboard, no login, no SDK. -

- -
- USDC - Base - Solana - Stellar -
- -
-
-
- -
- - - - -
- - -
- - - - - - - - -
- - - -

0x followed by 40 hex characters

- -
- - API docs ↗ -
-
-
- - -
-
-
-
-
- - - - - - - diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index f78c445..3fd50de 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -14,6 +14,7 @@ import { cors } from '@elysiajs/cors' import { swagger } from '@elysiajs/swagger' import { html } from '@elysiajs/html' import { sql } from 'drizzle-orm' +import { LandingPage } from '@/views/landing/page' import { DashboardPage } from '@/views/dashboard/page' import { sellersRoutes } from '@/sellers/sellers.routes' import { settlementsRoutes } from '@/settlements/settlements.routes' @@ -54,9 +55,7 @@ export const app = new Elysia() .use(stellarMppRoutes) .use(bazaarRoutes) .use(bazaarEnrichedRoutes) - .get('/', () => Bun.file(new URL('../public/index.html', import.meta.url).pathname), { - detail: { hide: true }, - }) + .get('/', () => LandingPage(), { detail: { hide: true } }) .get('/dashboard', () => DashboardPage(), { detail: { hide: true } }) .get('/cover.mp4', () => Bun.file(new URL('../public/cover.mp4', import.meta.url).pathname)) .get('/health', async () => { diff --git a/packages/relay/src/views/landing/assets.d.ts b/packages/relay/src/views/landing/assets.d.ts new file mode 100644 index 0000000..c8f91b0 --- /dev/null +++ b/packages/relay/src/views/landing/assets.d.ts @@ -0,0 +1,14 @@ +declare module '*.html' { + const content: string + export default content +} + +declare module '*.css' { + const content: string + export default content +} + +declare module '*.txt' { + const content: string + export default content +} diff --git a/packages/relay/src/views/landing/content.html b/packages/relay/src/views/landing/content.html new file mode 100644 index 0000000..85cfdc7 --- /dev/null +++ b/packages/relay/src/views/landing/content.html @@ -0,0 +1,564 @@ +
+ +
+
+
+

+ Any chain in, + your chain out. +

+ +

+ Cross-chain USDC settlement for x402 (Coinbase's HTTP 402 protocol) and MPP (Machine + Payments Protocol). Buyer pays on Solana, seller receives on Stellar. No SDK, no + dashboard, no code changes. +

+ + +
+
+
+ + +
+
+ +
+
+
+ +
+ terminal +
+
# 1. Register your wallet (one-time)
+curl -X POST https://facilitator.402.md/register \
+  -H "Content-Type: application/json" \
+  -d '{"wallet": "GSELLER...", "network": "stellar:pubnet"}'
+
+# 2. Use standard @x402/express — zero 402md code
+import { paymentMiddleware } from "@x402/express"
+
+app.use(paymentMiddleware({
+  "GET /your-endpoint": {
+    accepts: [
+      { scheme: "exact", network: "eip155:8453", payTo: "0x4C95...c3b9", price: "$0.001", extra: { merchantId: "7c-vQZHANEd5ICp" } },
+      { scheme: "exact", network: "solana:mainnet", payTo: "4r7q...BDP5", price: "$0.001", extra: { merchantId: "7c-vQZHANEd5ICp" } },
+      { scheme: "exact", network: "stellar:pubnet", payTo: "GAX5...5HZR", price: "$0.001", extra: { merchantId: "7c-vQZHANEd5ICp" } },
+    ],
+  },
+}, "https://facilitator.402.md"))
+
+# Buyers on Base, Solana, or Stellar can now pay you.
+
+
+
+ + +
+
+ +

One chain is not enough

+

+ x402 lets any API charge for access. But every facilitator today locks sellers to a single + chain — and leaves money on the table. +

+ +
+
+

Chain lock-in

+

+ 30 x402 facilitators, all single-chain. Buyer on Solana, seller on Stellar? Payment + fails. Revenue lost silently. +

+
+
+

Wrong tokens, wrong path

+

+ DEX-based bridges mean wrapped USDC, slippage, and risk. Native USDC via CCTP V2 is the + only clean path. +

+
+
+

Fragmented protocols

+

x402 and MPP serve different ecosystems. No one speaks both — until now.

+
+
+

Agents don't choose chains

+

+ AI agents need to pay, not navigate blockchain infrastructure. The payment layer should + be invisible. +

+
+
+
+
+ + +
+
+ +

Three steps. That's it.

+ +
+
+
Seller
+

One curl to start receiving

+
+
+ 01 +
+

Register your wallet

+

+ POST /register with your address and chain. Get + facilitator addresses on all supported chains. +

+
+
+
+ 02 +
+

Use standard x402

+

+ Add @x402/express middleware. Point + payTo to your facilitator address. Zero 402md + dependencies. +

+
+
+
+ 03 +
+

Receive USDC on your chain

+

+ Buyer pays on any chain. 402md settles via CCTP V2. Native USDC, directly to your + wallet. +

+
+
+
+
+ +
+
Buyer / Agent
+

Pay from any chain

+
+
+ 01 +
+

Discover the seller

+

+ GET the API. Receive a 402 response with facilitator + addresses on your chain. +

+
+
+
+ 02 +
+

Pay with USDC

+

+ Sign a standard x402 payment on Base + Solana or + Stellar. No bridge, no swap. +

+
+
+
+ 03 +
+

Get the resource

+

+ Payment verified in milliseconds. Resource delivered instantly. Settlement runs in + the background. +

+
+
+
+
+
+
+
+ + +
+
+ +

Opinionated choices, for good reasons

+

+ Every decision optimizes for one thing: the seller should do zero extra work to accept + payments from any chain. +

+ +
+
+

Non-custodial

+

CCTP V2 mints USDC directly to the seller's wallet. 402md never holds your funds.

+
+
+

Free

+

+ 0% platform fee. Only network gas deducted from settlement. Fixed schedule, no + surprises. +

+
+
+

Zero dependencies

+

Sellers use standard @x402/express from Coinbase. No 402md SDK, no custom packages.

+
+
+ +
+
+

Dual protocol

+

x402 and MPP through one facilitator. Two ecosystems, one registration.

+
+
+

Native USDC only

+

Circle CCTP V2 burns and mints real USDC. No wrapped tokens, no slippage.

+
+
+

Three chains

+

Base, Solana, Stellar. Adding a new EVM chain is a config change, not a deploy.

+
+
+ + +
+
+ + +
+
+ +

Free. Seriously.

+

+ Zero platform fee. The only cost is a fixed gas allowance per route, deducted from the + seller's payout. Here's what that looks like on a $1.00 payment. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RouteSeller receivesGas allowance
+ Stellar → + Stellar + $0.99999$0.00001 0.001%
+ Solana → + Solana + $0.9990$0.0010 0.10%
+ Base → + Base + $0.9980$0.0020 0.20%
+ Stellar → + Solana + $0.9985$0.0015 0.15%
+ Solana → + Stellar + $0.9975$0.0025 0.25%
+ Stellar → + Base + $0.9975$0.0025 0.25%
+ Base → + Stellar + $0.9968$0.0032 0.32%
+ Base → + Solana + $0.9965$0.0035 0.35%
+ Solana → + Base + $0.9965$0.0035 0.35%
+
+
+
+ + +
+
+ +

Common questions

+ +
+
+ What is Facilitator by 402md? +
+ A cross-chain USDC settlement facilitator built by 402md for the x402 and MPP protocols. + Sellers register once and receive payments from buyers on any supported chain — Base, + Solana, or Stellar. +
+
+ +
+ How is this different from other x402 facilitators? +
+ Existing facilitators are single-chain. If a buyer is on Solana and the seller is on + Stellar, the payment fails. 402md bridges that gap using Circle CCTP V2 — atomic, + non-custodial, native USDC. No wrapped tokens, no slippage. +
+
+ +
+ What chains are supported? +
+ Base (EVM), Solana, and Stellar — all connected via Circle CCTP V2. Adding new EVM + chains requires only a config update, no contract deployments. +
+
+ +
+ How much does it cost? +
+ Zero platform fee. The only deduction is a fixed gas allowance per route — for example, + $0.0032 for Base→Stellar on a $1.00 payment. Same-chain payments cost even less + ($0.00001 for Stellar→Stellar). +
+
+ +
+ Is it custodial? +
+ No. Cross-chain settlements use Circle CCTP V2, which mints USDC directly to the + seller's wallet. 402md never holds seller funds. The facilitator only retains the gas + allowance needed to execute the settlement. +
+
+ +
+ How long does settlement take? +
+ Same-chain: under 5 seconds. Stellar or Solana cross-chain: 5-30 seconds. EVM + cross-chain: 15-19 minutes, limited by block finality. The buyer gets the resource + instantly — settlement happens in the background. +
+
+ +
+ Do I need to install any 402md package? +
+ No. Sellers use the standard @x402/express SDK from Coinbase. The only 402md-specific + step is a one-time POST /register call — after that, everything works with standard x402 + tooling. +
+
+ +
+ What about same-chain payments? +
+ If buyer and seller are on the same chain, 402md does a direct USDC transfer — no bridge + involved. Faster settlement, lower gas, same API. +
+
+ +
+ What is MPP? +
+ Machine Payments Protocol, developed by Stellar and used by services like OpenAI, + Anthropic, and Dune. 402md supports MPP alongside x402, enabling AI agents from both + ecosystems to transact through one facilitator. +
+
+
+
+
+ + +
+
+ +

One curl. All chains.

+

+ Register your wallet and start accepting cross-chain USDC in under a minute. No dashboard, + no login, no SDK. +

+ +
+ USDC + Base + Solana + Stellar +
+ +
+
+
+ +
+ + + + +
+ + +
+ + + + + + + + +
+ + + +

0x followed by 40 hex characters

+ +
+ + API docs ↗ +
+
+
+ + +
+
+
+
+
+ + + diff --git a/packages/relay/src/views/landing/page.test.ts b/packages/relay/src/views/landing/page.test.ts new file mode 100644 index 0000000..2f042ea --- /dev/null +++ b/packages/relay/src/views/landing/page.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' +import '@/shared/test-helpers' +import { LandingPage } from './page' + +describe('LandingPage', () => { + const html = LandingPage() + + test('renders as a large HTML string with doctype', () => { + expect(typeof html).toBe('string') + expect(html.startsWith('')).toBe(true) + expect(html.length).toBeGreaterThan(50_000) + }) + + test('includes the shared Nav (same component as the dashboard)', () => { + expect(html).toContain('