nac-presale/server/trc20Monitor.ts

242 lines
6.8 KiB
TypeScript

/**
* TRC20 USDT Monitor & Auto-Distribution Service
*
* Flow:
* 1. Poll TRON address for incoming USDT transactions every 30s
* 2. For each new confirmed tx, record in DB
* 3. Look for pre-registered EVM address intent (from trc20_intents table)
* 4. Auto-match the EVM address to the purchase record
* 5. Distribute XIC from operator wallet to buyer's EVM address (if available)
* OR mark as pending manual distribution
*/
import { eq, sql } from "drizzle-orm";
import { getDb } from "./db";
import { trc20Purchases, trc20Intents } from "../drizzle/schema";
import { TOKEN_PRICE_USDT } from "./onchain";
import { notifyNewTRC20Purchase } from "./telegram";
import { creditXic } from "./tokenDistributionService";
const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp";
const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
// Trongrid API endpoint (public, no key needed for basic queries)
const TRONGRID_API = "https://api.trongrid.io";
let isMonitoring = false;
let monitorInterval: ReturnType<typeof setInterval> | null = null;
interface TronTransaction {
transaction_id: string;
token_info?: { address: string; decimals: number };
from: string;
to: string;
value: string;
block_timestamp: number;
type: string;
}
async function fetchRecentTRC20Transfers(): Promise<TronTransaction[]> {
try {
const url = `${TRONGRID_API}/v1/accounts/${TRON_RECEIVING_ADDRESS}/transactions/trc20?limit=50&contract_address=${TRON_USDT_CONTRACT}&only_confirmed=true`;
const resp = await fetch(url, {
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
console.warn(`[TRC20Monitor] API error: ${resp.status}`);
return [];
}
const data = await resp.json() as { data?: TronTransaction[]; success?: boolean };
if (!data.success || !Array.isArray(data.data)) return [];
// Only incoming transfers (to our address)
return data.data.filter((tx) => tx.to === TRON_RECEIVING_ADDRESS);
} catch (e) {
console.warn("[TRC20Monitor] Fetch error:", e);
return [];
}
}
async function processTransaction(tx: TronTransaction): Promise<void> {
const db = await getDb();
if (!db) return;
// Check if already processed
const existing = await db
.select()
.from(trc20Purchases)
.where(eq(trc20Purchases.txHash, tx.transaction_id))
.limit(1);
if (existing.length > 0) return; // Already recorded
// USDT has 6 decimals on TRON
const usdtAmount = Number(tx.value) / 1_000_000;
if (usdtAmount < 0.01) return; // Skip dust
const xicAmount = usdtAmount / TOKEN_PRICE_USDT;
console.log(
`[TRC20Monitor] New purchase: ${tx.from}${usdtAmount} USDT → ${xicAmount} XIC (tx: ${tx.transaction_id})`
);
// Look for a pre-registered EVM intent (user submitted their EVM address before sending)
// Match by most recent unmatched intent
let matchedEvmAddress: string | null = null;
let matchedIntentId: number | null = null;
const recentIntents = await db
.select()
.from(trc20Intents)
.where(eq(trc20Intents.matched, false))
.orderBy(trc20Intents.createdAt)
.limit(1);
if (recentIntents.length > 0) {
matchedEvmAddress = recentIntents[0].evmAddress;
matchedIntentId = recentIntents[0].id;
console.log(`[TRC20Monitor] Auto-matched EVM address ${matchedEvmAddress} for TX ${tx.transaction_id}`);
}
// Record in DB with EVM address if matched
await db.insert(trc20Purchases).values({
txHash: tx.transaction_id,
fromAddress: tx.from,
usdtAmount: String(usdtAmount),
xicAmount: String(xicAmount),
blockNumber: tx.block_timestamp,
status: "confirmed",
evmAddress: matchedEvmAddress || undefined,
createdAt: new Date(),
updatedAt: new Date(),
});
// Mark the intent as matched
if (matchedIntentId !== null) {
await db
.update(trc20Intents)
.set({ matched: true })
.where(eq(trc20Intents.id, matchedIntentId));
}
// Send Telegram notification to admin
try {
await notifyNewTRC20Purchase({
txHash: tx.transaction_id,
fromAddress: tx.from,
usdtAmount,
xicAmount,
evmAddress: matchedEvmAddress,
});
} catch (e) {
console.warn("[TRC20Monitor] Telegram notification failed:", e);
}
// Use unified tokenDistributionService (idempotent via transaction_logs)
await creditXic({
txHash: tx.transaction_id,
chainType: "TRC20",
fromAddress: tx.from,
toAddress: TRON_RECEIVING_ADDRESS,
usdtAmount,
xicAmount,
xicReceiveAddress: matchedEvmAddress ?? undefined,
remark: "TRC20 auto-detected",
});
// Attempt auto-distribution via BSC
await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress);
}
async function attemptAutoDistribute(
txHash: string,
fromTronAddress: string,
xicAmount: number,
evmAddress: string | null
): Promise<void> {
const db = await getDb();
if (!db) return;
const operatorPrivateKey = process.env.OPERATOR_PRIVATE_KEY;
if (!operatorPrivateKey) {
console.warn("[TRC20Monitor] No OPERATOR_PRIVATE_KEY set, skipping auto-distribute");
return;
}
if (!evmAddress) {
console.log(
`[TRC20Monitor] No EVM address for ${fromTronAddress}: ${xicAmount} XIC — admin must distribute manually`
);
return;
}
// EVM address available — log for admin to distribute
console.log(
`[TRC20Monitor] Ready to distribute ${xicAmount} XIC to ${evmAddress} for TX ${txHash}`
);
console.log(
`[TRC20Monitor] Admin can mark as distributed via admin panel`
);
}
export async function startTRC20Monitor(): Promise<void> {
if (isMonitoring) return;
isMonitoring = true;
console.log("[TRC20Monitor] Starting monitor for", TRON_RECEIVING_ADDRESS);
const poll = async () => {
try {
const txs = await fetchRecentTRC20Transfers();
for (const tx of txs) {
await processTransaction(tx);
}
} catch (e) {
console.error("[TRC20Monitor] Poll error:", e);
}
};
// Initial poll
await poll();
// Poll every 30 seconds
monitorInterval = setInterval(poll, 30_000);
}
export function stopTRC20Monitor(): void {
if (monitorInterval) {
clearInterval(monitorInterval);
monitorInterval = null;
}
isMonitoring = false;
}
export async function getRecentPurchases(limit = 20): Promise<Array<{
txHash: string;
fromAddress: string;
usdtAmount: number;
xicAmount: number;
status: string;
createdAt: Date;
chain: string;
}>> {
const db = await getDb();
if (!db) return [];
const rows = await db
.select()
.from(trc20Purchases)
.orderBy(sql`${trc20Purchases.createdAt} DESC`)
.limit(limit);
return rows.map((r) => ({
txHash: r.txHash,
fromAddress: r.fromAddress,
usdtAmount: Number(r.usdtAmount),
xicAmount: Number(r.xicAmount),
status: r.status,
createdAt: r.createdAt,
chain: "TRON",
}));
}