import { COOKIE_NAME } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; import { getCombinedStats, getPresaleStats } from "./onchain"; import { getRecentPurchases } from "./trc20Monitor"; import { getDb } from "./db"; import { trc20Purchases, trc20Intents, bridgeOrders, bridgeIntents } from "../drizzle/schema"; import { eq, desc, sql } from "drizzle-orm"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { notifyDistributed, testTelegramConnection } from "./telegram"; import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb"; import { creditXic } from "./tokenDistributionService"; import { createAlipayOrder, handleAlipayCallback, queryAlipayOrder, generateAlipayOrderId, } from "./services/alipayService"; import { createWechatOrder, handleWechatCallback, queryWechatOrder, generateWechatOrderId, } from "./services/wechatPayService"; import { createPaypalOrder, capturePaypalOrder, handlePaypalWebhook, generatePaypalOrderId, } from "./services/paypalService"; import { fiatOrders, siteSettings } from "../drizzle/schema"; // Default receiving addresses (used when DB is empty — admin must update via admin panel) const EVM_DEFAULT = "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3"; const DEFAULT_RECEIVING_ADDRESSES: Record = { trc20_receiving_address: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp", bsc_receiving_address: EVM_DEFAULT, eth_receiving_address: EVM_DEFAULT, polygon_receiving_address: EVM_DEFAULT, arbitrum_receiving_address: EVM_DEFAULT, avalanche_receiving_address: EVM_DEFAULT, }; // Helper: get a site setting by key async function getSiteSetting(key: string): Promise { const db = await getDb(); if (!db) return null; const rows = await db.select().from(siteSettings).where(eq(siteSettings.key, key)).limit(1); return rows[0]?.value ?? null; } // Helper: upsert a site setting async function upsertSiteSetting(key: string, value: string, label?: string, description?: string) { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Database unavailable" }); await db.insert(siteSettings).values({ key, value, label: label ?? null, description: description ?? null }) .onDuplicateKeyUpdate({ set: { value, updatedAt: new Date() } }); } // Admin password from env (fallback for development) const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!"; // ─── Bridge Router ─────────────────────────────────────────────────────────── const bridgeRouter = router({ // Record a completed cross-chain USDT transfer and credit XIC (idempotent) recordOrder: publicProcedure .input(z.object({ txHash: z.string().min(1).max(128), walletAddress: z.string().min(1).max(64), fromChainId: z.number().int(), fromToken: z.string().max(32), fromAmount: z.string(), // USDT amount toChainId: z.number().int(), toToken: z.string().max(32), toAmount: z.string(), // XIC amount xicReceiveAddress: z.string().optional(), chainType: z.enum(["ERC20", "TRC20"]).default("ERC20"), receivingAddress: z.string().optional(), })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) return { success: false, message: "DB unavailable" }; // Insert bridge order (pending — creditXic will update to confirmed) try { await db.insert(bridgeOrders).values({ txHash: input.txHash, walletAddress: input.walletAddress.toLowerCase(), fromChainId: input.fromChainId, fromToken: input.fromToken, fromAmount: input.fromAmount, toChainId: input.toChainId, toToken: input.toToken, toAmount: input.toAmount, xicReceiveAddress: input.xicReceiveAddress ?? null, status: "pending" as const, }); } catch (e: any) { if (e?.code !== "ER_DUP_ENTRY") throw e; } // Unified credit service (idempotent via transaction_logs) const usdtAmount = parseFloat(input.fromAmount); const xicAmount = parseFloat(input.toAmount); const result = await creditXic({ txHash: input.txHash, chainType: input.chainType, fromAddress: input.walletAddress.toLowerCase(), toAddress: input.receivingAddress ?? "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3", usdtAmount, xicAmount, xicReceiveAddress: input.xicReceiveAddress, remark: `Frontend recordOrder (${input.chainType})`, }); return { success: result.success, alreadyProcessed: result.alreadyProcessed }; }), // List orders by wallet address (includes pending intents + confirmed orders) myOrders: publicProcedure .input(z.object({ walletAddress: z.string().min(1).max(64), limit: z.number().min(1).max(50).default(20), })) .query(async ({ input }) => { const db = await getDb(); if (!db) return []; const addr = input.walletAddress.toLowerCase(); // Query confirmed bridge orders const confirmedRows = await db .select() .from(bridgeOrders) .where(eq(bridgeOrders.walletAddress, addr)) .orderBy(desc(bridgeOrders.createdAt)) .limit(input.limit); // Query pending bridge intents (by xicReceiveAddress) const intentRows = await db .select() .from(bridgeIntents) .where(eq(bridgeIntents.xicReceiveAddress, addr)) .orderBy(desc(bridgeIntents.createdAt)) .limit(input.limit); // Merge: intents first (pending), then confirmed orders const result = [ ...intentRows.map(r => ({ id: r.id, type: 'intent' as const, fromChainId: r.fromChainId, xicReceiveAddress: r.xicReceiveAddress, expectedUsdt: r.expectedUsdt ? Number(r.expectedUsdt) : null, matched: r.matched, status: r.matched ? 'confirmed' as const : 'pending' as const, createdAt: r.createdAt, })), ...confirmedRows.map(r => ({ id: r.id, type: 'order' as const, txHash: r.txHash, walletAddress: r.walletAddress, fromChainId: r.fromChainId, fromToken: r.fromToken, fromAmount: Number(r.fromAmount), toChainId: r.toChainId, toToken: r.toToken, toAmount: Number(r.toAmount), status: r.status, createdAt: r.createdAt, })), ]; return result.sort((a, b) => new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime()).slice(0, input.limit); }), // Register a bridge intent — user pre-registers before sending USDT registerIntent: publicProcedure .input(z.object({ fromChainId: z.number().int(), senderAddress: z.string().max(64).or(z.literal("")).transform(v => v === "" ? undefined : v).optional(), // optional — filled when wallet connected xicReceiveAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/, "Invalid EVM address"), expectedUsdt: z.number().min(0.01).optional(), })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); await db.insert(bridgeIntents).values({ fromChainId: input.fromChainId, senderAddress: input.senderAddress ? input.senderAddress.toLowerCase() : null, xicReceiveAddress: input.xicReceiveAddress, expectedUsdt: input.expectedUsdt ? String(input.expectedUsdt) : null, matched: false, }); return { success: true, message: "Intent registered. Please send USDT now." }; }), // List recent bridge orders (public) recentOrders: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ input }) => { const db = await getDb(); if (!db) return []; const rows = await db .select() .from(bridgeOrders) .orderBy(desc(bridgeOrders.createdAt)) .limit(input.limit); return rows.map(r => ({ id: r.id, txHash: r.txHash, walletAddress: r.walletAddress, fromChainId: r.fromChainId, fromToken: r.fromToken, fromAmount: Number(r.fromAmount), toChainId: r.toChainId, toToken: r.toToken, toAmount: Number(r.toAmount), status: r.status, createdAt: r.createdAt, })); }), }); // ─── Payment Router (Fiat: Alipay / WeChat Pay / PayPal) ───────────────────── const paymentRouter = router({ // ── Alipay ────────────────────────────────────────────────────────────── createAlipayOrder: publicProcedure .input(z.object({ totalAmount: z.string().regex(/^\d+(\.\d{1,2})?$/, "Invalid amount"), xicReceiveAddress: z.string().min(10), isMobile: z.boolean().optional().default(false), })) .mutation(async ({ input }) => { const orderId = generateAlipayOrderId(); const result = await createAlipayOrder({ orderId, subject: "NAC XIC Token Purchase", totalAmount: input.totalAmount, xicReceiveAddress: input.xicReceiveAddress, isMobile: input.isMobile, }); if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); return { orderId, paymentUrl: result.paymentUrl }; }), queryAlipayOrder: publicProcedure .input(z.object({ orderId: z.string() })) .query(async ({ input }) => { const db = await getDb(); if (db) { const rows = await db.select().from(fiatOrders).where(eq(fiatOrders.orderId, input.orderId)).limit(1); if (rows[0]) return { dbStatus: rows[0].status, xicAmount: rows[0].xicAmount }; } return { dbStatus: "not_found" }; }), // ── WeChat Pay ─────────────────────────────────────────────────────────── createWechatOrder: publicProcedure .input(z.object({ totalFen: z.number().int().min(1), xicReceiveAddress: z.string().min(10), payType: z.enum(["NATIVE", "H5", "JSAPI"]).optional().default("NATIVE"), openId: z.string().optional(), clientIp: z.string().optional(), })) .mutation(async ({ input }) => { const orderId = generateWechatOrderId(); const result = await createWechatOrder({ orderId, description: "NAC XIC Token Purchase", totalFen: input.totalFen, xicReceiveAddress: input.xicReceiveAddress, payType: input.payType, openId: input.openId, clientIp: input.clientIp, }); if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); return { orderId, qrCodeUrl: result.qrCodeUrl, h5Url: result.h5Url, jsapiParams: result.jsapiParams, }; }), queryWechatOrder: publicProcedure .input(z.object({ orderId: z.string() })) .query(async ({ input }) => { const db = await getDb(); if (db) { const rows = await db.select().from(fiatOrders).where(eq(fiatOrders.orderId, input.orderId)).limit(1); if (rows[0]) return { dbStatus: rows[0].status, xicAmount: rows[0].xicAmount }; } return { dbStatus: "not_found" }; }), // ── PayPal ─────────────────────────────────────────────────────────────── createPaypalOrder: publicProcedure .input(z.object({ usdAmount: z.string().regex(/^\d+(\.\d{1,2})?$/, "Invalid amount"), xicReceiveAddress: z.string().min(10), })) .mutation(async ({ input }) => { const orderId = generatePaypalOrderId(); const result = await createPaypalOrder({ orderId, usdAmount: input.usdAmount, xicReceiveAddress: input.xicReceiveAddress, }); if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); return { orderId, paypalOrderId: result.paypalOrderId, approveUrl: result.approveUrl }; }), capturePaypalOrder: publicProcedure .input(z.object({ paypalOrderId: z.string(), internalOrderId: z.string(), })) .mutation(async ({ input }) => { const result = await capturePaypalOrder(input.paypalOrderId, input.internalOrderId); if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error }); return { success: true, captureId: result.captureId }; }), queryPaypalOrder: publicProcedure .input(z.object({ orderId: z.string() })) .query(async ({ input }) => { const db = await getDb(); if (db) { const rows = await db.select().from(fiatOrders).where(eq(fiatOrders.orderId, input.orderId)).limit(1); if (rows[0]) return { dbStatus: rows[0].status, xicAmount: rows[0].xicAmount }; } return { dbStatus: "not_found" }; }), // ── Admin: List Fiat Orders ─────────────────────────────────────────────── listFiatOrders: publicProcedure .input(z.object({ token: z.string(), page: z.number().min(1).default(1), limit: z.number().min(1).max(100).default(20), channel: z.enum(["all", "alipay", "wechat", "paypal"]).default("all"), status: z.enum(["all", "pending", "paid", "distributed", "refunded", "failed", "expired"]).default("all"), })) .query(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); const offset = (input.page - 1) * input.limit; let query = db.select().from(fiatOrders); if (input.channel !== "all") { query = query.where(eq(fiatOrders.channel, input.channel)) as typeof query; } if (input.status !== "all") { query = query.where(eq(fiatOrders.status, input.status)) as typeof query; } const rows = await query .orderBy(desc(fiatOrders.createdAt)) .limit(input.limit) .offset(offset); const countResult = await db .select({ count: sql`COUNT(*)` }) .from(fiatOrders); return { orders: rows.map(r => ({ id: r.id, orderId: r.orderId, channel: r.channel, currency: r.currency, originalAmount: Number(r.originalAmount), usdtEquivalent: Number(r.usdtEquivalent), xicAmount: Number(r.xicAmount), xicReceiveAddress: r.xicReceiveAddress, status: r.status, payerEmail: r.payerEmail, distributedAt: r.distributedAt, createdAt: r.createdAt, })), total: Number(countResult[0]?.count || 0), page: input.page, limit: input.limit, }; }), }); export const appRouter = router({ system: systemRouter, bridge: bridgeRouter, payment: paymentRouter, auth: router({ me: publicProcedure.query(opts => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true } as const; }), }), // ─── Presale Stats ──────────────────────────────────────────────────────── presale: router({ // Combined stats from BSC + ETH + TRC20 stats: publicProcedure.query(async () => { const stats = await getCombinedStats(); // Append presale active/paused status from config const presaleStatus = await getConfig("presaleStatus"); return { ...stats, presaleStatus: presaleStatus ?? "live" }; }), // Single chain stats chainStats: publicProcedure .input(z.object({ chain: z.enum(["BSC", "ETH"]) })) .query(async ({ input }) => { return await getPresaleStats(input.chain); }), // Recent purchases (TRC20 from DB + mock EVM for live feed) recentPurchases: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(20) })) .query(async ({ input }) => { const trc20 = await getRecentPurchases(input.limit); return trc20; }), // Submit EVM address for a pending TRC20 purchase // User provides their TRON tx hash and EVM address to receive XIC tokens submitEvmAddress: publicProcedure .input(z.object({ txHash: z.string().min(10).max(128), evmAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/, "Invalid EVM address format"), })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); const existing = await db .select() .from(trc20Purchases) .where(eq(trc20Purchases.txHash, input.txHash)) .limit(1); if (existing.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Transaction not found. Please wait for confirmation." }); } if (existing[0].status === "distributed") { throw new TRPCError({ code: "BAD_REQUEST", message: "Tokens already distributed for this transaction." }); } await db .update(trc20Purchases) .set({ evmAddress: input.evmAddress, updatedAt: new Date() }) .where(eq(trc20Purchases.txHash, input.txHash)); return { success: true, message: "EVM address saved. Tokens will be distributed within 1-24 hours." }; }), // Register a new TRC20 purchase intent (user submits EVM address before/after sending) // Stored in DB so TRC20 Monitor can auto-match when it detects the TX registerTrc20Intent: publicProcedure .input(z.object({ evmAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/, "Invalid EVM address format"), expectedUsdt: z.number().min(0.01).optional(), })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); // Store the intent in DB for auto-matching await db.insert(trc20Intents).values({ evmAddress: input.evmAddress, expectedUsdt: input.expectedUsdt ? String(input.expectedUsdt) : null, matched: false, }); return { success: true, receivingAddress: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp", evmAddress: input.evmAddress, message: "EVM address registered. Please send TRC20 USDT to the address above. Tokens will be distributed to your EVM address within 1-24 hours.", }; }), }), // ─── Admin ──────────────────────────────────────────────────────────────── admin: router({ // Admin login — returns a simple token login: publicProcedure .input(z.object({ password: z.string() })) .mutation(async ({ input }) => { if (input.password !== ADMIN_PASSWORD) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid password" }); } // Return a simple session token (base64 of timestamp + password hash) const token = Buffer.from(`nac-admin:${Date.now()}`).toString("base64"); return { success: true, token }; }), // List all TRC20 purchases with pagination listPurchases: publicProcedure .input(z.object({ token: z.string(), page: z.number().min(1).default(1), limit: z.number().min(1).max(100).default(20), status: z.enum(["all", "pending", "confirmed", "distributed", "failed"]).default("all"), })) .query(async ({ input }) => { // Verify admin token if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); const offset = (input.page - 1) * input.limit; let query = db.select().from(trc20Purchases); if (input.status !== "all") { query = query.where(eq(trc20Purchases.status, input.status)) as typeof query; } const rows = await query .orderBy(desc(trc20Purchases.createdAt)) .limit(input.limit) .offset(offset); // Get total count const countResult = await db .select({ count: sql`COUNT(*)` }) .from(trc20Purchases) .where(input.status !== "all" ? eq(trc20Purchases.status, input.status) : sql`1=1`); return { purchases: rows.map(r => ({ id: r.id, txHash: r.txHash, fromAddress: r.fromAddress, evmAddress: r.evmAddress, usdtAmount: Number(r.usdtAmount), xicAmount: Number(r.xicAmount), status: r.status, distributedAt: r.distributedAt, distributeTxHash: r.distributeTxHash, createdAt: r.createdAt, })), total: Number(countResult[0]?.count || 0), page: input.page, limit: input.limit, }; }), // Mark a purchase as distributed markDistributed: publicProcedure .input(z.object({ token: z.string(), purchaseId: z.number(), distributeTxHash: z.string().optional(), })) .mutation(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); const purchase = await db .select() .from(trc20Purchases) .where(eq(trc20Purchases.id, input.purchaseId)) .limit(1); await db .update(trc20Purchases) .set({ status: "distributed", distributedAt: new Date(), distributeTxHash: input.distributeTxHash || null, updatedAt: new Date(), }) .where(eq(trc20Purchases.id, input.purchaseId)); // Send Telegram notification if (purchase[0]?.evmAddress) { try { await notifyDistributed({ txHash: purchase[0].txHash, evmAddress: purchase[0].evmAddress, xicAmount: Number(purchase[0].xicAmount), distributeTxHash: input.distributeTxHash, }); } catch (e) { console.warn("[Admin] Telegram notification failed:", e); } } return { success: true }; }), // List unmatched EVM address intents listIntents: publicProcedure .input(z.object({ token: z.string(), showAll: z.boolean().default(false), })) .query(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); let query = db.select().from(trc20Intents); if (!input.showAll) { query = query.where(eq(trc20Intents.matched, false)) as typeof query; } const rows = await query .orderBy(desc(trc20Intents.createdAt)) .limit(50); return rows.map(r => ({ id: r.id, evmAddress: r.evmAddress, expectedUsdt: r.expectedUsdt ? Number(r.expectedUsdt) : null, matched: r.matched, matchedPurchaseId: r.matchedPurchaseId, createdAt: r.createdAt, })); }), // Get summary stats for admin dashboard stats: publicProcedure .input(z.object({ token: z.string() })) .query(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); const result = await db .select({ status: trc20Purchases.status, count: sql`COUNT(*)`, totalUsdt: sql`SUM(CAST(${trc20Purchases.usdtAmount} AS DECIMAL(30,6)))`, totalXic: sql`SUM(CAST(${trc20Purchases.xicAmount} AS DECIMAL(30,6)))`, }) .from(trc20Purchases) .groupBy(trc20Purchases.status); return result.map(r => ({ status: r.status, count: Number(r.count), totalUsdt: Number(r.totalUsdt || 0), totalXic: Number(r.totalXic || 0), })); }), // ─── Presale Configuration Management ───────────────────────────────── // Get all config key-value pairs with metadata getConfig: publicProcedure .input(z.object({ token: z.string() })) .query(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } // Seed defaults first await seedDefaultConfig(); const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); const { presaleConfig } = await import("../drizzle/schema"); const rows = await db.select().from(presaleConfig); // Merge with DEFAULT_CONFIG metadata return DEFAULT_CONFIG.map(def => { const row = rows.find(r => r.key === def.key); return { key: def.key, value: row?.value ?? def.value, label: def.label, description: def.description, type: def.type, updatedAt: row?.updatedAt ?? null, }; }); }), // Update a single config value setConfig: publicProcedure .input(z.object({ token: z.string(), key: z.string().min(1).max(64), value: z.string(), })) .mutation(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } // Validate key is in allowed list const allowed = DEFAULT_CONFIG.map(c => c.key); if (!allowed.includes(input.key)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown config key: ${input.key}` }); } await setConfig(input.key, input.value); return { success: true }; }), // List bridge orders (cross-chain bridge) listBridgeOrders: publicProcedure .input(z.object({ token: z.string(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20), status: z.enum(["all", "pending", "confirmed", "distributed", "failed"]).default("all"), })) .query(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); const offset = (input.page - 1) * input.limit; let query = db.select().from(bridgeOrders); if (input.status !== "all") { query = query.where(eq(bridgeOrders.status, input.status)) as typeof query; } const rows = await query .orderBy(desc(bridgeOrders.createdAt)) .limit(input.limit) .offset(offset); const countResult = await db .select({ count: sql`COUNT(*)` }) .from(bridgeOrders) .where(input.status !== "all" ? eq(bridgeOrders.status, input.status) : sql`1=1`); const intents = await db .select() .from(bridgeIntents) .orderBy(desc(bridgeIntents.createdAt)) .limit(50); return { orders: rows.map(r => ({ id: r.id, txHash: r.txHash, walletAddress: r.walletAddress, fromChainId: r.fromChainId, fromToken: r.fromToken, fromAmount: Number(r.fromAmount), toChainId: r.toChainId, toToken: r.toToken, toAmount: Number(r.toAmount), xicReceiveAddress: r.xicReceiveAddress, status: r.status, confirmedAt: r.confirmedAt, distributedAt: r.distributedAt, distributeTxHash: r.distributeTxHash, blockNumber: r.blockNumber, createdAt: r.createdAt, })), intents: intents.map(i => ({ id: i.id, fromChainId: i.fromChainId, senderAddress: i.senderAddress, xicReceiveAddress: i.xicReceiveAddress, expectedUsdt: i.expectedUsdt ? Number(i.expectedUsdt) : null, matched: i.matched, matchedOrderId: i.matchedOrderId, createdAt: i.createdAt, })), total: Number(countResult[0]?.count || 0), page: input.page, limit: input.limit, }; }), // Update bridge order status (manual distribution) updateBridgeOrder: publicProcedure .input(z.object({ token: z.string(), orderId: z.number().int(), status: z.enum(["pending", "confirmed", "distributed", "failed"]), distributeTxHash: z.string().optional(), })) .mutation(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" }); await db .update(bridgeOrders) .set({ status: input.status, distributedAt: input.status === "distributed" ? new Date() : undefined, distributeTxHash: input.distributeTxHash || null, updatedAt: new Date(), }) .where(eq(bridgeOrders.id, input.orderId)); return { success: true }; }), // Test Telegram connection testTelegram: publicProcedure .input(z.object({ token: z.string(), botToken: z.string().min(10), chatId: z.string().min(1), })) .mutation(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const result = await testTelegramConnection(input.botToken, input.chatId); if (!result.success) { throw new TRPCError({ code: "BAD_REQUEST", message: result.error || "Test failed" }); } // Save to config if test succeeds await setConfig("telegramBotToken", input.botToken); await setConfig("telegramChatId", input.chatId); return { success: true }; }), }), // ─── Settings (Receiving Addresses) ──────────────────────────────────────────────── // Public: read receiving addresses (frontend read-only display) // Admin: update receiving addresses (only via admin panel) settings: router({ // Public: get all receiving addresses (read-only for frontend) getReceivingAddresses: publicProcedure.query(async () => { const keys = [ "trc20_receiving_address", "bsc_receiving_address", "eth_receiving_address", "polygon_receiving_address", "arbitrum_receiving_address", "avalanche_receiving_address", ]; const result: Record = {}; for (const key of keys) { const val = await getSiteSetting(key); result[key] = val ?? DEFAULT_RECEIVING_ADDRESSES[key] ?? ""; } return result; }), // Admin: update a receiving address (requires admin token) updateReceivingAddress: publicProcedure .input(z.object({ token: z.string(), key: z.enum(["trc20_receiving_address", "bsc_receiving_address", "eth_receiving_address", "polygon_receiving_address", "arbitrum_receiving_address", "avalanche_receiving_address"]), value: z.string().min(10).max(128), })) .mutation(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const labels: Record = { trc20_receiving_address: "TRC20 USDT 接收地址", bsc_receiving_address: "BSC BEP-20 USDT 接收地址", eth_receiving_address: "ETH ERC-20 USDT 接收地址", polygon_receiving_address: "Polygon USDT 接收地址", arbitrum_receiving_address: "Arbitrum USDT 接收地址", avalanche_receiving_address: "Avalanche USDT 接收地址", }; await upsertSiteSetting( input.key, input.value, labels[input.key], "仅管理员可修改,前端只读显示" ); console.log(`[Admin] Receiving address updated: ${input.key} = ${input.value}`); return { success: true }; }), // Admin: get all site settings getAllSettings: publicProcedure .input(z.object({ token: z.string() })) .query(async ({ input }) => { if (!input.token.startsWith("bmFjLWFkbWlu")) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" }); } const db = await getDb(); if (!db) return []; return await db.select().from(siteSettings); }), }), }); export type AppRouter = typeof appRouter;