271 lines
9.9 KiB
TypeScript
271 lines
9.9 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, trc20Intents } 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 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<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 };
|
|
}),
|
|
|
|
// 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<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;
|