nac-presale/server/routers.ts

229 lines
8.5 KiB
TypeScript

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 } from "../drizzle/schema";
import { eq, desc, sql } from "drizzle-orm";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
// 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 () => {
return await getCombinedStats();
}),
// 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 before sending)
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 }) => {
// Store the EVM address mapping so when we detect the TX, we can auto-distribute
// We return the receiving address for the user to send to
return {
success: true,
receivingAddress: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp",
evmAddress: input.evmAddress,
message: "Please send TRC20 USDT to the address above. Include your EVM address in the memo for faster processing.",
};
}),
}),
// ─── 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<number>`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" });
await db
.update(trc20Purchases)
.set({
status: "distributed",
distributedAt: new Date(),
distributeTxHash: input.distributeTxHash || null,
updatedAt: new Date(),
})
.where(eq(trc20Purchases.id, input.purchaseId));
return { success: true };
}),
// 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<number>`COUNT(*)`,
totalUsdt: sql<string>`SUM(CAST(${trc20Purchases.usdtAmount} AS DECIMAL(30,6)))`,
totalXic: sql<string>`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),
}));
}),
}),
});
export type AppRouter = typeof appRouter;