337 lines
10 KiB
TypeScript
337 lines
10 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://eth.llamarpc.com",
|
||
"https://ethereum.publicnode.com",
|
||
"https://rpc.ankr.com/eth",
|
||
"https://1rpc.io/eth",
|
||
"https://eth.drpc.org",
|
||
"https://cloudflare-eth.com",
|
||
"https://rpc.payload.de",
|
||
],
|
||
};
|
||
|
||
// ─── Contract Addresses ────────────────────────────────────────────────────────
|
||
export const CONTRACTS = {
|
||
BSC: {
|
||
presale: "0x5953c025dA734e710886916F2d739A3A78f8bbc4",
|
||
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 (supports both old and new contract)
|
||
const PRESALE_ABI = [
|
||
// New contract (XICPresale v2)
|
||
"function totalRaised() view returns (uint256)",
|
||
"function totalTokensSold() view returns (uint256)",
|
||
"function hardCap() view returns (uint256)",
|
||
"function isPresaleActive() view returns (bool)",
|
||
"function presaleProgress() view returns (uint256 sold, uint256 cap, uint256 progressBps)",
|
||
"function timeRemaining() view returns (uint256)",
|
||
"function availableXIC() view returns (uint256)",
|
||
// Old contract fallbacks
|
||
"function totalUSDTRaised() 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 = 50_000_000; // 25亿 XIC × $0.02 = $5000万 USDT
|
||
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.totalRaised();
|
||
return Number(ethers.formatUnits(raw, 18)); // BSC USDT 18 decimals
|
||
} catch {
|
||
try {
|
||
const raw = await contract.usdtRaised();
|
||
return Number(ethers.formatUnits(raw, 18)); // BSC USDT 18 decimals
|
||
} catch {
|
||
try {
|
||
const raw = await contract.weiRaised();
|
||
return Number(ethers.formatUnits(raw, 18)); // BSC USDT 18 decimals
|
||
} 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(),
|
||
};
|
||
}
|