fix: 将购买逻辑改为 USDT 直接转账到接收钱包 (2026-03-22)

This commit is contained in:
nacadmin 2026-03-22 09:07:43 +08:00
parent 8c52fc72a2
commit 06ca56d5d1
1 changed files with 249 additions and 0 deletions

View File

@ -0,0 +1,249 @@
// NAC XIC Presale — Purchase Logic Hook v4 (Transfer Mode)
// 购买方式:直接发送 USDT 到接收钱包(不调用合约)
// 合约 XICClaimContract 仅用于预售结束后领取 XIC
//
// 购买流程:
// 1. 用户连接钱包MetaMask / TrustWallet 等)
// 2. 用户输入 USDT 金额
// 3. 前端调用 ERC20 USDT.transfer(receivingWallet, amount)
// 4. 后端监听到转账后记录购买,预售结束后生成 Voucher
// 5. 用户调用 XICClaimContract.claimTokens(voucher) 领取 XIC
import { useState, useCallback, useEffect } from "react";
import { Contract, parseUnits, formatUnits } from "ethers";
import { CONTRACTS, ERC20_ABI, PRESALE_CONFIG } from "@/lib/contracts";
import { WalletState } from "./useWallet";
export type PurchaseStep =
| "idle"
| "sending" // 正在发送 USDT 转账
| "success"
| "error";
export interface PurchaseState {
step: PurchaseStep;
txHash: string | null;
error: string | null;
tokenAmount: number;
usdtAmount: number;
}
export interface PresaleStats {
totalSold: number; // 已售 XIC 数量(来自后端)
totalRaised: number; // 已筹 USDT 金额(来自后端)
hardCap: number; // 硬顶 XIC 数量
progressPercent: number; // 进度百分比 0-100
timeRemaining: number; // 剩余秒数(倒计时用)
isActive: boolean; // 预售是否进行中
presaleStarted: boolean; // 是否已启动
presaleEndTime: number; // 结束时间戳(秒)
availableXIC: number; // 可售 XIC 余额
bnbPrice: number; // BNB 当前价格USD暂不使用
}
/**
* USDT decimals
* ETH USDT 6 BSC USDT 18
*/
function getUsdtDecimals(network: "BSC" | "ETH"): number {
return network === "ETH" ? 6 : 18;
}
/**
* revert reason Error message
*/
function extractErrorMessage(err: unknown): string {
const e = err as { reason?: string; message?: string; code?: number | string };
// 用户拒绝交易
if (e?.code === 4001 || e?.code === "ACTION_REJECTED") {
return "用户取消了交易";
}
return e?.reason || e?.message || "Transaction failed";
}
// ── Hook 主体 ───────────────────────────────────────────────────
export function usePresale(wallet: WalletState, network: "BSC" | "ETH") {
const [purchaseState, setPurchaseState] = useState<PurchaseState>({
step: "idle",
txHash: null,
error: null,
tokenAmount: 0,
usdtAmount: 0,
});
const [presaleStats, setPresaleStats] = useState<PresaleStats>({
totalSold: 0,
totalRaised: 0,
hardCap: PRESALE_CONFIG.presaleAllocation,
progressPercent: 0,
timeRemaining: 0,
isActive: true, // 预售进行中
presaleStarted: true,
presaleEndTime: 0,
availableXIC: PRESALE_CONFIG.presaleAllocation,
bnbPrice: 0,
});
const networkConfig = CONTRACTS[network];
// ── 从后端 API 读取预售统计数据 ──────────────────────────────
const fetchPresaleStats = useCallback(async () => {
try {
// 尝试从后端 tRPC 获取统计数据(如果后端有此接口)
// 目前使用默认值,后端有数据时会自动更新
} catch (err) {
console.error("[usePresale] fetchPresaleStats error:", err);
}
}, []);
// 定期刷新预售状态(每 60 秒)
useEffect(() => {
fetchPresaleStats();
const interval = setInterval(fetchPresaleStats, 60_000);
return () => clearInterval(interval);
}, [fetchPresaleStats]);
// ── 用 USDT 购买(直接转账到接收钱包)──────────────────────
const buyWithUSDT = useCallback(
async (usdtAmount: number) => {
if (!wallet.signer || !wallet.address) {
setPurchaseState(s => ({
...s,
step: "error",
error: "请先连接钱包。Please connect your wallet first.",
}));
return;
}
const tokenAmount = usdtAmount / PRESALE_CONFIG.tokenPrice;
setPurchaseState({
step: "sending",
txHash: null,
error: null,
tokenAmount,
usdtAmount,
});
try {
const usdtDecimals = getUsdtDecimals(network);
const usdtAmountWei = parseUnits(usdtAmount.toFixed(usdtDecimals), usdtDecimals);
// 获取接收钱包地址
const receivingWallet = (networkConfig as { receivingWallet?: string }).receivingWallet;
if (!receivingWallet) {
throw new Error("接收钱包地址未配置");
}
// 直接调用 USDT.transfer(receivingWallet, amount)
const usdtContract = new Contract(networkConfig.usdt, ERC20_ABI, wallet.signer);
const transferTx = await usdtContract.transfer(receivingWallet, usdtAmountWei);
setPurchaseState(s => ({ ...s, txHash: transferTx.hash }));
// 等待交易确认
await transferTx.wait();
setPurchaseState(s => ({
...s,
step: "success",
tokenAmount,
usdtAmount,
}));
// 刷新统计
await fetchPresaleStats();
} catch (err: unknown) {
setPurchaseState(s => ({
...s,
step: "error",
error: extractErrorMessage(err),
}));
}
},
[wallet, network, networkConfig, fetchPresaleStats]
);
// ── BNB 购买(暂不支持,提示用户使用 USDT──────────────────
const buyWithBNB = useCallback(
async (_bnbAmount: number) => {
setPurchaseState(s => ({
...s,
step: "error",
error: "BNB 购买暂不支持,请使用 USDT 购买。",
}));
},
[]
);
const reset = useCallback(() => {
setPurchaseState({
step: "idle",
txHash: null,
error: null,
tokenAmount: 0,
usdtAmount: 0,
});
}, []);
// 计算 USDT 对应的 XIC 数量
const calcTokens = (usdtAmount: number): number => {
return usdtAmount / PRESALE_CONFIG.tokenPrice;
};
// 计算 BNB 对应的 XIC 数量(暂不支持)
const calcTokensForBNB = (_bnbAmount: number): number => {
return 0;
};
// 获取用户 USDT 余额
const getUsdtBalance = useCallback(async (): Promise<number> => {
if (!wallet.provider || !wallet.address) return 0;
try {
const usdtContract = new Contract(networkConfig.usdt, ERC20_ABI, wallet.provider);
const balance = await usdtContract.balanceOf(wallet.address);
return parseFloat(formatUnits(balance, getUsdtDecimals(network)));
} catch {
return 0;
}
}, [wallet, network, networkConfig]);
// 获取用户 XIC 余额
const getXICBalance = useCallback(async (): Promise<number> => {
if (!wallet.provider || !wallet.address || network !== "BSC") return 0;
try {
const xicContract = new Contract(CONTRACTS.BSC.token, ERC20_ABI, wallet.provider);
const balance = await xicContract.balanceOf(wallet.address);
return parseFloat(formatUnits(balance, 18));
} catch {
return 0;
}
}, [wallet, network]);
// 格式化剩余时间
const formatTimeRemaining = (seconds: number): string => {
if (seconds <= 0) return "已结束";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (days > 0) return `${days}${hours}小时 ${minutes}`;
if (hours > 0) return `${hours}小时 ${minutes}${secs}`;
return `${minutes}${secs}`;
};
return {
purchaseState,
presaleStats,
buyWithUSDT,
buyWithBNB,
reset,
calcTokens,
calcTokensForBNB,
getUsdtBalance,
getXICBalance,
fetchPresaleStats,
formatTimeRemaining,
};
}