/** * NAC Cross-Chain Bridge Monitor * Self-developed bridge: monitors USDT transfers on BSC/ETH/Polygon/Arbitrum/Avalanche * When user sends USDT to our receiving address, we record the order and distribute XIC */ import { getDb } from "./db"; import { bridgeOrders, bridgeIntents } from "../drizzle/schema"; import { eq, and, desc } from "drizzle-orm"; import { creditXic } from "./tokenDistributionService"; // ─── Presale Config ─────────────────────────────────────────────────────────── export const XIC_PRICE_USDT = 0.02; // $0.02 per XIC // ─── Chain Configs ──────────────────────────────────────────────────────────── export interface ChainConfig { chainId: number; name: string; symbol: string; // native token symbol (BNB, ETH, MATIC, AVAX) icon: string; color: string; usdtAddress: string; // USDT contract on this chain receivingAddress: string; // Our USDT receiving address on this chain rpcUrl: string; explorerUrl: string; explorerTxPath: string; // e.g. /tx/ decimals: number; // USDT decimals (6 for most, 18 for BSC) } export const BRIDGE_CHAINS: ChainConfig[] = [ { chainId: 56, name: "BSC", symbol: "BNB", icon: "🟡", color: "#F0B90B", usdtAddress: "0x55d398326f99059fF775485246999027B3197955", receivingAddress: process.env.BRIDGE_BSC_ADDRESS || "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3", rpcUrl: "https://bsc-dataseed1.binance.org/", explorerUrl: "https://bscscan.com", explorerTxPath: "/tx/", decimals: 18, // BSC USDT is 18 decimals }, { chainId: 1, name: "Ethereum", symbol: "ETH", icon: "🔵", color: "#627EEA", usdtAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", receivingAddress: process.env.BRIDGE_ETH_ADDRESS || "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3", rpcUrl: "https://ethereum.publicnode.com", explorerUrl: "https://etherscan.io", explorerTxPath: "/tx/", decimals: 6, // ETH USDT is 6 decimals }, { chainId: 137, name: "Polygon", symbol: "MATIC", icon: "🟣", color: "#8247E5", usdtAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", receivingAddress: process.env.BRIDGE_POLYGON_ADDRESS || "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3", rpcUrl: "https://polygon-rpc.com/", explorerUrl: "https://polygonscan.com", explorerTxPath: "/tx/", decimals: 6, }, { chainId: 42161, name: "Arbitrum", symbol: "ETH", icon: "🔷", color: "#28A0F0", usdtAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", receivingAddress: process.env.BRIDGE_ARB_ADDRESS || "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3", rpcUrl: "https://arb1.arbitrum.io/rpc", explorerUrl: "https://arbiscan.io", explorerTxPath: "/tx/", decimals: 6, }, { chainId: 43114, name: "Avalanche", symbol: "AVAX", icon: "🔴", color: "#E84142", usdtAddress: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", receivingAddress: process.env.BRIDGE_AVAX_ADDRESS || "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3", rpcUrl: "https://api.avax.network/ext/bc/C/rpc", explorerUrl: "https://snowtrace.io", explorerTxPath: "/tx/", decimals: 6, }, ]; // ─── ERC-20 Transfer event ABI ──────────────────────────────────────────────── // Transfer(address indexed from, address indexed to, uint256 value) const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; // ─── Fetch USDT transfers to our address via eth_getLogs ────────────────────── async function fetchUsdtTransfers(chain: ChainConfig, fromBlock: string = "latest"): Promise> { try { // Pad address to 32 bytes for topic matching const paddedTo = "0x000000000000000000000000" + chain.receivingAddress.slice(2).toLowerCase(); const payload = { jsonrpc: "2.0", id: 1, method: "eth_getLogs", params: [{ fromBlock: fromBlock === "latest" ? "latest" : fromBlock, toBlock: "latest", address: chain.usdtAddress, topics: [ TRANSFER_TOPIC, null, // any sender paddedTo, // to our receiving address ], }], }; const res = await fetch(chain.rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(10000), }); const data = await res.json(); if (!data.result || !Array.isArray(data.result)) return []; return data.result.map((log: any) => { const fromAddress = "0x" + log.topics[1].slice(26); const toAddress = "0x" + log.topics[2].slice(26); const rawAmount = BigInt(log.data); const divisor = BigInt(10 ** chain.decimals); const amount = Number(rawAmount) / Number(divisor); const blockNumber = parseInt(log.blockNumber, 16); return { txHash: log.transactionHash, fromAddress: fromAddress.toLowerCase(), toAddress: toAddress.toLowerCase(), amount, blockNumber, }; }); } catch (err) { console.error(`[BridgeMonitor] Error fetching transfers on ${chain.name}:`, err); return []; } } // ─── Get latest block number ────────────────────────────────────────────────── async function getLatestBlock(chain: ChainConfig): Promise { try { const res = await fetch(chain.rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_blockNumber", params: [] }), signal: AbortSignal.timeout(8000), }); const data = await res.json(); return parseInt(data.result, 16); } catch { return 0; } } // ─── Process new transfers and save to DB ──────────────────────────────────── async function processTransfers(chain: ChainConfig): Promise { const db = await getDb(); if (!db) return; // Get recent blocks (last ~100 blocks ≈ ~5 min on BSC, ~20 min on ETH) const latestBlock = await getLatestBlock(chain); if (!latestBlock) return; const lookbackBlocks = chain.chainId === 1 ? 50 : 200; // ETH slower, BSC faster const fromBlock = "0x" + Math.max(0, latestBlock - lookbackBlocks).toString(16); const transfers = await fetchUsdtTransfers(chain, fromBlock); for (const transfer of transfers) { if (transfer.amount < 0.01) continue; // ignore dust // Check if already recorded try { const existing = await db .select({ id: bridgeOrders.id }) .from(bridgeOrders) .where(eq(bridgeOrders.txHash, transfer.txHash)) .limit(1); if (existing.length > 0) continue; // already recorded // Calculate XIC amount const xicAmount = transfer.amount / XIC_PRICE_USDT; // Try to find matching intent (user pre-registered their XIC receive address) const intent = await db .select() .from(bridgeIntents) .where(and( eq(bridgeIntents.fromChainId, chain.chainId), eq(bridgeIntents.senderAddress, transfer.fromAddress), eq(bridgeIntents.matched, false), )) .orderBy(desc(bridgeIntents.createdAt)) .limit(1); const xicReceiveAddress = intent.length > 0 ? intent[0].xicReceiveAddress : null; // Record the order first (pending status) try { await db.insert(bridgeOrders).values({ txHash: transfer.txHash, walletAddress: transfer.fromAddress, fromChainId: chain.chainId, fromToken: "USDT", fromAmount: String(transfer.amount), toChainId: 56, toToken: "XIC", toAmount: String(xicAmount), xicReceiveAddress, status: "pending", blockNumber: transfer.blockNumber, }); } catch (insertErr: any) { if (insertErr?.code !== "ER_DUP_ENTRY") throw insertErr; } // Mark intent as matched if (intent.length > 0) { await db .update(bridgeIntents) .set({ matched: true, matchedOrderId: undefined }) .where(eq(bridgeIntents.id, intent[0].id)); } // Use unified tokenDistributionService (idempotent via transaction_logs) await creditXic({ txHash: transfer.txHash, chainType: "ERC20", fromAddress: transfer.fromAddress, toAddress: chain.receivingAddress, usdtAmount: transfer.amount, xicAmount, blockNumber: transfer.blockNumber, xicReceiveAddress: xicReceiveAddress ?? undefined, remark: `${chain.name} auto-detected`, }); console.log(`[BridgeMonitor] New ${chain.name} deposit: ${transfer.amount} USDT from ${transfer.fromAddress} → ${xicAmount} XIC`); } catch (err: any) { if (err?.code === "ER_DUP_ENTRY") continue; console.error(`[BridgeMonitor] Error recording transfer ${transfer.txHash}:`, err); } } } // ─── Start monitoring all chains ────────────────────────────────────────────── let monitorInterval: NodeJS.Timeout | null = null; export function startBridgeMonitor(): void { if (monitorInterval) return; console.log("[BridgeMonitor] Starting multi-chain USDT deposit monitor..."); const run = async () => { for (const chain of BRIDGE_CHAINS) { await processTransfers(chain).catch(err => console.error(`[BridgeMonitor] Error on ${chain.name}:`, err) ); } }; // Run immediately, then every 30 seconds run(); monitorInterval = setInterval(run, 30_000); } export function stopBridgeMonitor(): void { if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null; console.log("[BridgeMonitor] Stopped."); } } // ─── Export chain config for use in routes ─────────────────────────────────── export function getChainConfig(chainId: number): ChainConfig | undefined { return BRIDGE_CHAINS.find(c => c.chainId === chainId); }