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 } 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"; // Admin password from env (fallback for development) const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!"; export const appRouter = router({ system: systemRouter, 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 }; }), // 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 }; }), }), }); export type AppRouter = typeof appRouter;