459 lines
17 KiB
TypeScript
459 lines
17 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, bridgeOrders } 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!";
|
|
|
|
// ─── Bridge Router ───────────────────────────────────────────────────────────
|
|
const bridgeRouter = router({
|
|
// Record a completed Li.Fi cross-chain order
|
|
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(),
|
|
toChainId: z.number().int(),
|
|
toToken: z.string().max(32),
|
|
toAmount: z.string(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const db = await getDb();
|
|
if (!db) return { success: false, message: "DB unavailable" };
|
|
try {
|
|
await db.insert(bridgeOrders).values({
|
|
txHash: input.txHash,
|
|
walletAddress: input.walletAddress,
|
|
fromChainId: input.fromChainId,
|
|
fromToken: input.fromToken,
|
|
fromAmount: input.fromAmount,
|
|
toChainId: input.toChainId,
|
|
toToken: input.toToken,
|
|
toAmount: input.toAmount,
|
|
status: "completed",
|
|
});
|
|
return { success: true };
|
|
} catch (e: any) {
|
|
if (e?.code === "ER_DUP_ENTRY") return { success: true };
|
|
throw e;
|
|
}
|
|
}),
|
|
|
|
// List orders by wallet address
|
|
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 rows = await db
|
|
.select()
|
|
.from(bridgeOrders)
|
|
.where(eq(bridgeOrders.walletAddress, input.walletAddress.toLowerCase()))
|
|
.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,
|
|
}));
|
|
}),
|
|
|
|
// 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,
|
|
}));
|
|
}),
|
|
});
|
|
|
|
export const appRouter = router({
|
|
system: systemRouter,
|
|
bridge: bridgeRouter,
|
|
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<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" });
|
|
|
|
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<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),
|
|
}));
|
|
}),
|
|
|
|
// ─── 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;
|