nac-presale/server/onchain.ts

329 lines
9.5 KiB
TypeScript

/**
* On-chain data service
* Reads presale stats from BSC and ETH contracts using ethers.js
* Caches results in DB to avoid rate limiting
*
* RPC Strategy: Multi-node failover pool — tries each node in order until one succeeds
*/
import { ethers } from "ethers";
import { eq } from "drizzle-orm";
import { getDb } from "./db";
import { presaleStatsCache } from "../drizzle/schema";
// ─── Multi-node RPC Pool ────────────────────────────────────────────────────────
// Multiple public RPC endpoints for each chain — tried in order, first success wins
const RPC_POOLS = {
BSC: [
"https://bsc-dataseed1.binance.org/",
"https://bsc-dataseed2.binance.org/",
"https://bsc-dataseed3.binance.org/",
"https://bsc-dataseed4.binance.org/",
"https://bsc-dataseed1.defibit.io/",
"https://bsc-dataseed2.defibit.io/",
"https://bsc.publicnode.com",
"https://binance.llamarpc.com",
"https://rpc.ankr.com/bsc",
],
ETH: [
"https://ethereum.publicnode.com", // China-accessible
"https://rpc.ankr.com/eth",
"https://eth.drpc.org",
"https://1rpc.io/eth",
"https://eth.llamarpc.com",
"https://cloudflare-eth.com",
"https://rpc.payload.de",
],
};
// ─── Contract Addresses ────────────────────────────────────────────────────────
export const CONTRACTS = {
BSC: {
presale: "0x5953c025dA734e710886916F2d739A3A78f8bbc4", // XICPresale v2
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
rpc: RPC_POOLS.BSC[0],
chainId: 56,
chainName: "BNB Smart Chain",
explorerUrl: "https://bscscan.com",
usdt: "0x55d398326f99059fF775485246999027B3197955",
},
ETH: {
presale: "0x85AB2F2d9f7ca7ecB272b5E8726c70f3fd45D1E3",
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
rpc: RPC_POOLS.ETH[0],
chainId: 1,
chainName: "Ethereum",
explorerUrl: "https://etherscan.io",
usdt: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
},
TRON: {
receivingWallet: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp",
evmReceivingWallet: "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3",
usdtContract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
},
};
// Minimal ABI for reading presale stats
const PRESALE_ABI = [
"function totalUSDTRaised() view returns (uint256)",
"function totalTokensSold() view returns (uint256)",
"function weiRaised() view returns (uint256)",
"function tokensSold() view returns (uint256)",
"function usdtRaised() view returns (uint256)",
];
// Token price: $0.02 per XIC
export const TOKEN_PRICE_USDT = 0.02;
export const HARD_CAP_USDT = 5_000_000;
export const TOTAL_SUPPLY = 100_000_000_000;
export const MAX_PURCHASE_USDT = 50_000;
export interface PresaleStats {
chain: string;
usdtRaised: number;
tokensSold: number;
lastUpdated: Date;
fromCache: boolean;
rpcUsed?: string;
}
export interface CombinedStats {
totalUsdtRaised: number;
totalTokensSold: number;
hardCap: number;
progressPct: number;
bsc: PresaleStats | null;
eth: PresaleStats | null;
trc20UsdtRaised: number;
trc20TokensSold: number;
lastUpdated: Date;
}
// Cache TTL: 60 seconds
const CACHE_TTL_MS = 60_000;
// RPC timeout: 8 seconds per node
const RPC_TIMEOUT_MS = 8_000;
/**
* Try each RPC node in the pool until one succeeds.
* Returns { usdtRaised, tokensSold, rpcUsed } or throws if all fail.
*/
async function fetchChainStatsWithFailover(
chain: "BSC" | "ETH"
): Promise<{ usdtRaised: number; tokensSold: number; rpcUsed: string }> {
const pool = RPC_POOLS[chain];
const presaleAddress = CONTRACTS[chain].presale;
const errors: string[] = [];
for (const rpcUrl of pool) {
try {
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
staticNetwork: true,
polling: false,
});
// Set a timeout for the provider
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`RPC timeout: ${rpcUrl}`)), RPC_TIMEOUT_MS)
);
const contract = new ethers.Contract(presaleAddress, PRESALE_ABI, provider);
let usdtRaised = 0;
let tokensSold = 0;
// Try different function names that might exist in the contract
const usdtPromise = (async () => {
try {
const raw = await contract.totalUSDTRaised();
return Number(ethers.formatUnits(raw, 6));
} catch {
try {
const raw = await contract.usdtRaised();
return Number(ethers.formatUnits(raw, 6));
} catch {
try {
const raw = await contract.weiRaised();
return Number(ethers.formatUnits(raw, 6));
} catch {
return 0;
}
}
}
})();
const tokensPromise = (async () => {
try {
const raw = await contract.totalTokensSold();
return Number(ethers.formatUnits(raw, 18));
} catch {
try {
const raw = await contract.tokensSold();
return Number(ethers.formatUnits(raw, 18));
} catch {
return 0;
}
}
})();
const [usdtResult, tokensResult] = await Promise.race([
Promise.all([usdtPromise, tokensPromise]),
timeoutPromise,
]);
usdtRaised = usdtResult;
tokensSold = tokensResult;
console.log(`[OnChain] ${chain} stats fetched via ${rpcUrl}: $${usdtRaised} USDT, ${tokensSold} XIC`);
return { usdtRaised, tokensSold, rpcUsed: rpcUrl };
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
errors.push(`${rpcUrl}: ${errMsg}`);
console.warn(`[OnChain] ${chain} RPC failed (${rpcUrl}): ${errMsg}`);
}
}
throw new Error(`All ${chain} RPC nodes failed:\n${errors.join("\n")}`);
}
export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStats> {
const db = await getDb();
// Check cache first
if (db) {
try {
const cached = await db
.select()
.from(presaleStatsCache)
.where(eq(presaleStatsCache.chain, chain))
.limit(1);
if (cached.length > 0) {
const row = cached[0];
const age = Date.now() - new Date(row.lastUpdated).getTime();
if (age < CACHE_TTL_MS) {
return {
chain,
usdtRaised: Number(row.usdtRaised || 0),
tokensSold: Number(row.tokensSold || 0),
lastUpdated: new Date(row.lastUpdated),
fromCache: true,
};
}
}
} catch (e) {
console.warn("[OnChain] Cache read error:", e);
}
}
// Fetch fresh from chain with failover
let usdtRaised = 0;
let tokensSold = 0;
let rpcUsed = "";
try {
const data = await fetchChainStatsWithFailover(chain);
usdtRaised = data.usdtRaised;
tokensSold = data.tokensSold;
rpcUsed = data.rpcUsed;
} catch (e) {
console.error(`[OnChain] All ${chain} RPC nodes exhausted:`, e);
}
// Update cache
if (db) {
try {
const existing = await db
.select()
.from(presaleStatsCache)
.where(eq(presaleStatsCache.chain, chain))
.limit(1);
if (existing.length > 0) {
await db
.update(presaleStatsCache)
.set({
usdtRaised: String(usdtRaised),
tokensSold: String(tokensSold),
lastUpdated: new Date(),
})
.where(eq(presaleStatsCache.chain, chain));
} else {
await db.insert(presaleStatsCache).values({
chain,
usdtRaised: String(usdtRaised),
tokensSold: String(tokensSold),
lastUpdated: new Date(),
});
}
} catch (e) {
console.warn("[OnChain] Cache write error:", e);
}
}
return {
chain,
usdtRaised,
tokensSold,
lastUpdated: new Date(),
fromCache: false,
rpcUsed,
};
}
export async function getCombinedStats(): Promise<CombinedStats> {
const [bsc, eth] = await Promise.allSettled([
getPresaleStats("BSC"),
getPresaleStats("ETH"),
]);
const bscStats = bsc.status === "fulfilled" ? bsc.value : null;
const ethStats = eth.status === "fulfilled" ? eth.value : null;
// Get TRC20 stats from DB
let trc20UsdtRaised = 0;
let trc20TokensSold = 0;
try {
const db = await getDb();
if (db) {
const { trc20Purchases } = await import("../drizzle/schema");
const { sql, inArray } = await import("drizzle-orm");
const result = await db
.select({
totalUsdt: sql<string>`SUM(CAST(${trc20Purchases.usdtAmount} AS DECIMAL(30,6)))`,
totalXic: sql<string>`SUM(CAST(${trc20Purchases.xicAmount} AS DECIMAL(30,6)))`,
})
.from(trc20Purchases)
.where(inArray(trc20Purchases.status, ["confirmed", "distributed"]));
if (result[0]) {
trc20UsdtRaised = Number(result[0].totalUsdt || 0);
trc20TokensSold = Number(result[0].totalXic || 0);
}
}
} catch (e) {
console.warn("[OnChain] TRC20 stats error:", e);
}
const totalUsdtRaised =
(bscStats?.usdtRaised || 0) +
(ethStats?.usdtRaised || 0) +
trc20UsdtRaised;
const totalTokensSold =
(bscStats?.tokensSold || 0) +
(ethStats?.tokensSold || 0) +
trc20TokensSold;
return {
totalUsdtRaised,
totalTokensSold,
hardCap: HARD_CAP_USDT,
progressPct: Math.min((totalUsdtRaised / HARD_CAP_USDT) * 100, 100),
bsc: bscStats,
eth: ethStats,
trc20UsdtRaised,
trc20TokensSold,
lastUpdated: new Date(),
};
}