|
1 | 1 | /** |
2 | | - * x402 Admin GET /api/x402/admin/orders |
3 | | - * Protected endpoint for monitoring x402 payment orders. |
4 | | - * Auth: x-admin-key header or ?secret= query param vs X402_ADMIN_SECRET env var. |
| 2 | + * Thin proxy: forwards to Pretix plugin /plugin/x402/admin/orders. |
| 3 | + * All business logic (order listing, stats) lives in the plugin now. |
| 4 | + * |
| 5 | + * Keep the existing ADMIN_SECRET check — devcon-next still enforces admin auth |
| 6 | + * at the public edge; the plugin uses Pretix API token auth. |
5 | 7 | */ |
6 | 8 | import type { NextApiRequest, NextApiResponse } from 'next' |
7 | | -import { createPublicClient, type Hex } from 'viem' |
8 | | -import { mainnet, optimism, arbitrum, base, polygon, baseSepolia } from 'viem/chains' |
9 | | -import { getAllPendingOrders, getAllCompletedOrders, getStoreStats } from 'services/ticketStore' |
10 | | -import { getPaymentRecipient } from 'services/x402' |
11 | | -import { getTransport } from 'services/rpc' |
12 | | -import { getAllGaslessConfigs } from 'types/x402' |
13 | | -import { fetchEthPriceUsd } from 'services/ethPrice' |
| 9 | +import { pluginFetch } from 'services/pretixPluginProxy' |
14 | 10 | import { TICKETING, TICKETING_ENV } from 'config/ticketing' |
| 11 | +import { fetchEthPriceUsd } from 'services/ethPrice' |
| 12 | +import { fetchWalletInfoFromZapper } from 'services/zapperWallet' |
| 13 | +import { checkAdminAuth } from 'utils/adminAuth' |
15 | 14 |
|
16 | | -const ADMIN_SECRET = process.env.X402_ADMIN_SECRET |
| 15 | +const SUPPORTED_CHAIN_IDS = [1, 10, 8453, 42161, 137] |
17 | 16 |
|
18 | | -/** Fetch POL/USD spot price from Coinbase */ |
| 17 | +/** Display-only POL/USD price from Coinbase. Unlike the ETH oracle we don't |
| 18 | + * need dual-source confirmation here — this drives the admin wallet panel's |
| 19 | + * total estimate, not actual payment pricing. Returns null on failure. */ |
19 | 20 | async function fetchPolPriceUsd(): Promise<number | null> { |
20 | 21 | try { |
21 | | - const res = await fetch('https://api.coinbase.com/v2/prices/POL-USD/spot') |
| 22 | + const res = await fetch('https://api.coinbase.com/v2/prices/POL-USD/spot', { signal: AbortSignal.timeout(5_000) }) |
22 | 23 | if (!res.ok) return null |
23 | | - const data = await res.json() |
24 | | - const price = parseFloat(data?.data?.amount) |
25 | | - return Number.isFinite(price) && price > 0 ? price : null |
| 24 | + const json = await res.json() |
| 25 | + const v = parseFloat(json?.data?.amount) |
| 26 | + return Number.isFinite(v) && v > 0 ? v : null |
26 | 27 | } catch { |
27 | 28 | return null |
28 | 29 | } |
29 | 30 | } |
30 | 31 |
|
31 | | -const VIEM_CHAINS: Record<number, any> = { |
32 | | - 1: mainnet, |
33 | | - 10: optimism, |
34 | | - 42161: arbitrum, |
35 | | - 8453: base, |
36 | | - 137: polygon, |
37 | | - 84532: baseSepolia, |
38 | | -} |
39 | | - |
40 | | -const ERC20_BALANCE_OF_ABI = [ |
41 | | - { |
42 | | - name: 'balanceOf', |
43 | | - type: 'function', |
44 | | - inputs: [{ name: 'account', type: 'address' }], |
45 | | - outputs: [{ name: '', type: 'uint256' }], |
46 | | - stateMutability: 'view', |
47 | | - }, |
48 | | -] as const |
49 | | - |
50 | | -interface ChainBalance { |
51 | | - chainId: number |
52 | | - network: string |
53 | | - ethBalance: string |
54 | | - tokens: { symbol: string; balance: string; address: string }[] |
55 | | -} |
56 | | - |
57 | | -async function fetchWalletBalances(recipient: string): Promise<ChainBalance[]> { |
58 | | - const configs = getAllGaslessConfigs() |
59 | | - |
60 | | - // Group tokens by chain |
61 | | - const byChain = new Map<number, typeof configs>() |
62 | | - for (const c of configs) { |
63 | | - const list = byChain.get(c.chainId) || [] |
64 | | - list.push(c) |
65 | | - byChain.set(c.chainId, list) |
66 | | - } |
67 | | - |
68 | | - const results = await Promise.allSettled( |
69 | | - Array.from(byChain.entries()).map(async ([chainId, tokens]) => { |
70 | | - const viemChain = VIEM_CHAINS[chainId] |
71 | | - if (!viemChain) return null |
72 | | - |
73 | | - const client = createPublicClient({ chain: viemChain, transport: getTransport(chainId) }) |
74 | | - |
75 | | - const [ethBal, ...tokenBals] = await Promise.all([ |
76 | | - client.getBalance({ address: recipient as Hex }), |
77 | | - ...tokens.map(t => |
78 | | - client.readContract({ |
79 | | - address: t.tokenAddress as Hex, |
80 | | - abi: ERC20_BALANCE_OF_ABI, |
81 | | - functionName: 'balanceOf', |
82 | | - args: [recipient as Hex], |
83 | | - }) |
84 | | - ), |
85 | | - ]) |
86 | | - |
87 | | - return { |
88 | | - chainId, |
89 | | - network: tokens[0].network, |
90 | | - ethBalance: (Number(ethBal) / 1e18).toFixed(6), |
91 | | - tokens: tokens.map((t, i) => ({ |
92 | | - symbol: t.tokenSymbol, |
93 | | - balance: (Number(tokenBals[i]) / 10 ** t.tokenDecimals).toFixed(2), |
94 | | - address: t.tokenAddress, |
95 | | - })), |
96 | | - } |
97 | | - }) |
98 | | - ) |
99 | | - |
100 | | - return results |
101 | | - .filter((r): r is PromiseFulfilledResult<ChainBalance | null> => r.status === 'fulfilled' && r.value != null) |
102 | | - .map(r => r.value!) |
103 | | -} |
104 | | - |
105 | 32 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { |
106 | 33 | if (req.method !== 'GET') { |
107 | | - return res.status(405).setHeader('Allow', 'GET').end() |
108 | | - } |
109 | | - |
110 | | - if (!ADMIN_SECRET) { |
111 | | - return res.status(500).json({ success: false, error: 'X402_ADMIN_SECRET not configured' }) |
112 | | - } |
113 | | - |
114 | | - const provided = (req.headers['x-admin-key'] as string) || (req.query.secret as string) |
115 | | - if (provided !== ADMIN_SECRET) { |
116 | | - return res.status(401).json({ success: false, error: 'Unauthorized' }) |
| 34 | + return res.status(405).json({ success: false, error: 'Method not allowed' }) |
117 | 35 | } |
| 36 | + if (!checkAdminAuth(req, res)) return |
118 | 37 |
|
119 | 38 | try { |
120 | | - let recipient: string | undefined |
121 | | - try { |
122 | | - recipient = getPaymentRecipient() |
123 | | - } catch { |
124 | | - // recipient not configured — skip balance fetch |
| 39 | + const { status, body } = await pluginFetch<{ |
| 40 | + success: boolean |
| 41 | + stats?: { |
| 42 | + pending: number |
| 43 | + completed: number |
| 44 | + totalUsd: string |
| 45 | + x402Count?: number |
| 46 | + legacyCount?: number |
| 47 | + } |
| 48 | + completed?: Array<Record<string, unknown>> |
| 49 | + pending?: Array<Record<string, unknown>> |
| 50 | + error?: string |
| 51 | + }>('/plugin/x402/admin/orders/', { method: 'GET' }) |
| 52 | + |
| 53 | + // Stamp env + base URL so the admin UI can label rows by environment |
| 54 | + // when multiple Pretix instances are proxied through the same build. |
| 55 | + if (body && body.success) { |
| 56 | + const envLabel = TICKETING_ENV |
| 57 | + const baseUrl = TICKETING.pretix.baseUrl |
| 58 | + const orgSlug = TICKETING.pretix.organizer |
| 59 | + const eventSlug = TICKETING.pretix.event |
| 60 | + body.completed = (body.completed || []).map(o => ({ ...o, env: envLabel })) |
| 61 | + body.pending = (body.pending || []).map(o => ({ ...o, env: envLabel })) |
| 62 | + ;(body as Record<string, unknown>).env = envLabel |
| 63 | + ;(body as Record<string, unknown>).pretixBaseUrl = baseUrl |
| 64 | + ;(body as Record<string, unknown>).pretixOrgSlug = orgSlug |
| 65 | + ;(body as Record<string, unknown>).pretixEventSlug = eventSlug |
| 66 | + |
| 67 | + // Enrich with two separate wallet panels — fetched here (not from the |
| 68 | + // Pretix plugin) because they need ZAPPER_API_KEY which lives in the |
| 69 | + // Vercel env. Best-effort: any failure returns null and the UI hides |
| 70 | + // the relevant panel. |
| 71 | + // |
| 72 | + // Two wallets, two responsibilities: |
| 73 | + // - destinationWallet: receives payments → show every token (native |
| 74 | + // + USDC + USDT0). No low-balance threshold; this wallet's job is |
| 75 | + // to accumulate, not pay gas. |
| 76 | + // - gasRelayerWallet: sponsors gas for EIP-3009 transfers → show |
| 77 | + // only native (ETH/POL). Token balances on the relayer aren't |
| 78 | + // relevant to operations. The native-balance threshold drives the |
| 79 | + // red-flag UI in admin.tsx. |
| 80 | + const recipient = TICKETING.payment.recipientAddress |
| 81 | + const relayer = TICKETING.payment.relayerAddress |
| 82 | + const [ethPriceResult, polPrice] = await Promise.all([ |
| 83 | + fetchEthPriceUsd().catch(() => null), |
| 84 | + fetchPolPriceUsd(), |
| 85 | + ]) |
| 86 | + const ethPrice = ethPriceResult?.price ?? null |
| 87 | + const [destinationFull, gasRelayerFull] = await Promise.all([ |
| 88 | + recipient |
| 89 | + ? fetchWalletInfoFromZapper({ address: recipient, chainIds: SUPPORTED_CHAIN_IDS, ethPrice, polPrice }) |
| 90 | + : Promise.resolve(null), |
| 91 | + relayer |
| 92 | + ? fetchWalletInfoFromZapper({ address: relayer, chainIds: SUPPORTED_CHAIN_IDS, ethPrice, polPrice }) |
| 93 | + : Promise.resolve(null), |
| 94 | + ]) |
| 95 | + if (destinationFull) { |
| 96 | + // Filter ERC-20 rows to only the tokens we actually accept. Match by |
| 97 | + // contract address (canonical) instead of symbol — the on-chain |
| 98 | + // `symbol()` for USD₮0 returns the unicode tether glyph (U+20AE), |
| 99 | + // not the ASCII "USDT0" we use elsewhere, so a symbol-based filter |
| 100 | + // was silently dropping it. Native (ETH/POL) is preserved separately |
| 101 | + // via `ethBalance`. |
| 102 | + // address (lowercase) -> normalized symbol used by the rest of the |
| 103 | + // app (TOKEN_ICONS, displays, exports). Zapper's on-chain `symbol()` |
| 104 | + // for USD₮0 returns the unicode tether glyph; mapping by address lets |
| 105 | + // us both filter to supported tokens AND normalize the symbol so |
| 106 | + // icon lookups (TOKEN_ICONS['USDT0']) keep working. |
| 107 | + const SUPPORTED_TOKENS_BY_ADDRESS: Record<string, string> = { |
| 108 | + // USDC |
| 109 | + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 'USDC', // mainnet |
| 110 | + '0x0b2c639c533813f4aa9d7837caf62653d097ff85': 'USDC', // optimism |
| 111 | + '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': 'USDC', // polygon |
| 112 | + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 'USDC', // base |
| 113 | + '0xaf88d065e77c8cc2239327c5edb3a432268e5831': 'USDC', // arbitrum |
| 114 | + // USD₮0 |
| 115 | + '0x01bff41798a0bcf287b996046ca68b395dbc1071': 'USDT0', // optimism |
| 116 | + '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9': 'USDT0', // arbitrum |
| 117 | + } |
| 118 | + const destinationWallet = { |
| 119 | + ...destinationFull, |
| 120 | + balances: destinationFull.balances.map(c => ({ |
| 121 | + ...c, |
| 122 | + tokens: c.tokens |
| 123 | + .map(t => { |
| 124 | + const canonicalSymbol = SUPPORTED_TOKENS_BY_ADDRESS[t.address.toLowerCase()] |
| 125 | + return canonicalSymbol ? { ...t, symbol: canonicalSymbol } : null |
| 126 | + }) |
| 127 | + .filter((t): t is NonNullable<typeof t> => t !== null), |
| 128 | + })), |
| 129 | + } |
| 130 | + ;(body as Record<string, unknown>).destinationWallet = destinationWallet |
| 131 | + } |
| 132 | + if (gasRelayerFull) { |
| 133 | + // Strip ERC-20 tokens — the gas relayer panel is native-only since |
| 134 | + // gas is paid in native currency. |
| 135 | + const gasRelayerWallet = { |
| 136 | + ...gasRelayerFull, |
| 137 | + balances: gasRelayerFull.balances.map(c => ({ ...c, tokens: [] })), |
| 138 | + } |
| 139 | + ;(body as Record<string, unknown>).gasRelayerWallet = gasRelayerWallet |
| 140 | + } |
125 | 141 | } |
126 | | - |
127 | | - const [stats, completed, pending, walletBalances, ethPriceResult, polPrice] = await Promise.all([ |
128 | | - getStoreStats(), |
129 | | - getAllCompletedOrders(), |
130 | | - getAllPendingOrders(), |
131 | | - recipient ? fetchWalletBalances(recipient).catch(() => []) : Promise.resolve([]), |
132 | | - fetchEthPriceUsd().catch(() => null), |
133 | | - fetchPolPriceUsd().catch(() => null), |
134 | | - ]) |
135 | | - |
136 | | - const { baseUrl, organizer, event } = TICKETING.pretix |
137 | | - const pretixBaseUrl = `${baseUrl}/control/event/${organizer}/${event}/orders` |
138 | | - |
139 | | - return res.status(200).json({ |
140 | | - success: true, |
141 | | - env: TICKETING_ENV, |
142 | | - pretixBaseUrl, |
143 | | - stats, |
144 | | - completed, |
145 | | - pending: pending.map(({ orderData, ...rest }) => rest), |
146 | | - wallet: recipient |
147 | | - ? { |
148 | | - address: recipient, |
149 | | - balances: walletBalances, |
150 | | - prices: { |
151 | | - ETH: ethPriceResult?.price ?? null, |
152 | | - POL: polPrice, |
153 | | - }, |
154 | | - } |
155 | | - : null, |
156 | | - }) |
157 | | - } catch (error) { |
158 | | - console.error('[x402 admin orders]', error) |
159 | | - return res.status(500).json({ |
160 | | - success: false, |
161 | | - error: 'Failed to fetch orders', |
162 | | - details: error instanceof Error ? error.message : String(error), |
163 | | - }) |
| 142 | + return res.status(status).json(body) |
| 143 | + } catch (e) { |
| 144 | + console.error('[x402 proxy] orders error:', e) |
| 145 | + return res.status(502).json({ success: false, error: 'Pretix plugin unreachable' }) |
164 | 146 | } |
165 | 147 | } |
0 commit comments