nac-presale/server/routers.ts

802 lines
31 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 } from "../drizzle/schema";
// 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 };
}),
}),
});
export type AppRouter = typeof appRouter;