Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 6 additions & 134 deletions src/app/api/chains/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
sol: 'solana',
btc: 'bitcoin',
doge: 'dogecoin',
'-239': 'ton',
'728126428': 'tron',
'1151111081099710': 'solana',
'23448594291968336': 'starknet',
}

const KEY_TYPES: Record<string, ChainType> = {
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<string, string> = {
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
Expand All @@ -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<string, any>()

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 },
)
}
}
141 changes: 6 additions & 135 deletions src/app/api/tokens/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown> | null
}
import { getTokens } from '@/lib/api/get-tokens'

/**
* GET /api/tokens?chainKey=42161
Expand All @@ -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<string>()
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<string, unknown>)._count === 'number'
? (t.providerIds as Record<string, unknown>)._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)
Expand Down
35 changes: 35 additions & 0 deletions src/app/api/v1/chains/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flash Review

🚨 Security: The type query parameter is taken directly from user input without validation against allowed values. If getChains uses this parameter directly in a SQL query, it could be vulnerable to SQL injection.

Fix: Implement Zod validation for type to ensure it's one of the expected enum values ('all', 'evm', 'solana').

import { z } from 'zod'

const querySchema = z.object({
  type: z.enum(['all', 'evm', 'solana']).default('all'),
  hasUSDC: z.string().optional().transform(s => s === 'true')
})

const { type, hasUSDC } = querySchema.parse(Object.fromEntries(searchParams))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flash Review

🧹 Code Quality: Using as any here bypasses TypeScript's type checking. While createServerClient might return a complex type, it's better to explicitly type it or infer it correctly to maintain type safety and catch potential errors early.

Fix: Consider importing the correct Supabase client type (e.g., SupabaseClient<Database>) or ensuring createServerClient is typed correctly to avoid any.


// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = createServerClient() as any

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flash Review

🧹 Code Quality: Using as any for the Supabase client bypasses TypeScript's type checking. This can lead to runtime errors that could have been caught at compile time.

Fix: Ensure createServerClient() returns a properly typed Supabase client. You might need to define a type for your Supabase database schema and pass it to createServerClient<Database>().

const result = await getChains(supabase, { type, hasUSDC })

return NextResponse.json({
data: result.chains,
total: result.total,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flash Review

🧹 Code Quality: console.error should be replaced with a structured logging solution in production environments. This helps with centralized error monitoring, analysis, and avoids potential performance overhead or missing critical alerts.

Fix: Integrate a dedicated logger (e.g., Pino, Winston, or a custom wrapper around a service like Sentry) to handle errors consistently across the application.

})
} catch (err) {
console.error('V1 Chains Error:', err)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flash Review

🧹 Code Quality: console.error should generally be avoided in production API routes. While it's an error, it doesn't integrate with structured logging systems.

Fix: Replace console.error with a dedicated logging utility (e.g., a custom logger, Pino, Winston) that can provide structured logs, context, and integrate with monitoring tools.

return NextResponse.json({ error: 'Failed to fetch chains' }, { status: 500 })
}
}
Loading
Loading