/** * 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"; 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 | 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 { 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 { 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)); } // 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 { 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 { 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> { 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", })); }