fix: 将购买逻辑改为 USDT 直接转账到接收钱包 (2026-03-22)
This commit is contained in:
parent
8c52fc72a2
commit
06ca56d5d1
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue