892 lines
34 KiB
TypeScript
892 lines
34 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, 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<string, string> = {
|
|
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<string | null> {
|
|
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<number>`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<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 };
|
|
}),
|
|
|
|
// 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<number>`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<string, string> = {};
|
|
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<string, string> = {
|
|
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;
|