Skip to content

Commit 0488886

Browse files
committed
restore x402
1 parent 0fefdfa commit 0488886

20 files changed

Lines changed: 899 additions & 2412 deletions
Lines changed: 125 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,147 @@
11
/**
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.
57
*/
68
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'
1410
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'
1514

16-
const ADMIN_SECRET = process.env.X402_ADMIN_SECRET
15+
const SUPPORTED_CHAIN_IDS = [1, 10, 8453, 42161, 137]
1716

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. */
1920
async function fetchPolPriceUsd(): Promise<number | null> {
2021
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) })
2223
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
2627
} catch {
2728
return null
2829
}
2930
}
3031

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-
10532
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
10633
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' })
11735
}
36+
if (!checkAdminAuth(req, res)) return
11837

11938
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+
}
125141
}
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' })
164146
}
165147
}

0 commit comments

Comments
 (0)