diff --git a/src/app/api/chains/route.ts b/src/app/api/chains/route.ts index e8f0f9b..5065883 100644 --- a/src/app/api/chains/route.ts +++ b/src/app/api/chains/route.ts @@ -1,44 +1,6 @@ import { NextResponse } from 'next/server' import { createServerClient } from '@/lib/supabase' -import { ChainTokenService } from '@/services/chain-token-service' -import type { ChainType } from '@/lib/chain-registry' - -/** - * Normalize stale cached chain keys/types at read time. - * Fixes entries written before alias/type corrections were deployed. - */ -const KEY_ALIASES: Record = { - sol: 'solana', - btc: 'bitcoin', - doge: 'dogecoin', - '-239': 'ton', - '728126428': 'tron', - '1151111081099710': 'solana', - '23448594291968336': 'starknet', -} - -const KEY_TYPES: Record = { - solana: 'solana', - bitcoin: 'bitcoin', - dogecoin: 'bitcoin', - near: 'near', - tron: 'tron', - sui: 'sui', - ton: 'ton', - starknet: 'starknet', - aptos: 'aptos', - bch: 'bitcoin', - ltc: 'bitcoin', -} - -const KEY_NAMES: Record = { - solana: 'Solana', - bitcoin: 'Bitcoin', - dogecoin: 'Dogecoin', - ton: 'TON', - starknet: 'StarkNet', - tron: 'Tron', -} +import { getChains } from '@/lib/api/get-chains' /** * GET /api/chains?type=all|evm|solana|bitcoin&hasUSDC=true @@ -52,109 +14,19 @@ export async function GET(request: Request) { const supabase = createServerClient() - // Build query — fetch all, then filter after normalization - // (type filter can't be applied at DB level because stale rows have wrong types) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let query = supabase.from('cached_chains' as any).select('*') - - if (hasUSDC) { - query = query.eq('has_usdc', true) - } - - const { data, error } = await query.order('name') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const chains = data as any[] | null - - // If cache has data, return it - if (!error && chains && chains.length > 0) { - // Map DB rows back to UnifiedChain shape, normalizing stale keys/types - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const chainMap = new Map() - - for (const c of chains) { - const canonicalKey = KEY_ALIASES[c.key] || c.key - const correctType = KEY_TYPES[canonicalKey] || c.type - const correctName = (c.name === 'Sol' || c.name === 'Btc' || c.name === 'Doge') - ? (KEY_NAMES[canonicalKey] || c.name) - : c.name - - const existing = chainMap.get(canonicalKey) - if (existing) { - // Merge: prefer the entry with more provider support - const existingCount = Object.values(existing.providers || {}).filter(Boolean).length - const newCount = Object.values(c.providers || {}).filter(Boolean).length - if (newCount > existingCount) { - chainMap.set(canonicalKey, { - key: canonicalKey, - chainId: correctType !== 'evm' ? null : c.chain_id, - name: correctName, - type: correctType, - symbol: c.symbol, - logoUrl: existing.logoUrl || c.logo_url, - providers: { ...existing.providers, ...c.providers }, - providerIds: { ...existing.providerIds, ...c.provider_ids }, - }) - } else { - // Just merge provider flags into existing - existing.providers = { ...c.providers, ...existing.providers } - existing.providerIds = { ...c.provider_ids, ...existing.providerIds } - } - } else { - chainMap.set(canonicalKey, { - key: canonicalKey, - chainId: correctType !== 'evm' ? null : c.chain_id, - name: correctName, - type: correctType, - symbol: c.symbol, - logoUrl: c.logo_url, - providers: c.providers, - providerIds: c.provider_ids, - }) - } - } - - let mapped = Array.from(chainMap.values()) - - if (type !== 'all') { - mapped = mapped.filter((c) => c.type === type) - } - - return NextResponse.json({ - success: true, - chains: mapped, - total: mapped.length, - cached: true, - }) - } - - // Fallback: live fetch (first request before cron runs) - console.log('Cache empty, falling back to live chain fetch...') - const liveChains = await ChainTokenService.getChains() - - let filtered = type === 'all' - ? liveChains - : liveChains.filter((c) => c.type === type) - - // For hasUSDC on fallback, just use static map (no token fetching to avoid OOM) - if (hasUSDC) { - const { getUSDCAddress } = await import('@/lib/tokens') - filtered = filtered.filter(chain => { - const chainId = chain.chainId || chain.key - return !!getUSDCAddress(chainId) - }) - } + const result = await getChains(supabase, { type, hasUSDC }) return NextResponse.json({ success: true, - chains: filtered, - total: filtered.length, - cached: false, + chains: result.chains, + total: result.total, + cached: result.cached, }) } catch (error) { console.error('API Chains Error:', error) return NextResponse.json( { success: false, error: 'Failed to fetch chains' }, - { status: 500 } + { status: 500 }, ) } } diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index 573f5d5..6e92d09 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -1,32 +1,6 @@ import { NextResponse } from 'next/server' import { createServerClient } from '@/lib/supabase' -import { ChainTokenService } from '@/services/chain-token-service' -import { TOKENS } from '@/lib/tokens' -import { isSpamToken, buildCanonicalAddresses } from '@/lib/token-filter' - -/** Shape of a row in the cached_tokens Supabase table */ -interface CachedTokenRow { - address: string - symbol: string - name: string - decimals: number - logo_url: string | null - is_native: boolean - chain_key: string - provider_ids: Record | null -} - -/** Mapped token shape used for sorting and response */ -interface MappedToken { - address: string - symbol: string - name: string - decimals: number - logoUrl?: string - isNative: boolean - chainKey: string - providerIds: Record | null -} +import { getTokens } from '@/lib/api/get-tokens' /** * GET /api/tokens?chainKey=42161 @@ -47,117 +21,14 @@ export async function GET(request: Request) { const supabase = createServerClient() - // Read from cache — cached_tokens is not in generated DB types, cast the table name - const { data, error } = await supabase - .from('cached_tokens' as 'merchants') - .select('*') - .eq('chain_key' as 'id', chainKey) - - const tokens = data as unknown as CachedTokenRow[] | null - - // If cache has data, return it - if (!error && tokens && tokens.length > 0) { - const STABLECOIN_SYMBOLS = new Set(['USDC', 'USDT', 'DAI', 'USDC.e', 'USDbC']) - - // Build canonical address set from static TOKENS map - const canonicalAddresses = new Set() - const numKey = Number(chainKey) - // TOKENS uses numeric keys for EVM chains and string keys for non-EVM - const staticTokens = (!isNaN(numKey) ? TOKENS[numKey] : null) || TOKENS[chainKey] - if (staticTokens) { - for (const t of staticTokens) { - canonicalAddresses.add(t.address.toLowerCase()) - } - } - - const rawMapped: MappedToken[] = tokens.map((t) => ({ - address: t.address, - symbol: t.symbol, - name: t.name, - decimals: t.decimals, - logoUrl: t.logo_url || undefined, - isNative: t.is_native, - chainKey: t.chain_key, - providerIds: t.provider_ids, - })) - - // Filter spam from cached data (catches pre-existing unfiltered tokens) - const spamCanonical = buildCanonicalAddresses(chainKey) - const mapped = rawMapped.filter((t) => { - const count = - t.providerIds && typeof (t.providerIds as Record)._count === 'number' - ? (t.providerIds as Record)._count as number - : 1 - return !isSpamToken(t, count, spamCanonical) - }) - - // Inject canonical tokens that are missing from cache - if (staticTokens) { - const cachedAddresses = new Set(mapped.map((t) => t.address.toLowerCase())) - for (const st of staticTokens) { - if (!cachedAddresses.has(st.address.toLowerCase())) { - mapped.push({ - address: st.address, - symbol: st.symbol, - name: st.name, - decimals: st.decimals, - logoUrl: st.logoUrl, - isNative: st.isNative || false, - chainKey, - providerIds: null, - }) - } - } - } - - // Provider count: stored as _count in providerIds by mergeTokens(), or count object keys - const getProviderCount = (t: MappedToken) => { - if (!t.providerIds || typeof t.providerIds !== 'object') return 0 - if (typeof t.providerIds._count === 'number') return t.providerIds._count - return Object.keys(t.providerIds).filter((k) => k !== '_count').length - } - - // Sort: native first, then canonical stablecoins, then multi-provider stablecoins, - // then alphabetically - mapped.sort((a, b) => { - if (a.isNative && !b.isNative) return -1 - if (!a.isNative && b.isNative) return 1 - const aStable = STABLECOIN_SYMBOLS.has(a.symbol) - const bStable = STABLECOIN_SYMBOLS.has(b.symbol) - if (aStable && !bStable) return -1 - if (!aStable && bStable) return 1 - // Among stablecoins with the same symbol: canonical > provider count > alphabetical - if (aStable && bStable && a.symbol === b.symbol) { - const aCanonical = canonicalAddresses.has(a.address.toLowerCase()) - const bCanonical = canonicalAddresses.has(b.address.toLowerCase()) - if (aCanonical && !bCanonical) return -1 - if (!aCanonical && bCanonical) return 1 - const aCount = getProviderCount(a) - const bCount = getProviderCount(b) - if (aCount !== bCount) return bCount - aCount - } - return a.symbol.localeCompare(b.symbol) - }) - - return NextResponse.json({ - success: true, - tokens: mapped, - chainKey, - total: mapped.length, - cached: true, - }) - } - - // Fallback: live fetch - console.log(`Token cache empty for ${chainKey}, falling back to live fetch...`) - const liveTokens = await ChainTokenService.getTokens(chainKey) + const result = await getTokens(supabase, chainKey) return NextResponse.json({ success: true, - tokens: liveTokens, - chainKey, - total: liveTokens.length, - cached: false, + tokens: result.tokens, + chainKey: result.chainKey, + total: result.total, + cached: result.cached, }) } catch (error) { console.error('API Tokens Error:', error) diff --git a/src/app/api/v1/chains/route.ts b/src/app/api/v1/chains/route.ts new file mode 100644 index 0000000..1f93219 --- /dev/null +++ b/src/app/api/v1/chains/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServerClient } from '@/lib/supabase' +import { verifyApiKey } from '@/lib/api/verify-api-key' +import { getChains } from '@/lib/api/get-chains' + +/** + * GET /api/v1/chains?type=all|evm|solana&hasUSDC=true + */ +export async function GET(req: NextRequest) { + const { error } = await verifyApiKey(req) + + if (error) { + return NextResponse.json({ error }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const rawType = searchParams.get('type') || 'all' + const type = ['all', 'evm', 'solana', 'bitcoin', 'near', 'tron', 'sui', 'ton', 'starknet', 'aptos'].includes(rawType) ? rawType : 'all' + const hasUSDC = searchParams.get('hasUSDC') === 'true' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = createServerClient() as any + + const result = await getChains(supabase, { type, hasUSDC }) + + return NextResponse.json({ + data: result.chains, + total: result.total, + }) + } catch (err) { + console.error('V1 Chains Error:', err) + return NextResponse.json({ error: 'Failed to fetch chains' }, { status: 500 }) + } +} diff --git a/src/app/api/v1/merchant/route.ts b/src/app/api/v1/merchant/route.ts new file mode 100644 index 0000000..73381ea --- /dev/null +++ b/src/app/api/v1/merchant/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServerClient } from '@/lib/supabase' +import { verifyApiKey } from '@/lib/api/verify-api-key' +import { z } from 'zod' + +const updateMerchantSchema = z.object({ + default_receive_chain: z + .union([z.string(), z.number(), z.null()]) + .optional(), + default_receive_token: z.string().nullable().optional(), + business_name: z.string().max(255).nullable().optional(), + email: z.string().email().nullable().optional(), +}) + +export async function GET(req: NextRequest) { + const { merchant, error } = await verifyApiKey(req) + + if (error) { + return NextResponse.json({ error }, { status: 401 }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = createServerClient() as any + + const { data: merchantData, error: dbError } = await supabase + .from('merchants') + .select( + 'wallet_address, business_name, email, default_receive_chain, default_receive_token, stealth_enabled, created_at', + ) + .eq('id', merchant.id) + .single() + + if (dbError || !merchantData) { + return NextResponse.json({ error: 'Merchant not found' }, { status: 404 }) + } + + return NextResponse.json({ + wallet_address: merchantData.wallet_address, + business_name: merchantData.business_name, + email: merchantData.email, + default_receive_chain: merchantData.default_receive_chain, + default_receive_token: merchantData.default_receive_token, + stealth_enabled: merchantData.stealth_enabled, + created_at: merchantData.created_at, + }) +} + +export async function PUT(req: NextRequest) { + const { merchant, error } = await verifyApiKey(req) + + if (error) { + return NextResponse.json({ error }, { status: 401 }) + } + + let body + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON request body' }, { status: 400 }) + } + + const validation = updateMerchantSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json({ + error: 'Validation error', + details: validation.error.issues, + }, { status: 400 }) + } + + const data = validation.data + + // Build update object dynamically — only include provided fields + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateObj: Record = {} + + if (data.default_receive_chain !== undefined) updateObj.default_receive_chain = data.default_receive_chain + if (data.default_receive_token !== undefined) updateObj.default_receive_token = data.default_receive_token + if (data.business_name !== undefined) updateObj.business_name = data.business_name + if (data.email !== undefined) updateObj.email = data.email + + if (Object.keys(updateObj).length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = createServerClient() as any + + const { data: updated, error: updateError } = await supabase + .from('merchants') + .update(updateObj) + .eq('id', merchant.id) + .select( + 'wallet_address, business_name, email, default_receive_chain, default_receive_token, stealth_enabled, created_at', + ) + .single() + + if (updateError || !updated) { + console.error('Update merchant error:', updateError) + return NextResponse.json({ error: 'Failed to update merchant profile' }, { status: 500 }) + } + + return NextResponse.json({ + wallet_address: updated.wallet_address, + business_name: updated.business_name, + email: updated.email, + default_receive_chain: updated.default_receive_chain, + default_receive_token: updated.default_receive_token, + stealth_enabled: updated.stealth_enabled, + created_at: updated.created_at, + }) +} diff --git a/src/app/api/v1/payment-links/[id]/route.ts b/src/app/api/v1/payment-links/[id]/route.ts index 9949efe..045d350 100644 --- a/src/app/api/v1/payment-links/[id]/route.ts +++ b/src/app/api/v1/payment-links/[id]/route.ts @@ -1,37 +1,49 @@ import { NextRequest, NextResponse } from 'next/server' import { createServerClient } from '@/lib/supabase' import { verifyApiKey } from '@/lib/api/verify-api-key' +import { z } from 'zod' + +const updatePaymentLinkSchema = z.object({ + status: z.enum(['active', 'paused', 'archived']).optional(), + title: z.string().optional(), + description: z.string().optional(), + max_uses: z.number().int().positive().optional(), + expires_at: z.string().datetime().optional().nullable(), + success_url: z.string().url().optional(), + cancel_url: z.string().url().optional().nullable(), + metadata: z.record(z.string(), z.any()).optional(), +}) export async function GET( req: NextRequest, - { params }: { params: Promise<{ id: string }> } + { params }: { params: Promise<{ id: string }> }, ) { const { merchant, error } = await verifyApiKey(req) - + if (error) { return NextResponse.json({ error }, { status: 401 }) } - + const { id } = await params // eslint-disable-next-line @typescript-eslint/no-explicit-any const supabase = createServerClient() as any - + // Fetch payment link const { data: link, error: dbError } = await supabase .from('payment_links') .select('*') .eq('id', id) .single() - + if (dbError || !link) { return NextResponse.json({ error: 'Payment link not found' }, { status: 404 }) } - + // Verify ownership if (link.merchant_id !== merchant.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) } - + // Fetch recent transactions (optional, limit 5) const { data: transactions } = await supabase .from('transactions') @@ -39,57 +51,115 @@ export async function GET( .eq('payment_link_id', id) .order('created_at', { ascending: false }) .limit(5) - + return NextResponse.json({ id: link.id, url: `${process.env.NEXT_PUBLIC_BASE_URL}/pay/${link.id}`, amount: link.amount, currency: link.currency, status: link.status, + title: link.title, + description: link.description, + receive_token: link.receive_token, + receive_chain_id: link.receive_chain_id, + receive_token_symbol: link.receive_token_symbol, + use_stealth: link.use_stealth, current_uses: link.current_uses, max_uses: link.max_uses, + expires_at: link.expires_at, + success_url: link.success_url, + cancel_url: link.cancel_url, metadata: link.api_metadata, created_at: link.created_at, - transactions: transactions || [] + updated_at: link.updated_at, + transactions: transactions || [], }) } export async function PATCH( req: NextRequest, - { params }: { params: Promise<{ id: string }> } + { params }: { params: Promise<{ id: string }> }, ) { const { merchant, error } = await verifyApiKey(req) - + if (error) { return NextResponse.json({ error }, { status: 401 }) } - + const { id } = await params - const body = await req.json() - - // Only allow updating status for now - if (!body.status || !['active', 'paused', 'archived'].includes(body.status)) { - return NextResponse.json({ error: 'Invalid status' }, { status: 400 }) + + let body + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON request body' }, { status: 400 }) + } + + const validation = updatePaymentLinkSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json({ + error: 'Validation error', + details: validation.error.issues, + }, { status: 400 }) + } + + const data = validation.data + + // Build update object dynamically — only include provided fields + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateObj: Record = { + updated_at: new Date().toISOString(), + } + + if (data.status !== undefined) updateObj.status = data.status + if (data.title !== undefined) updateObj.title = data.title + if (data.description !== undefined) updateObj.description = data.description + if (data.max_uses !== undefined) updateObj.max_uses = data.max_uses + if (data.expires_at !== undefined) updateObj.expires_at = data.expires_at + if (data.success_url !== undefined) updateObj.success_url = data.success_url + if (data.cancel_url !== undefined) updateObj.cancel_url = data.cancel_url + if (data.metadata !== undefined) updateObj.api_metadata = data.metadata + + if (Object.keys(updateObj).length === 1) { + // Only updated_at — no actual fields provided + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) } - + // eslint-disable-next-line @typescript-eslint/no-explicit-any const supabase = createServerClient() as any - + const { data: link, error: updateError } = await supabase .from('payment_links') - .update({ status: body.status, updated_at: new Date().toISOString() }) + .update(updateObj) .eq('id', id) .eq('merchant_id', merchant.id) .select() .single() - + if (updateError || !link) { return NextResponse.json({ error: 'Failed to update payment link' }, { status: 500 }) } - + return NextResponse.json({ id: link.id, + url: `${process.env.NEXT_PUBLIC_BASE_URL}/pay/${link.id}`, + amount: link.amount, + currency: link.currency, status: link.status, - updated_at: link.updated_at + title: link.title, + description: link.description, + receive_token: link.receive_token, + receive_chain_id: link.receive_chain_id, + receive_token_symbol: link.receive_token_symbol, + use_stealth: link.use_stealth, + current_uses: link.current_uses, + max_uses: link.max_uses, + expires_at: link.expires_at, + success_url: link.success_url, + cancel_url: link.cancel_url, + metadata: link.api_metadata, + created_at: link.created_at, + updated_at: link.updated_at, }) } diff --git a/src/app/api/v1/payment-links/route.ts b/src/app/api/v1/payment-links/route.ts index aa7e98a..679f324 100644 --- a/src/app/api/v1/payment-links/route.ts +++ b/src/app/api/v1/payment-links/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createServerClient } from '@/lib/supabase' import { verifyApiKey } from '@/lib/api/verify-api-key' +import { ChainTokenService } from '@/services/chain-token-service' import { z } from 'zod' // Validation schema @@ -10,11 +11,13 @@ const createPaymentLinkSchema = z.object({ receive_token: z.string().optional(), receive_chain: z.string().optional(), title: z.string().optional(), + description: z.string().optional(), success_url: z.string().url(), cancel_url: z.string().url().optional(), metadata: z.record(z.string(), z.any()).optional(), max_uses: z.number().int().positive().optional(), - expires_at: z.string().datetime().optional() + expires_at: z.string().datetime().optional(), + use_stealth: z.boolean().optional(), }) const CHAIN_ID_MAP: Record = { @@ -31,11 +34,11 @@ const CHAIN_ID_MAP: Record = { export async function POST(req: NextRequest) { // 1. Verify API key const { merchant, error } = await verifyApiKey(req) - + if (error) { return NextResponse.json({ error }, { status: 401 }) } - + // 2. Parse and validate request body let body try { @@ -45,26 +48,26 @@ export async function POST(req: NextRequest) { } const validation = createPaymentLinkSchema.safeParse(body) - + if (!validation.success) { return NextResponse.json({ error: 'Validation error', - details: validation.error.issues + details: validation.error.issues, }, { status: 400 }) } - + const data = validation.data - + // eslint-disable-next-line @typescript-eslint/no-explicit-any const supabase = createServerClient() as any - + // 3. Get merchant defaults const { data: merchantData } = await supabase .from('merchants') - .select('default_receive_chain, default_receive_token, wallet_address') + .select('default_receive_chain, default_receive_token, wallet_address, stealth_enabled') .eq('id', merchant.id) .single() - + let receiveChainId: string | undefined = merchantData?.default_receive_chain ? String(merchantData.default_receive_chain) : undefined @@ -80,21 +83,47 @@ export async function POST(req: NextRequest) { } } - // 4. Create payment link + // 4. Stealth enforcement — replicate logic from internal payment-links POST + let receiveToken = data.receive_token || merchantData?.default_receive_token + let receiveTokenSymbol: string | undefined + let useStealth = data.use_stealth || false + + if (useStealth) { + if (merchantData?.stealth_enabled) { + const chainKey = receiveChainId || '1' + const chains = await ChainTokenService.getChains() + const chainConfig = chains.find((c) => c.key === chainKey) + receiveToken = '0x0000000000000000000000000000000000000000' + receiveTokenSymbol = chainConfig?.symbol || 'ETH' + } else { + // Merchant doesn't have stealth enabled, ignore the flag + useStealth = false + } + } + + // Resolve receive_token_symbol for non-stealth links + if (!receiveTokenSymbol) { + receiveTokenSymbol = receiveToken || undefined + } + + // 5. Create payment link const insertData = { merchant_id: merchant.id, amount: data.amount, currency: data.currency, - receive_token: data.receive_token || merchantData?.default_receive_token, + receive_token: receiveToken, + receive_token_symbol: receiveTokenSymbol, receive_chain_id: receiveChainId, recipient_address: merchantData?.wallet_address, title: data.title, + description: data.description, max_uses: data.max_uses, expires_at: data.expires_at, + use_stealth: useStealth, created_via: 'api', success_url: data.success_url, cancel_url: data.cancel_url, - api_metadata: data.metadata || {} + api_metadata: data.metadata || {}, } const { data: paymentLink, error: dbError } = await supabase @@ -102,15 +131,15 @@ export async function POST(req: NextRequest) { .insert(insertData) .select() .single() - + if (dbError) { console.error('Database error creating payment link:', dbError) return NextResponse.json({ - error: 'Failed to create payment link' + error: 'Failed to create payment link', }, { status: 500 }) } - - // 5. Log API call (optional/async) + + // 6. Log API call (optional/async) const clientIp = req.headers.get('x-forwarded-for') || 'unknown' supabase.from('api_logs').insert({ merchant_id: merchant.id, @@ -118,27 +147,37 @@ export async function POST(req: NextRequest) { method: 'POST', status_code: 201, request_body: body, - ip_address: clientIp.split(',')[0] + ip_address: clientIp.split(',')[0], // eslint-disable-next-line @typescript-eslint/no-explicit-any }).then(({ error }: any) => { - if(error) console.error('Failed to log API call', error) + if (error) console.error('Failed to log API call', error) }) - - // 6. Return response + + // 7. Return enriched response return NextResponse.json({ id: paymentLink.id, url: `${process.env.NEXT_PUBLIC_BASE_URL}/pay/${paymentLink.id}`, amount: paymentLink.amount, currency: paymentLink.currency, status: paymentLink.status, + title: paymentLink.title, + description: paymentLink.description, + receive_token: paymentLink.receive_token, + receive_chain_id: paymentLink.receive_chain_id, + receive_token_symbol: paymentLink.receive_token_symbol, + use_stealth: paymentLink.use_stealth, + max_uses: paymentLink.max_uses, + expires_at: paymentLink.expires_at, + success_url: paymentLink.success_url, + cancel_url: paymentLink.cancel_url, created_at: paymentLink.created_at, - metadata: paymentLink.api_metadata + metadata: paymentLink.api_metadata, }, { status: 201 }) } export async function GET(req: NextRequest) { const { merchant, error } = await verifyApiKey(req) - + if (error) { return NextResponse.json({ error }, { status: 401 }) } @@ -146,10 +185,10 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url) const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100) const offset = parseInt(searchParams.get('offset') || '0') - + // eslint-disable-next-line @typescript-eslint/no-explicit-any const supabase = createServerClient() as any - + const { data: links, count, error: dbError } = await supabase .from('payment_links') .select('*', { count: 'exact' }) @@ -165,6 +204,6 @@ export async function GET(req: NextRequest) { data: links, count: count, limit, - offset + offset, }) } diff --git a/src/app/api/v1/tokens/route.ts b/src/app/api/v1/tokens/route.ts new file mode 100644 index 0000000..3221f4b --- /dev/null +++ b/src/app/api/v1/tokens/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServerClient } from '@/lib/supabase' +import { verifyApiKey } from '@/lib/api/verify-api-key' +import { getTokens } from '@/lib/api/get-tokens' + +/** + * GET /api/v1/tokens?chainKey=42161 + */ +export async function GET(req: NextRequest) { + const { error } = await verifyApiKey(req) + + if (error) { + return NextResponse.json({ error }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const chainKey = searchParams.get('chainKey') + + if (!chainKey) { + return NextResponse.json( + { error: 'chainKey query parameter is required' }, + { status: 400 }, + ) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = createServerClient() as any + + const result = await getTokens(supabase, chainKey) + + return NextResponse.json({ + data: result.tokens, + chainKey: result.chainKey, + total: result.total, + }) + } catch (err) { + console.error('V1 Tokens Error:', err) + return NextResponse.json({ error: 'Failed to fetch tokens' }, { status: 500 }) + } +} diff --git a/src/app/api/v1/webhooks/route.ts b/src/app/api/v1/webhooks/route.ts index a287d9b..9714a88 100644 --- a/src/app/api/v1/webhooks/route.ts +++ b/src/app/api/v1/webhooks/route.ts @@ -115,35 +115,30 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Failed to fetch webhook endpoints' }, { status: 500 }) } - // Fetch delivery stats for all endpoints in two queries - const endpointIds = (endpoints || []).map((ep: { id: string }) => ep.id) - - const totalsByEndpoint: Record = {} - const successByEndpoint: Record = {} - - if (endpointIds.length > 0) { - const { data: allDeliveries } = await supabase - .from('webhook_deliveries') - .select('webhook_endpoint_id, delivered') - .in('webhook_endpoint_id', endpointIds) - - for (const d of allDeliveries || []) { - const epId = d.webhook_endpoint_id - totalsByEndpoint[epId] = (totalsByEndpoint[epId] || 0) + 1 - if (d.delivered) { - successByEndpoint[epId] = (successByEndpoint[epId] || 0) + 1 + // Fetch delivery stats per endpoint using count queries (not full row fetches) + const enriched = await Promise.all( + (endpoints || []).map(async (ep: { id: string }) => { + const [{ count: total }, { count: successful }] = await Promise.all([ + supabase + .from('webhook_deliveries') + .select('id', { count: 'exact', head: true }) + .eq('webhook_endpoint_id', ep.id), + supabase + .from('webhook_deliveries') + .select('id', { count: 'exact', head: true }) + .eq('webhook_endpoint_id', ep.id) + .eq('delivered', true), + ]) + return { + ...ep, + recent_deliveries: { + total: total || 0, + successful: successful || 0, + failed: (total || 0) - (successful || 0), + }, } - } - } - - const enriched = (endpoints || []).map((ep: { id: string }) => { - const total = totalsByEndpoint[ep.id] || 0 - const successful = successByEndpoint[ep.id] || 0 - return { - ...ep, - recent_deliveries: { total, successful, failed: total - successful }, - } - }) + }), + ) return NextResponse.json({ data: enriched }) } diff --git a/src/app/docs/authentication/page.tsx b/src/app/docs/authentication/page.tsx index 0e40e79..e0c1ca8 100644 --- a/src/app/docs/authentication/page.tsx +++ b/src/app/docs/authentication/page.tsx @@ -44,20 +44,54 @@ export default function DocsAuthPage() { - This endpoint uses session authentication (httpOnly cookie), not API key auth. It is intended for use from the Dashboard or authenticated browser sessions. + This endpoint uses wallet authentication (x-wallet-address header), not API key auth. It is intended for use from the Dashboard or authenticated browser sessions. - + +

+ Check whether an active API key exists for the merchant and retrieve its metadata. +

+
+ GET + /api/v1/auth/api-keys +
+ + + This endpoint uses wallet authentication (x-wallet-address header), not API key auth. + + + + + +
+

Revoke your current API key. All API integrations using this key will stop working immediately. diff --git a/src/app/docs/chains-tokens/page.tsx b/src/app/docs/chains-tokens/page.tsx new file mode 100644 index 0000000..6d93b1d --- /dev/null +++ b/src/app/docs/chains-tokens/page.tsx @@ -0,0 +1,167 @@ +import { DocHeader, DocSection, DocCodeBlock, MultiLangCodeBlock } from '@/components/docs/DocComponents' + +export default function DocsChainsTokensPage() { + return ( +

+ + + {/* CHAINS */} + +

+ Retrieve all supported blockchain networks. Results are cached and refreshed every 5 minutes. +

+
+ GET + /api/v1/chains +
+ +

Query Parameters

+
+
+
Parameter
+
Required
+
Description
+
+
+
type
+
No
+
Filter by type: "all" (default), "evm", "solana".
+
+
+
hasUSDC
+
No
+
Set to "true" to only return chains with USDC support.
+
+
+ + + + +
+ + {/* TOKENS */} + +

+ Retrieve supported tokens for a specific chain. Results are cached and include spam filtering. +

+
+ GET + /api/v1/tokens +
+ +

Query Parameters

+
+
+
Parameter
+
Required
+
Description
+
+
+
chainKey
+
Yes
+
Chain key (e.g. "8453", "137", "solana").
+
+
+ + + + +
+ + {/* ERROR CODES */} + +
+
+
Status
+
Code
+
Description
+
+
+
400
+
Bad Request
+
Missing required chainKey parameter (tokens endpoint).
+
+
+
401
+
Unauthorized
+
Invalid or missing API key.
+
+
+
+
+ ) +} diff --git a/src/app/docs/merchant/page.tsx b/src/app/docs/merchant/page.tsx new file mode 100644 index 0000000..9d566ad --- /dev/null +++ b/src/app/docs/merchant/page.tsx @@ -0,0 +1,159 @@ +import { DocHeader, DocSection, DocCodeBlock, MultiLangCodeBlock } from '@/components/docs/DocComponents' + +export default function DocsMerchantPage() { + return ( +
+ + + {/* GET */} + +

+ Retrieve your merchant profile including default receive settings and stealth privacy status. +

+
+ GET + /api/v1/merchant +
+ + + + +
+ + {/* PUT */} + +

+ Update your default receive settings and profile information. All fields are optional — only include the fields you want to change. +

+
+ PUT + /api/v1/merchant +
+ +

Request Body

+
+
+
Field
+
Type
+
Required
+
Description
+
+
+
default_receive_chain
+
string | number | null
+
No
+
Default chain ID for receiving payments.
+
+
+
default_receive_token
+
string | null
+
No
+
Default token address for receiving payments.
+
+
+
business_name
+
string | null
+
No
+
Business display name (max 255 chars).
+
+
+
email
+
string | null
+
No
+
Contact email address.
+
+
+ + + + +
+ + {/* ERROR CODES */} + +
+
+
Status
+
Code
+
Description
+
+
+
400
+
Validation Error
+
Invalid field values or no fields provided.
+
+
+
401
+
Unauthorized
+
Invalid or missing API key.
+
+
+
404
+
Not Found
+
Merchant profile not found.
+
+
+
+
+ ) +} diff --git a/src/app/docs/payment-links/page.tsx b/src/app/docs/payment-links/page.tsx index 534d3a9..e92251a 100644 --- a/src/app/docs/payment-links/page.tsx +++ b/src/app/docs/payment-links/page.tsx @@ -86,6 +86,18 @@ export default function DocsPaymentLinksPage() {
No
Expiration datetime for the link.
+
+
description
+
string
+
No
+
Description shown to payer.
+
+
+
use_stealth
+
boolean
+
No
+
Enable stealth address privacy (requires merchant stealth setup).
+

Request Example

@@ -121,7 +133,7 @@ const paymentLink = await response.json(); }} /> - + +
{/* GET SINGLE */} @@ -187,7 +219,7 @@ console.log(link.status, link.transactions);` }} /> - - {/* PATCH STATUS */} - + {/* PATCH */} +

- Pause, resume, or archive a payment link. Only the status field can be updated. + Update one or more fields on a payment link. All fields are optional — only include the fields you want to change.

PATCH /api/v1/payment-links/:id
-

Allowed Status Values

-
- active - paused - archived +

Updatable Fields

+
+
+
Field
+
Type
+
Description
+
+
+
status
+
string
+
"active", "paused", or "archived".
+
+
+
title
+
string
+
Product name shown to payer.
+
+
+
description
+
string
+
Description shown to payer.
+
+
+
max_uses
+
integer
+
Max number of payments allowed.
+
+
+
expires_at
+
ISO 8601 | null
+
Expiration datetime (null to remove).
+
+
+
success_url
+
string
+
URL to redirect after payment.
+
+
+
cancel_url
+
string | null
+
URL if user cancels payment.
+
+
+
metadata
+
object
+
Custom key-value pairs (replaces existing).
+
- - diff --git a/src/app/docs/transactions/page.tsx b/src/app/docs/transactions/page.tsx index 5a4fd40..818b5d5 100644 --- a/src/app/docs/transactions/page.tsx +++ b/src/app/docs/transactions/page.tsx @@ -76,9 +76,9 @@ const { data, count } = await response.json();` "payment_link_id": "pl_abc123", "status": "completed", "customer_wallet": "0x123...abc", - "source_chain_id": 1, - "source_token_symbol": "ETH", - "source_amount": "0.015", + "from_chain_id": "1", + "from_token_symbol": "ETH", + "from_amount": "0.015", "actual_output": "49.99", "source_tx_hash": "0xdef...", "completed_at": "2024-03-20T14:05:00Z", @@ -127,14 +127,14 @@ if (transaction.status === 'completed') { "payment_link_id": "pl_abc123", "status": "completed", "customer_wallet": "0x123...abc", - "source_chain_id": 1, - "source_token_symbol": "ETH", - "source_amount": "0.015", + "from_chain_id": "1", + "from_token_symbol": "ETH", + "from_amount": "0.015", "actual_output": "49.99", - "destination_chain_id": 8453, - "destination_token_symbol": "USDC", + "to_chain_id": "8453", + "to_token_symbol": "USDC", "source_tx_hash": "0xdef...", - "destination_tx_hash": "0xaaa...", + "dest_tx_hash": "0xaaa...", "completed_at": "2024-03-20T14:05:00Z", "created_at": "2024-03-20T14:04:30Z" }`} diff --git a/src/app/docs/webhooks/page.tsx b/src/app/docs/webhooks/page.tsx index ae32428..9a41ea9 100644 --- a/src/app/docs/webhooks/page.tsx +++ b/src/app/docs/webhooks/page.tsx @@ -289,6 +289,7 @@ const { data } = await response.json();`, "description": "Production webhook", "active": true, "created_at": "2026-03-30T12:00:00.000Z", + "updated_at": "2026-03-30T12:00:00.000Z", "recent_deliveries": { "total": 142, "successful": 138, @@ -331,7 +332,7 @@ const { data } = await response.json();`, "id": "delivery-uuid-2", "event_type": "payment.failed", "response_status": 500, - "error_message": null, + "error_message": "Internal Server Error", "delivered": false, "duration_ms": 340, "attempt": 1, @@ -377,6 +378,19 @@ const { data } = await response.json();`, const endpoint = await response.json();`, }} /> + + {/* DELETE */} diff --git a/src/config/docs.ts b/src/config/docs.ts index 522f214..dd56671 100644 --- a/src/config/docs.ts +++ b/src/config/docs.ts @@ -1,4 +1,4 @@ -import { Book, Key, Link as LinkIcon, ArrowRightLeft, ShieldCheck, Webhook } from 'lucide-react' +import { Book, Key, Link as LinkIcon, ArrowRightLeft, ShieldCheck, Webhook, User, Layers } from 'lucide-react' export type DocSection = { title: string @@ -26,6 +26,8 @@ export const toolsDocsConfig: DocSection[] = [ items: [ { title: "Payment Links", href: "/docs/payment-links", icon: LinkIcon }, { title: "Transactions", href: "/docs/transactions", icon: ArrowRightLeft }, + { title: "Merchant", href: "/docs/merchant", icon: User }, + { title: "Chains & Tokens", href: "/docs/chains-tokens", icon: Layers }, ] }, { diff --git a/src/lib/api/get-chains.ts b/src/lib/api/get-chains.ts new file mode 100644 index 0000000..d2129eb --- /dev/null +++ b/src/lib/api/get-chains.ts @@ -0,0 +1,137 @@ +import { ChainTokenService } from '@/services/chain-token-service' +import type { ChainType } from '@/lib/chain-registry' + +/** + * Normalize stale cached chain keys/types at read time. + * Fixes entries written before alias/type corrections were deployed. + */ +const KEY_ALIASES: Record = { + sol: 'solana', + btc: 'bitcoin', + doge: 'dogecoin', + '-239': 'ton', + '728126428': 'tron', + '1151111081099710': 'solana', + '23448594291968336': 'starknet', +} + +const KEY_TYPES: Record = { + solana: 'solana', + bitcoin: 'bitcoin', + dogecoin: 'bitcoin', + near: 'near', + tron: 'tron', + sui: 'sui', + ton: 'ton', + starknet: 'starknet', + aptos: 'aptos', + bch: 'bitcoin', + ltc: 'bitcoin', +} + +const KEY_NAMES: Record = { + solana: 'Solana', + bitcoin: 'Bitcoin', + dogecoin: 'Dogecoin', + ton: 'TON', + starknet: 'StarkNet', + tron: 'Tron', +} + +interface GetChainsParams { + type?: string + hasUSDC?: boolean +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getChains(supabase: any, params: GetChainsParams = {}) { + const { type = 'all', hasUSDC = false } = params + + // Build query — fetch all, then filter after normalization + // (type filter can't be applied at DB level because stale rows have wrong types) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let query = supabase.from('cached_chains' as any).select('*') + + if (hasUSDC) { + query = query.eq('has_usdc', true) + } + + const { data, error } = await query.order('name') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chains = data as any[] | null + + // If cache has data, return it + if (!error && chains && chains.length > 0) { + // Map DB rows back to UnifiedChain shape, normalizing stale keys/types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chainMap = new Map() + + for (const c of chains) { + const canonicalKey = KEY_ALIASES[c.key] || c.key + const correctType = KEY_TYPES[canonicalKey] || c.type + const correctName = + c.name === 'Sol' || c.name === 'Btc' || c.name === 'Doge' + ? KEY_NAMES[canonicalKey] || c.name + : c.name + + const existing = chainMap.get(canonicalKey) + if (existing) { + // Merge: prefer the entry with more provider support + const existingCount = Object.values(existing.providers || {}).filter(Boolean).length + const newCount = Object.values(c.providers || {}).filter(Boolean).length + if (newCount > existingCount) { + chainMap.set(canonicalKey, { + key: canonicalKey, + chainId: correctType !== 'evm' ? null : c.chain_id, + name: correctName, + type: correctType, + symbol: c.symbol, + logoUrl: existing.logoUrl || c.logo_url, + providers: { ...existing.providers, ...c.providers }, + providerIds: { ...existing.providerIds, ...c.provider_ids }, + }) + } else { + // Just merge provider flags into existing + existing.providers = { ...c.providers, ...existing.providers } + existing.providerIds = { ...c.provider_ids, ...existing.providerIds } + } + } else { + chainMap.set(canonicalKey, { + key: canonicalKey, + chainId: correctType !== 'evm' ? null : c.chain_id, + name: correctName, + type: correctType, + symbol: c.symbol, + logoUrl: c.logo_url, + providers: c.providers, + providerIds: c.provider_ids, + }) + } + } + + let mapped = Array.from(chainMap.values()) + + if (type !== 'all') { + mapped = mapped.filter((c) => c.type === type) + } + + return { chains: mapped, total: mapped.length, cached: true } + } + + // Fallback: live fetch (first request before cron runs) + console.log('Cache empty, falling back to live chain fetch...') + const liveChains = await ChainTokenService.getChains() + + let filtered = type === 'all' ? liveChains : liveChains.filter((c) => c.type === type) + + // For hasUSDC on fallback, just use static map (no token fetching to avoid OOM) + if (hasUSDC) { + const { getUSDCAddress } = await import('@/lib/tokens') + filtered = filtered.filter((chain) => { + const chainId = chain.chainId || chain.key + return !!getUSDCAddress(chainId) + }) + } + + return { chains: filtered, total: filtered.length, cached: false } +} diff --git a/src/lib/api/get-tokens.ts b/src/lib/api/get-tokens.ts new file mode 100644 index 0000000..643bfcd --- /dev/null +++ b/src/lib/api/get-tokens.ts @@ -0,0 +1,134 @@ +import { ChainTokenService } from '@/services/chain-token-service' +import { TOKENS } from '@/lib/tokens' +import { isSpamToken, buildCanonicalAddresses } from '@/lib/token-filter' + +/** Shape of a row in the cached_tokens Supabase table */ +interface CachedTokenRow { + address: string + symbol: string + name: string + decimals: number + logo_url: string | null + is_native: boolean + chain_key: string + provider_ids: Record | null +} + +/** Mapped token shape used for sorting and response */ +interface MappedToken { + address: string + symbol: string + name: string + decimals: number + logoUrl?: string + isNative: boolean + chainKey: string + providerIds: Record | null +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SupabaseClient = any + +export async function getTokens(supabase: SupabaseClient, chainKey: string) { + // Read from cache — cached_tokens is not in generated DB types, cast the table name + const { data, error } = await supabase + .from('cached_tokens' as 'merchants') + .select('*') + .eq('chain_key' as 'id', chainKey) + + const tokens = data as unknown as CachedTokenRow[] | null + + // If cache has data, return it + if (!error && tokens && tokens.length > 0) { + const STABLECOIN_SYMBOLS = new Set(['USDC', 'USDT', 'DAI', 'USDC.e', 'USDbC']) + + // Build canonical address set from static TOKENS map + const canonicalAddresses = new Set() + const numKey = Number(chainKey) + // TOKENS uses numeric keys for EVM chains and string keys for non-EVM + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const staticTokens = (!isNaN(numKey) ? (TOKENS as any)[numKey] : null) || (TOKENS as any)[chainKey] + if (staticTokens) { + for (const t of staticTokens) { + canonicalAddresses.add(t.address.toLowerCase()) + } + } + + const rawMapped: MappedToken[] = tokens.map((t) => ({ + address: t.address, + symbol: t.symbol, + name: t.name, + decimals: t.decimals, + logoUrl: t.logo_url || undefined, + isNative: t.is_native, + chainKey: t.chain_key, + providerIds: t.provider_ids, + })) + + // Filter spam from cached data (catches pre-existing unfiltered tokens) + const spamCanonical = buildCanonicalAddresses(chainKey) + const mapped = rawMapped.filter((t) => { + const count = + t.providerIds && typeof (t.providerIds as Record)._count === 'number' + ? ((t.providerIds as Record)._count as number) + : 1 + return !isSpamToken(t, count, spamCanonical) + }) + + // Inject canonical tokens that are missing from cache + if (staticTokens) { + const cachedAddresses = new Set(mapped.map((t) => t.address.toLowerCase())) + for (const st of staticTokens) { + if (!cachedAddresses.has(st.address.toLowerCase())) { + mapped.push({ + address: st.address, + symbol: st.symbol, + name: st.name, + decimals: st.decimals, + logoUrl: st.logoUrl, + isNative: st.isNative || false, + chainKey, + providerIds: null, + }) + } + } + } + + // Provider count: stored as _count in providerIds by mergeTokens(), or count object keys + const getProviderCount = (t: MappedToken) => { + if (!t.providerIds || typeof t.providerIds !== 'object') return 0 + if (typeof t.providerIds._count === 'number') return t.providerIds._count + return Object.keys(t.providerIds).filter((k) => k !== '_count').length + } + + // Sort: native first, then canonical stablecoins, then multi-provider stablecoins, + // then alphabetically + mapped.sort((a, b) => { + if (a.isNative && !b.isNative) return -1 + if (!a.isNative && b.isNative) return 1 + const aStable = STABLECOIN_SYMBOLS.has(a.symbol) + const bStable = STABLECOIN_SYMBOLS.has(b.symbol) + if (aStable && !bStable) return -1 + if (!aStable && bStable) return 1 + // Among stablecoins with the same symbol: canonical > provider count > alphabetical + if (aStable && bStable && a.symbol === b.symbol) { + const aCanonical = canonicalAddresses.has(a.address.toLowerCase()) + const bCanonical = canonicalAddresses.has(b.address.toLowerCase()) + if (aCanonical && !bCanonical) return -1 + if (!aCanonical && bCanonical) return 1 + const aCount = getProviderCount(a) + const bCount = getProviderCount(b) + if (aCount !== bCount) return bCount - aCount + } + return a.symbol.localeCompare(b.symbol) + }) + + return { tokens: mapped, chainKey, total: mapped.length, cached: true } + } + + // Fallback: live fetch + console.log(`Token cache empty for ${chainKey}, falling back to live fetch...`) + const liveTokens = await ChainTokenService.getTokens(chainKey) + + return { tokens: liveTokens, chainKey, total: liveTokens.length, cached: false } +}