xic-presale/server/bridgeMonitor.ts

286 lines
9.9 KiB
TypeScript

/**
* 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";
// ─── 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<Array<{
txHash: string;
fromAddress: string;
toAddress: string;
amount: number; // in USDT (human-readable)
blockNumber: number;
}>> {
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<number> {
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<void> {
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
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: "confirmed",
confirmedAt: new Date(),
blockNumber: transfer.blockNumber,
});
// Mark intent as matched
if (intent.length > 0) {
await db
.update(bridgeIntents)
.set({ matched: true, matchedOrderId: undefined })
.where(eq(bridgeIntents.id, intent[0].id));
}
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);
}