286 lines
9.9 KiB
TypeScript
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);
|
|
}
|