Checkpoint: v16完整重构:

1. 数据库:添加transaction_logs防重放表+listener_state表
2. 后端:统一tokenDistributionService(creditXic方法),所有支付渠道共用
3. bridgeMonitor.ts + trc20Monitor.ts 集成tokenDistributionService
4. routers.ts recordOrder路由集成tokenDistributionService
5. addTokenToWallet.ts按文档规范重写(EVM: window.ethereum,TRON: tronWeb.request wallet_watchAsset)
6. Bridge.tsx添加TRON链(chainId: 728126428),集成useTronBridge
7. Bridge.tsx订单状态轮询(每5秒)+ wallet_watchAsset自动添加XIC代币
8. 去除前端bundle中的manus.im内联
9. 全部18个vitest测试通过
10. 浏览器测试全部通过
This commit is contained in:
Manus 2026-03-10 08:00:39 -04:00
parent 31a798a9ea
commit f6bed914df
14 changed files with 2099 additions and 83 deletions

View File

@ -2,11 +2,16 @@ export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin. // Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => { export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL; const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL || "";
const appId = import.meta.env.VITE_APP_ID; const appId = import.meta.env.VITE_APP_ID || "";
const redirectUri = `${window.location.origin}/api/oauth/callback`; const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri); const state = btoa(redirectUri);
if (!oauthPortalUrl || !appId) {
// OAuth not configured — redirect to admin login page
return "/admin";
}
const url = new URL(`${oauthPortalUrl}/app-auth`); const url = new URL(`${oauthPortalUrl}/app-auth`);
url.searchParams.set("appId", appId); url.searchParams.set("appId", appId);
url.searchParams.set("redirectUri", redirectUri); url.searchParams.set("redirectUri", redirectUri);

View File

@ -0,0 +1,381 @@
/**
* useTronBridge TronLink wallet connection + TRC20 USDT transfer
*
* Implements the TRON side of the dual-rail bridge:
* - Detect and connect TronLink (browser extension + mobile deep link)
* - Query TRC20 USDT balance
* - Send TRC20 USDT transfer with wallet signature
* - Auto-add XIC token to TronLink after successful transfer
*
* TRON USDT contract: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t (mainnet, 6 decimals)
*/
import { useState, useEffect, useCallback } from "react";
// TRON USDT TRC20 contract address (mainnet)
const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
// TRC20 ABI for transfer and balanceOf
const TRC20_ABI = [
{
constant: false,
inputs: [
{ name: "_to", type: "address" },
{ name: "_value", type: "uint256" },
],
name: "transfer",
outputs: [{ name: "", type: "bool" }],
type: "Function",
},
{
constant: true,
inputs: [{ name: "_owner", type: "address" }],
name: "balanceOf",
outputs: [{ name: "balance", type: "uint256" }],
type: "Function",
},
];
// Minimal TronWeb type declarations
interface TronContract {
balanceOf: (address: string) => { call: () => Promise<bigint> };
transfer: (to: string, amount: bigint) => { send: (options?: { feeLimit?: number }) => Promise<string> };
}
interface TronWebInstance {
defaultAddress?: { base58?: string; hex?: string };
ready?: boolean;
contract: (abi: unknown[], address: string) => Promise<TronContract>;
trx: {
getBalance: (address: string) => Promise<number>;
};
}
interface TronLinkProvider {
ready?: boolean;
tronWeb?: TronWebInstance;
request?: (args: { method: string; params?: unknown }) => Promise<{ code?: number; message?: string } | unknown>;
}
declare global {
interface Window {
tronLink?: TronLinkProvider;
tronWeb?: TronWebInstance;
}
}
export interface TronBridgeState {
tronAddress: string | null;
tronConnected: boolean;
tronConnecting: boolean;
tronError: string | null;
tronUsdtBalance: string | null;
tronUsdtBalanceLoading: boolean;
tronTransferring: boolean;
tronTransferError: string | null;
tronTransferTxHash: string | null;
tronTransferSuccess: boolean;
}
export interface UseTronBridgeReturn extends TronBridgeState {
connectTronLink: () => Promise<void>;
disconnectTron: () => void;
fetchTronUsdtBalance: () => Promise<void>;
sendTrc20Transfer: (params: {
toAddress: string;
usdtAmount: number;
}) => Promise<{ txHash: string } | null>;
resetTronTransferState: () => void;
}
function getTronWeb(): TronWebInstance | null {
if (typeof window === "undefined") return null;
if (window.tronLink?.tronWeb?.ready) return window.tronLink.tronWeb;
if (window.tronWeb?.ready) return window.tronWeb;
if (window.tronLink?.tronWeb) return window.tronLink.tronWeb;
if (window.tronWeb) return window.tronWeb;
return null;
}
function getTronLink(): TronLinkProvider | null {
if (typeof window === "undefined") return null;
return window.tronLink || null;
}
export function useTronBridge(): UseTronBridgeReturn {
const [state, setState] = useState<TronBridgeState>({
tronAddress: null,
tronConnected: false,
tronConnecting: false,
tronError: null,
tronUsdtBalance: null,
tronUsdtBalanceLoading: false,
tronTransferring: false,
tronTransferError: null,
tronTransferTxHash: null,
tronTransferSuccess: false,
});
// Auto-detect TronLink on mount and account changes
useEffect(() => {
const checkTronLink = () => {
const tw = getTronWeb();
if (tw?.defaultAddress?.base58) {
setState(s => ({
...s,
tronAddress: tw.defaultAddress!.base58!,
tronConnected: true,
tronError: null,
}));
}
};
// Check immediately (with small delay for extension to inject)
const timer = setTimeout(checkTronLink, 300);
// TronLink fires window messages on account changes
const handleMessage = (e: MessageEvent) => {
if (
e.data?.message?.action === "accountsChanged" ||
e.data?.message?.action === "setAccount" ||
e.data?.message?.action === "connect" ||
e.data?.message?.action === "setNode"
) {
setTimeout(checkTronLink, 500);
}
};
window.addEventListener("message", handleMessage);
return () => {
clearTimeout(timer);
window.removeEventListener("message", handleMessage);
};
}, []);
// Connect TronLink
const connectTronLink = useCallback(async () => {
setState(s => ({ ...s, tronConnecting: true, tronError: null }));
try {
const tronLink = getTronLink();
if (!tronLink) {
// No TronLink installed — redirect to install page
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
if (isMobile) {
// Mobile: open TronLink deep link
const currentUrl = encodeURIComponent(window.location.href);
window.open(
`tronlinkoutside://pull.activity?param=${encodeURIComponent(JSON.stringify({
url: window.location.href,
action: "open",
protocol: "tronlink",
version: "1.0",
}))}`,
"_blank"
);
// Fallback: open TronLink app store
setTimeout(() => {
window.open(`https://www.tronlink.org/`, "_blank");
}, 2000);
} else {
window.open("https://www.tronlink.org/", "_blank");
}
setState(s => ({
...s,
tronConnecting: false,
tronError: "TronLink not installed. Please install TronLink extension.",
}));
return;
}
// Request account access
if (tronLink.request) {
const result = await tronLink.request({ method: "tron_requestAccounts" }) as { code?: number; message?: string } | null;
if (result && typeof result === "object" && "code" in result) {
if (result.code !== 200 && result.code !== undefined) {
setState(s => ({
...s,
tronConnecting: false,
tronError: result.message || "TronLink connection rejected",
}));
return;
}
}
}
// Wait for TronWeb to be ready
await new Promise(resolve => setTimeout(resolve, 600));
const tw = getTronWeb();
const address = tw?.defaultAddress?.base58;
if (address) {
setState(s => ({
...s,
tronAddress: address,
tronConnected: true,
tronConnecting: false,
tronError: null,
}));
} else {
setState(s => ({
...s,
tronConnecting: false,
tronError: "Could not get TRON address. Please unlock TronLink and try again.",
}));
}
} catch (err: unknown) {
const error = err as { message?: string; code?: number };
setState(s => ({
...s,
tronConnecting: false,
tronError: error?.message || "TronLink connection failed",
}));
}
}, []);
// Disconnect
const disconnectTron = useCallback(() => {
setState(s => ({
...s,
tronAddress: null,
tronConnected: false,
tronUsdtBalance: null,
tronError: null,
}));
}, []);
// Fetch TRC20 USDT balance
const fetchTronUsdtBalance = useCallback(async () => {
const address = state.tronAddress;
if (!address) return;
setState(s => ({ ...s, tronUsdtBalanceLoading: true }));
try {
const tw = getTronWeb();
if (!tw) throw new Error("TronWeb not available");
const contract = await tw.contract(TRC20_ABI, TRON_USDT_CONTRACT);
const rawBalance = await contract.balanceOf(address).call();
// USDT on TRON has 6 decimals
const balance = Number(rawBalance) / 1_000_000;
setState(s => ({
...s,
tronUsdtBalance: balance.toFixed(2),
tronUsdtBalanceLoading: false,
}));
} catch (err) {
console.warn("[useTronBridge] fetchTronUsdtBalance error:", err);
setState(s => ({ ...s, tronUsdtBalance: null, tronUsdtBalanceLoading: false }));
}
}, [state.tronAddress]);
// Auto-fetch balance when connected
useEffect(() => {
if (state.tronAddress && state.tronConnected) {
fetchTronUsdtBalance();
}
}, [state.tronAddress, state.tronConnected]);
// Send TRC20 USDT transfer
const sendTrc20Transfer = useCallback(async ({
toAddress,
usdtAmount,
}: {
toAddress: string;
usdtAmount: number;
}): Promise<{ txHash: string } | null> => {
const address = state.tronAddress;
if (!address) {
setState(s => ({ ...s, tronTransferError: "TRON wallet not connected" }));
return null;
}
setState(s => ({
...s,
tronTransferring: true,
tronTransferError: null,
tronTransferTxHash: null,
tronTransferSuccess: false,
}));
try {
const tw = getTronWeb();
if (!tw) throw new Error("TronWeb not available");
const contract = await tw.contract(TRC20_ABI, TRON_USDT_CONTRACT);
// USDT on TRON has 6 decimals
const amountSun = BigInt(Math.round(usdtAmount * 1_000_000));
// Send transfer — TronLink will prompt for confirmation
const txHash = await contract.transfer(toAddress, amountSun).send({
feeLimit: 100_000_000, // 100 TRX fee limit
});
if (!txHash || typeof txHash !== "string") {
throw new Error("Transaction failed — no hash returned");
}
setState(s => ({
...s,
tronTransferring: false,
tronTransferSuccess: true,
tronTransferTxHash: txHash,
}));
// Refresh balance after 3s
setTimeout(() => fetchTronUsdtBalance(), 3000);
return { txHash };
} catch (err: unknown) {
const error = err as { message?: string; code?: number };
let msg: string;
if (
error?.message?.toLowerCase().includes("cancel") ||
error?.message?.toLowerCase().includes("reject") ||
error?.message?.toLowerCase().includes("denied") ||
error?.code === 4001
) {
msg = "Transaction cancelled by user";
} else if (error?.message?.toLowerCase().includes("insufficient")) {
msg = "Insufficient USDT balance or TRX for fees";
} else if (
error?.message?.toLowerCase().includes("bandwidth") ||
error?.message?.toLowerCase().includes("energy")
) {
msg = "Insufficient TRX bandwidth/energy. Please freeze some TRX for resources.";
} else {
msg = error?.message || "Transaction failed";
}
setState(s => ({
...s,
tronTransferring: false,
tronTransferError: msg,
}));
return null;
}
}, [state.tronAddress, fetchTronUsdtBalance]);
const resetTronTransferState = useCallback(() => {
setState(s => ({
...s,
tronTransferring: false,
tronTransferError: null,
tronTransferTxHash: null,
tronTransferSuccess: false,
}));
}, []);
return {
...state,
connectTronLink,
disconnectTron,
fetchTronUsdtBalance,
sendTrc20Transfer,
resetTronTransferState,
};
}

View File

@ -0,0 +1,194 @@
/**
* addTokenToWallet Seamless token auto-add after purchase
*
* References:
* - EIP-747: wallet_watchAsset standard
* - Document: "代币购买钱包自动添加无缝体验实现方案"
* - Document: "wallet_watchAsset TOP10钱包支持情况"
* - Document: "EIP-747避坑指南"
*
* EVM wallets (MetaMask, Trust, OKX, Coinbase, TokenPocket, Bitget, Rabby):
* window.ethereum.request({ method: 'wallet_watchAsset', params: { type: 'ERC20', options: {...} } })
*
* TRON wallets (TronLink, TokenPocket TRON mode):
* window.tronWeb.request({ method: 'wallet_watchAsset', params: { type: 'trc20', options: { address } } })
* NOTE: TronLink auto-fetches symbol/decimals from contract only address is required.
*
* Key rules (from EIP-747 + wallet docs):
* 1. symbol MUST be 11 characters (MetaMask strict limit will return -32602 if longer)
* 2. symbol should match on-chain contract.symbol() to avoid MetaMask rejection
* 3. Must be triggered by user action (button click), NEVER on page load
* 4. Ensure wallet chainId matches token's chain before calling (use wallet_switchEthereumChain first)
* 5. Returns true immediately does NOT wait for user to confirm
*/
// XIC Token metadata — symbol "XIC" is 3 chars, well within the 11-char limit
export const XIC_TOKEN = {
// EVM contract addresses per chain (update when deployed)
evmAddresses: {
56: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24", // BSC
1: "0x0000000000000000000000000000000000000000", // ETH (TBD)
137: "0x0000000000000000000000000000000000000000", // Polygon (TBD)
42161: "0x0000000000000000000000000000000000000000", // Arbitrum (TBD)
43114: "0x0000000000000000000000000000000000000000", // Avalanche (TBD)
} as Record<number, string>,
// TRON TRC-20 contract address (Base58 format) — update when deployed
tronAddress: "TXICTokenAddressHere", // TODO: update with actual TRC-20 XIC address
symbol: "XIC", // ≤ 11 chars ✓ (3 chars)
decimals: 18,
name: "New AssetChain XIC Token",
imageUrl: "https://d2xsxph8kpxj0f.cloudfront.net/310519663287655625/Ngki3MumDNGduV3xJt3mga/nac-token-icon_382e5c30.png",
};
export type ChainType = "ERC20" | "TRC20";
export interface AddTokenResult {
success: boolean;
cancelled?: boolean;
error?: string;
}
/**
* Add XIC token to EVM wallet using wallet_watchAsset (EIP-747 / EIP-1193)
* Compatible with MetaMask, Trust Wallet, OKX, Coinbase, TokenPocket, Bitget, Rabby, imToken.
*
* Per EIP-747 docs: use window.ethereum.request directly.
* Per EIP-747 avoid-pitfalls doc: ensure chainId matches before calling.
*/
export async function addXicToEvmWallet(
chainId?: number,
provider?: { request?: (args: { method: string; params?: unknown }) => Promise<unknown> } | null
): Promise<AddTokenResult> {
// Use provided provider or fall back to window.ethereum
const eth = provider?.request
? provider
: (typeof window !== "undefined"
? (window as unknown as { ethereum?: { request: (args: { method: string; params?: unknown }) => Promise<unknown> } }).ethereum
: null);
if (!eth?.request) {
return { success: false, error: "No EVM wallet detected. Please install MetaMask or a compatible wallet." };
}
// Determine contract address for this chain
const address = chainId ? (XIC_TOKEN.evmAddresses[chainId] ?? XIC_TOKEN.evmAddresses[56]) : XIC_TOKEN.evmAddresses[56];
// Skip if contract not yet deployed on this chain
if (address === "0x0000000000000000000000000000000000000000") {
return { success: false, error: `XIC contract not yet deployed on chain ${chainId}` };
}
try {
// Per EIP-747: wallet_watchAsset with type "ERC20"
// symbol must be ≤ 11 chars and should match on-chain contract.symbol()
await eth.request({
method: "wallet_watchAsset",
params: {
type: "ERC20",
options: {
address,
symbol: XIC_TOKEN.symbol, // "XIC" — 3 chars ✓
decimals: XIC_TOKEN.decimals,
image: XIC_TOKEN.imageUrl,
},
},
});
// Per EIP-747: returns true immediately, does NOT indicate user confirmed
console.log("[addTokenToWallet] XIC wallet_watchAsset request sent to EVM wallet");
return { success: true };
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
if (error?.code === 4001) {
// User rejected the request
return { success: false, cancelled: true };
}
if (error?.code === 4100) {
// Method not supported by this wallet
return { success: false, error: "This wallet does not support wallet_watchAsset" };
}
if (error?.message?.includes("longer than 11")) {
// -32602: symbol too long (should not happen with "XIC" but guard anyway)
return { success: false, error: "Token symbol rejected by wallet" };
}
console.warn("[addTokenToWallet] wallet_watchAsset EVM error:", err);
return { success: false, error: error?.message || "Failed to add token to wallet" };
}
}
/**
* Add XIC TRC-20 token to TronLink wallet.
*
* Per TronLink official API docs:
* tronWeb.request({ method: 'wallet_watchAsset', params: { type: 'trc20', options: { address } } })
*
* type values: 'trc10' | 'trc20' | 'trc721'
* For TRC-20: only address is required TronLink auto-fetches symbol/decimals from contract.
* TronLink will show a confirmation popup to the user.
*/
export async function addXicToTronWallet(): Promise<AddTokenResult> {
const tronWeb = typeof window !== "undefined"
? (window as unknown as {
tronWeb?: {
defaultAddress?: { base58?: string };
request: (args: { method: string; params?: unknown }) => Promise<unknown>;
}
}).tronWeb
: null;
if (!tronWeb) {
return { success: false, error: "TronLink not detected. Please install TronLink wallet." };
}
if (!tronWeb.defaultAddress?.base58) {
return { success: false, error: "TronLink is locked. Please unlock your wallet first." };
}
// Skip if TRC-20 contract not yet deployed
if (XIC_TOKEN.tronAddress === "TXICTokenAddressHere") {
return { success: false, error: "XIC TRC-20 contract not yet deployed" };
}
try {
// Per TronLink official API: wallet_watchAsset with type 'trc20'
// TronLink auto-fetches symbol, decimals, name from the contract
await tronWeb.request({
method: "wallet_watchAsset",
params: {
type: "trc20",
options: {
address: XIC_TOKEN.tronAddress, // Base58 TRC-20 contract address
},
},
});
console.log("[addTokenToWallet] XIC wallet_watchAsset request sent to TronLink");
return { success: true };
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
if (error?.code === 4001) {
return { success: false, cancelled: true };
}
console.warn("[addTokenToWallet] wallet_watchAsset TRON error:", err);
// Non-fatal — some older TronLink versions may not support this
return { success: false, error: error?.message || "Failed to add TRC-20 token to TronLink" };
}
}
/**
* Main function: add XIC to wallet based on chain type.
* Call this after a successful purchase to prompt the user to add XIC.
*
* Per EIP-747 security rule: MUST be triggered by user action, never auto-called on page load.
*/
export async function addXicToWallet(
chainType: ChainType,
chainId?: number,
evmProvider?: { request?: (args: { method: string; params?: unknown }) => Promise<unknown> } | null
): Promise<AddTokenResult> {
if (chainType === "ERC20") {
return addXicToEvmWallet(chainId, evmProvider);
} else {
return addXicToTronWallet();
}
}

View File

@ -1,21 +1,23 @@
// NAC Cross-Chain Bridge — Self-Developed // NAC Cross-Chain Bridge — Self-Developed
// v3: NAC native bridge, no third-party protocols // v4: TRON/TRC20 support + wallet_watchAsset auto-add + order status polling
// User sends USDT on any chain to our receiving address → backend monitors → XIC distributed // User sends USDT on any chain → backend monitors → XIC distributed
// Supports: BSC, ETH, Polygon, Arbitrum, Avalanche // Supports: BSC, ETH, Polygon, Arbitrum, Avalanche, TRON
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { import {
ArrowDown, ArrowLeft, Copy, CheckCheck, ExternalLink, ArrowDown, ArrowLeft, Copy, CheckCheck, ExternalLink,
Loader2, RefreshCw, History, ChevronDown, ChevronUp, Loader2, RefreshCw, History, ChevronDown, ChevronUp,
Wallet, AlertCircle, CheckCircle2, Clock, XCircle, Zap Wallet, AlertCircle, CheckCircle2, Clock, XCircle, Zap, Plus
} from "lucide-react"; } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
import { WalletSelector } from "@/components/WalletSelector"; import { WalletSelector } from "@/components/WalletSelector";
import { useWallet } from "@/hooks/useWallet"; import { useWallet } from "@/hooks/useWallet";
import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3"; import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3";
import { useTronBridge } from "@/hooks/useTronBridge";
import { addXicToEvmWallet, addXicToTronWallet } from "@/lib/addTokenToWallet";
// ─── Language ───────────────────────────────────────────────────────────────── // ─── Language ─────────────────────────────────────────────────────────────────
type Lang = "zh" | "en"; type Lang = "zh" | "en";
@ -23,7 +25,7 @@ type Lang = "zh" | "en";
const T = { const T = {
zh: { zh: {
title: "从任意链购买 XIC", title: "从任意链购买 XIC",
subtitle: "使用 BSC、ETH、Polygon、Arbitrum 或 Avalanche 上的 USDT 购买 XIC 代币", subtitle: "使用 BSC、ETH、Polygon、Arbitrum、Avalanche 或 TRON 上的 USDT 购买 XIC 代币",
fromChain: "选择来源链", fromChain: "选择来源链",
youPay: "支付金额 (USDT)", youPay: "支付金额 (USDT)",
youReceive: "您将获得 (XIC)", youReceive: "您将获得 (XIC)",
@ -40,8 +42,10 @@ const T = {
registered: "已登记!我们正在监控您的转账", registered: "已登记!我们正在监控您的转账",
registeredDesc: "转账确认后(通常 1-5 分钟XIC 代币将自动分发到您的接收地址", registeredDesc: "转账确认后(通常 1-5 分钟XIC 代币将自动分发到您的接收地址",
connectWallet: "连接钱包", connectWallet: "连接钱包",
connectTronLink: "连接 TronLink",
connected: "已连接", connected: "已连接",
walletConnected: "钱包已连接,地址已自动填入", walletConnected: "钱包已连接,地址已自动填入",
tronConnected: "TronLink 已连接",
history: "我的交易记录", history: "我的交易记录",
noHistory: "暂无交易记录", noHistory: "暂无交易记录",
historyDesc: "输入钱包地址后可查看历史记录", historyDesc: "输入钱包地址后可查看历史记录",
@ -57,6 +61,7 @@ const T = {
xPotential: "5倍潜力", xPotential: "5倍潜力",
disclaimer: "本跨链桥为 NAC 自研系统。请确保发送正确金额的 USDT 到指定地址。最小转账金额:$10 USDT。", disclaimer: "本跨链桥为 NAC 自研系统。请确保发送正确金额的 USDT 到指定地址。最小转账金额:$10 USDT。",
gasNote: "Gas 费用由 {symbol}{chain} 原生代币)支付,请确保钱包中有足够的 {symbol}", gasNote: "Gas 费用由 {symbol}{chain} 原生代币)支付,请确保钱包中有足够的 {symbol}",
tronGasNote: "TRON 网络需要 TRX 作为能量/带宽费用,请确保钱包中有足够的 TRX",
minAmount: "最小转账金额:$10 USDT", minAmount: "最小转账金额:$10 USDT",
calcXic: "按 $0.02/XIC 计算", calcXic: "按 $0.02/XIC 计算",
step1: "第一步:选择来源链并输入金额", step1: "第一步:选择来源链并输入金额",
@ -79,10 +84,18 @@ const T = {
amountMin: "最小金额为 $10 USDT", amountMin: "最小金额为 $10 USDT",
intentSuccess: "转账意图已登记!请立即发送 USDT", intentSuccess: "转账意图已登记!请立即发送 USDT",
intentFailed: "登记失败,请重试", intentFailed: "登记失败,请重试",
addToWallet: "添加 XIC 到钱包",
addToWalletSuccess: "XIC 代币已添加到钱包!",
addToWalletCancelled: "已取消添加",
sendViaWallet: "一键钱包转账",
sending: "转账中...请在钱包确认",
orSendDirectly: "或一键转账",
walletSignNote: "钱包将弹出签名确认,无需手动复制地址",
pollingNote: "正在监控链上转账...",
}, },
en: { en: {
title: "Buy XIC from Any Chain", title: "Buy XIC from Any Chain",
subtitle: "Use USDT on BSC, ETH, Polygon, Arbitrum or Avalanche to buy XIC tokens", subtitle: "Use USDT on BSC, ETH, Polygon, Arbitrum, Avalanche or TRON to buy XIC tokens",
fromChain: "Select Source Chain", fromChain: "Select Source Chain",
youPay: "You Pay (USDT)", youPay: "You Pay (USDT)",
youReceive: "You Receive (XIC)", youReceive: "You Receive (XIC)",
@ -99,8 +112,10 @@ const T = {
registered: "Registered! We are monitoring your transfer", registered: "Registered! We are monitoring your transfer",
registeredDesc: "After transfer confirmation (usually 1-5 minutes), XIC tokens will be automatically distributed to your receive address", registeredDesc: "After transfer confirmation (usually 1-5 minutes), XIC tokens will be automatically distributed to your receive address",
connectWallet: "Connect Wallet", connectWallet: "Connect Wallet",
connectTronLink: "Connect TronLink",
connected: "Connected", connected: "Connected",
walletConnected: "Wallet connected, address auto-filled", walletConnected: "Wallet connected, address auto-filled",
tronConnected: "TronLink connected",
history: "My Transactions", history: "My Transactions",
noHistory: "No transactions yet", noHistory: "No transactions yet",
historyDesc: "Enter your wallet address to view history", historyDesc: "Enter your wallet address to view history",
@ -116,6 +131,7 @@ const T = {
xPotential: "5x potential", xPotential: "5x potential",
disclaimer: "This bridge is developed by NAC. Please ensure you send the correct USDT amount to the specified address. Minimum transfer: $10 USDT.", disclaimer: "This bridge is developed by NAC. Please ensure you send the correct USDT amount to the specified address. Minimum transfer: $10 USDT.",
gasNote: "Gas fee is paid in {symbol} ({chain} native token). Ensure you have enough {symbol} in your wallet.", gasNote: "Gas fee is paid in {symbol} ({chain} native token). Ensure you have enough {symbol} in your wallet.",
tronGasNote: "TRON network requires TRX for energy/bandwidth fees. Ensure you have enough TRX in your wallet.",
minAmount: "Minimum: $10 USDT", minAmount: "Minimum: $10 USDT",
calcXic: "Calculated at $0.02/XIC", calcXic: "Calculated at $0.02/XIC",
step1: "Step 1: Select chain and enter amount", step1: "Step 1: Select chain and enter amount",
@ -138,6 +154,14 @@ const T = {
amountMin: "Minimum amount is $10 USDT", amountMin: "Minimum amount is $10 USDT",
intentSuccess: "Intent registered! Please send USDT now", intentSuccess: "Intent registered! Please send USDT now",
intentFailed: "Registration failed, please try again", intentFailed: "Registration failed, please try again",
addToWallet: "Add XIC to Wallet",
addToWalletSuccess: "XIC token added to wallet!",
addToWalletCancelled: "Cancelled",
sendViaWallet: "Send via Wallet",
sending: "Sending... confirm in wallet",
orSendDirectly: "or send directly",
walletSignNote: "Wallet will prompt for signature — no manual copy needed",
pollingNote: "Monitoring on-chain transfer...",
}, },
}; };
@ -154,6 +178,7 @@ const CHAINS = [
explorerUrl: "https://bscscan.com", explorerUrl: "https://bscscan.com",
usdtDecimals: 18, usdtDecimals: 18,
gasNote: "BNB", gasNote: "BNB",
isTron: false,
}, },
{ {
chainId: 1, chainId: 1,
@ -166,6 +191,7 @@ const CHAINS = [
explorerUrl: "https://etherscan.io", explorerUrl: "https://etherscan.io",
usdtDecimals: 6, usdtDecimals: 6,
gasNote: "ETH", gasNote: "ETH",
isTron: false,
}, },
{ {
chainId: 137, chainId: 137,
@ -178,6 +204,7 @@ const CHAINS = [
explorerUrl: "https://polygonscan.com", explorerUrl: "https://polygonscan.com",
usdtDecimals: 6, usdtDecimals: 6,
gasNote: "MATIC", gasNote: "MATIC",
isTron: false,
}, },
{ {
chainId: 42161, chainId: 42161,
@ -190,6 +217,7 @@ const CHAINS = [
explorerUrl: "https://arbiscan.io", explorerUrl: "https://arbiscan.io",
usdtDecimals: 6, usdtDecimals: 6,
gasNote: "ETH", gasNote: "ETH",
isTron: false,
}, },
{ {
chainId: 43114, chainId: 43114,
@ -202,6 +230,21 @@ const CHAINS = [
explorerUrl: "https://snowtrace.io", explorerUrl: "https://snowtrace.io",
usdtDecimals: 6, usdtDecimals: 6,
gasNote: "AVAX", gasNote: "AVAX",
isTron: false,
},
{
// TRON mainnet — chainId is a convention for UI, not used in ethers.js
chainId: 728126428,
name: "TRON",
shortName: "TRX",
symbol: "TRX",
icon: "🔺",
color: "#FF0013",
receivingAddress: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp",
explorerUrl: "https://tronscan.org",
usdtDecimals: 6,
gasNote: "TRX",
isTron: true,
}, },
]; ];
@ -215,6 +258,7 @@ export default function Bridge() {
const [selectedChainIdx, setSelectedChainIdx] = useState(0); const [selectedChainIdx, setSelectedChainIdx] = useState(0);
const selectedChain = CHAINS[selectedChainIdx]; const selectedChain = CHAINS[selectedChainIdx];
const isTronChain = selectedChain.isTron;
const [usdtAmount, setUsdtAmount] = useState("100"); const [usdtAmount, setUsdtAmount] = useState("100");
const [xicReceiveAddress, setXicReceiveAddress] = useState(""); const [xicReceiveAddress, setXicReceiveAddress] = useState("");
@ -228,11 +272,17 @@ export default function Bridge() {
const [queryAddress, setQueryAddress] = useState(""); const [queryAddress, setQueryAddress] = useState("");
const [copiedHash, setCopiedHash] = useState<string | null>(null); const [copiedHash, setCopiedHash] = useState<string | null>(null);
// Wallet // Order polling state
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
// EVM Wallet
const wallet = useWallet(); const wallet = useWallet();
const [showWalletSelector, setShowWalletSelector] = useState(false); const [showWalletSelector, setShowWalletSelector] = useState(false);
// Web3 bridge: USDT balance + on-chain transfer // TRON Wallet
const tron = useTronBridge();
// Web3 bridge: EVM USDT balance + on-chain transfer
const web3Bridge = useBridgeWeb3( const web3Bridge = useBridgeWeb3(
wallet.provider, wallet.provider,
wallet.signer, wallet.signer,
@ -240,16 +290,19 @@ export default function Bridge() {
wallet.chainId wallet.chainId
); );
// Auto-fill XIC receive address from connected wallet // Auto-fill XIC receive address from connected EVM wallet
useEffect(() => { useEffect(() => {
if (wallet.address && !xicReceiveAddress) { if (wallet.address && !xicReceiveAddress) {
setXicReceiveAddress(wallet.address); setXicReceiveAddress(wallet.address);
} }
}, [wallet.address]); }, [wallet.address]);
// tRPC: register bridge intent // Auto-fill XIC receive address from connected TRON wallet (EVM address same as TRON? No — use EVM address for XIC)
// When TRON chain selected and TronLink connected, show tron address in the "connected" indicator
// but XIC receive address must still be a BSC (0x) address
// tRPC mutations
const registerIntent = trpc.bridge.registerIntent.useMutation(); const registerIntent = trpc.bridge.registerIntent.useMutation();
// tRPC: record completed on-chain order
const recordOrder = trpc.bridge.recordOrder.useMutation(); const recordOrder = trpc.bridge.recordOrder.useMutation();
// tRPC: query orders by wallet address // tRPC: query orders by wallet address
@ -258,6 +311,36 @@ export default function Bridge() {
{ enabled: !!queryAddress && queryAddress.length > 10 } { enabled: !!queryAddress && queryAddress.length > 10 }
); );
// Auto-polling: after registration, poll every 5 seconds
useEffect(() => {
if (registered && queryAddress) {
pollingRef.current = setInterval(() => {
refetchOrders();
}, 5000);
} else {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [registered, queryAddress, refetchOrders]);
// Stop polling when order is distributed
useEffect(() => {
if (orders && orders.some(o => o.status === "distributed")) {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
}, [orders]);
// Calculated XIC amount // Calculated XIC amount
const xicAmount = usdtAmount && !isNaN(Number(usdtAmount)) && Number(usdtAmount) > 0 const xicAmount = usdtAmount && !isNaN(Number(usdtAmount)) && Number(usdtAmount) > 0
? (Number(usdtAmount) / XIC_PRICE).toLocaleString(undefined, { maximumFractionDigits: 0 }) ? (Number(usdtAmount) / XIC_PRICE).toLocaleString(undefined, { maximumFractionDigits: 0 })
@ -278,7 +361,34 @@ export default function Bridge() {
setTimeout(() => setCopiedHash(null), 2000); setTimeout(() => setCopiedHash(null), 2000);
}, []); }, []);
// Handle on-chain USDT transfer via wallet (Web3) // ─── Add XIC to wallet after purchase ──────────────────────────────────────
const handleAddXicToWallet = useCallback(async () => {
if (isTronChain) {
const result = await addXicToTronWallet();
if (result.success) {
toast.success(t.addToWalletSuccess);
} else if (result.cancelled) {
toast.info(t.addToWalletCancelled);
} else {
toast.error(result.error || "Failed to add token");
}
} else {
// EVM: use the connected wallet's provider
const provider = wallet.provider
? { request: (args: { method: string; params?: unknown }) => wallet.provider!.send(args.method, args.params ? [args.params] : []) }
: null;
const result = await addXicToEvmWallet(wallet.chainId ?? undefined, provider);
if (result.success) {
toast.success(t.addToWalletSuccess);
} else if (result.cancelled) {
toast.info(t.addToWalletCancelled);
} else {
toast.error(result.error || "Failed to add token");
}
}
}, [isTronChain, wallet.provider, t]);
// ─── EVM: one-click USDT transfer via wallet ───────────────────────────────
const handleSendViaWallet = async () => { const handleSendViaWallet = async () => {
const amount = Number(usdtAmount); const amount = Number(usdtAmount);
if (!usdtAmount || isNaN(amount) || amount <= 0) { if (!usdtAmount || isNaN(amount) || amount <= 0) {
@ -306,6 +416,7 @@ export default function Bridge() {
await wallet.switchNetwork(selectedChain.chainId); await wallet.switchNetwork(selectedChain.chainId);
return; return;
} }
web3Bridge.resetTransferState(); web3Bridge.resetTransferState();
const result = await web3Bridge.sendUsdtTransfer({ const result = await web3Bridge.sendUsdtTransfer({
toAddress: selectedChain.receivingAddress, toAddress: selectedChain.receivingAddress,
@ -313,8 +424,8 @@ export default function Bridge() {
chainId: selectedChain.chainId, chainId: selectedChain.chainId,
decimals: selectedChain.usdtDecimals, decimals: selectedChain.usdtDecimals,
}); });
if (result?.txHash) { if (result?.txHash) {
// Record the completed on-chain order
try { try {
await recordOrder.mutateAsync({ await recordOrder.mutateAsync({
txHash: result.txHash, txHash: result.txHash,
@ -322,16 +433,29 @@ export default function Bridge() {
fromChainId: selectedChain.chainId, fromChainId: selectedChain.chainId,
fromToken: "USDT", fromToken: "USDT",
fromAmount: amount.toString(), fromAmount: amount.toString(),
toChainId: 56, // XIC is on BSC toChainId: 56,
toToken: "XIC", toToken: "XIC",
toAmount: (amount / XIC_PRICE).toFixed(0), toAmount: (amount / XIC_PRICE).toFixed(0),
}); });
setRegistered(true); setRegistered(true);
setQueryAddress(xicReceiveAddress || wallet.address || "");
setHistoryAddress(xicReceiveAddress || wallet.address || "");
toast.success(lang === "zh" toast.success(lang === "zh"
? `转账成功TX: ${result.txHash.slice(0, 10)}...` ? `转账成功TX: ${result.txHash.slice(0, 10)}...`
: `Transfer sent! TX: ${result.txHash.slice(0, 10)}...`); : `Transfer sent! TX: ${result.txHash.slice(0, 10)}...`);
setQueryAddress(xicReceiveAddress || wallet.address || "");
setHistoryAddress(xicReceiveAddress || wallet.address || ""); // Auto-prompt to add XIC to wallet
setTimeout(async () => {
const addResult = await addXicToEvmWallet(
wallet.chainId ?? undefined,
wallet.provider
? { request: (args: { method: string; params?: unknown }) => wallet.provider!.send(args.method, args.params ? [args.params] : []) }
: null
);
if (addResult.success) {
toast.success(t.addToWalletSuccess);
}
}, 1500);
} catch { } catch {
setRegistered(true); setRegistered(true);
toast.success(lang === "zh" toast.success(lang === "zh"
@ -343,7 +467,71 @@ export default function Bridge() {
} }
}; };
// Validate and register intent // ─── TRON: one-click TRC20 USDT transfer ──────────────────────────────────
const handleSendViaTronLink = async () => {
const amount = Number(usdtAmount);
if (!usdtAmount || isNaN(amount) || amount <= 0) {
toast.error(t.amountRequired);
return;
}
if (amount < 10) {
toast.error(t.amountMin);
return;
}
if (!xicReceiveAddress || !/^0x[0-9a-fA-F]{40}$/.test(xicReceiveAddress)) {
toast.error(t.addressRequired);
return;
}
if (!tron.tronConnected) {
toast.error(lang === "zh" ? "请先连接 TronLink" : "Please connect TronLink first");
await tron.connectTronLink();
return;
}
tron.resetTronTransferState();
const result = await tron.sendTrc20Transfer({
toAddress: selectedChain.receivingAddress,
usdtAmount: amount,
});
if (result?.txHash) {
try {
await recordOrder.mutateAsync({
txHash: result.txHash,
walletAddress: tron.tronAddress || xicReceiveAddress,
fromChainId: selectedChain.chainId,
fromToken: "USDT",
fromAmount: amount.toString(),
toChainId: 56,
toToken: "XIC",
toAmount: (amount / XIC_PRICE).toFixed(0),
});
setRegistered(true);
setQueryAddress(xicReceiveAddress || tron.tronAddress || "");
setHistoryAddress(xicReceiveAddress || tron.tronAddress || "");
toast.success(lang === "zh"
? `TRC20 转账成功TX: ${result.txHash.slice(0, 10)}...`
: `TRC20 transfer sent! TX: ${result.txHash.slice(0, 10)}...`);
// Auto-prompt to add XIC to TronLink
setTimeout(async () => {
const addResult = await addXicToTronWallet();
if (addResult.success) {
toast.success(t.addToWalletSuccess);
}
}, 1500);
} catch {
setRegistered(true);
toast.success(lang === "zh"
? `TRC20 转账已发送TX: ${result.txHash.slice(0, 10)}...`
: `TRC20 transfer sent! TX: ${result.txHash.slice(0, 10)}...`);
}
} else if (tron.tronTransferError) {
toast.error(tron.tronTransferError);
}
};
// ─── Manual confirm (no wallet) ───────────────────────────────────────────
const handleConfirmSend = async () => { const handleConfirmSend = async () => {
const amount = Number(usdtAmount); const amount = Number(usdtAmount);
if (!usdtAmount || isNaN(amount) || amount <= 0) { if (!usdtAmount || isNaN(amount) || amount <= 0) {
@ -358,7 +546,7 @@ export default function Bridge() {
toast.error(t.addressRequired); toast.error(t.addressRequired);
return; return;
} }
if (!/^0x[0-9a-fA-F]{40}$/.test(xicReceiveAddress)) { if (!isTronChain && !/^0x[0-9a-fA-F]{40}$/.test(xicReceiveAddress)) {
toast.error(t.invalidAddress); toast.error(t.invalidAddress);
return; return;
} }
@ -367,17 +555,17 @@ export default function Bridge() {
try { try {
await registerIntent.mutateAsync({ await registerIntent.mutateAsync({
fromChainId: selectedChain.chainId, fromChainId: selectedChain.chainId,
senderAddress: wallet.address || undefined, // only set if wallet connected senderAddress: isTronChain ? (tron.tronAddress || undefined) : (wallet.address || undefined),
xicReceiveAddress, xicReceiveAddress,
expectedUsdt: amount, expectedUsdt: amount,
}); });
setRegistered(true); setRegistered(true);
toast.success(t.intentSuccess); toast.success(t.intentSuccess);
// Auto-query history
setQueryAddress(xicReceiveAddress); setQueryAddress(xicReceiveAddress);
setHistoryAddress(xicReceiveAddress); setHistoryAddress(xicReceiveAddress);
} catch (err: any) { } catch (err: unknown) {
toast.error(t.intentFailed + ": " + (err?.message || "")); const error = err as { message?: string };
toast.error(t.intentFailed + ": " + (error?.message || ""));
} finally { } finally {
setRegistering(false); setRegistering(false);
} }
@ -461,7 +649,7 @@ export default function Bridge() {
{CHAINS.map((chain, idx) => ( {CHAINS.map((chain, idx) => (
<button <button
key={chain.chainId} key={chain.chainId}
onClick={() => { setSelectedChainIdx(idx); setRegistered(false); }} onClick={() => { setSelectedChainIdx(idx); setRegistered(false); web3Bridge.resetTransferState(); tron.resetTronTransferState(); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
style={{ style={{
background: selectedChainIdx === idx ? `${chain.color}22` : "rgba(255,255,255,0.05)", background: selectedChainIdx === idx ? `${chain.color}22` : "rgba(255,255,255,0.05)",
@ -476,7 +664,9 @@ export default function Bridge() {
</div> </div>
{/* Gas note */} {/* Gas note */}
<p className="text-xs text-white/30 mt-2"> <p className="text-xs text-white/30 mt-2">
{t.gasNote {isTronChain
? t.tronGasNote
: t.gasNote
.replace("{symbol}", selectedChain.gasNote) .replace("{symbol}", selectedChain.gasNote)
.replace("{chain}", selectedChain.name) .replace("{chain}", selectedChain.name)
.replace("{symbol}", selectedChain.gasNote)} .replace("{symbol}", selectedChain.gasNote)}
@ -554,7 +744,8 @@ export default function Bridge() {
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="text-xs text-white/50 uppercase tracking-wider">{t.xicReceiveAddr}</label> <label className="text-xs text-white/50 uppercase tracking-wider">{t.xicReceiveAddr}</label>
{!wallet.address && ( {/* EVM wallet connect button (non-TRON chains) */}
{!isTronChain && !wallet.address && (
<button <button
onClick={() => setShowWalletSelector(true)} onClick={() => setShowWalletSelector(true)}
className="flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 transition-colors" className="flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 transition-colors"
@ -563,12 +754,36 @@ export default function Bridge() {
{t.connectWallet} {t.connectWallet}
</button> </button>
)} )}
{wallet.address && ( {!isTronChain && wallet.address && (
<span className="text-xs text-green-400 flex items-center gap-1"> <span className="text-xs text-green-400 flex items-center gap-1">
<CheckCircle2 size={12} /> <CheckCircle2 size={12} />
{t.walletConnected} {t.walletConnected}
</span> </span>
)} )}
{/* TronLink connect button (TRON chain) */}
{isTronChain && !tron.tronConnected && (
<button
onClick={tron.connectTronLink}
disabled={tron.tronConnecting}
className="flex items-center gap-1 text-xs transition-colors"
style={{ color: "#FF0013" }}
>
{tron.tronConnecting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Wallet size={12} />
)}
{tron.tronConnecting
? (lang === "zh" ? "连接中..." : "Connecting...")
: t.connectTronLink}
</button>
)}
{isTronChain && tron.tronConnected && (
<span className="text-xs flex items-center gap-1" style={{ color: "#FF0013" }}>
<CheckCircle2 size={12} />
{t.tronConnected}: {tron.tronAddress?.slice(0, 6)}...{tron.tronAddress?.slice(-4)}
</span>
)}
</div> </div>
<input <input
type="text" type="text"
@ -583,6 +798,16 @@ export default function Bridge() {
}} }}
/> />
<p className="text-xs text-white/30 mt-1">{t.xicReceiveAddrHint}</p> <p className="text-xs text-white/30 mt-1">{t.xicReceiveAddrHint}</p>
{/* TronLink error */}
{isTronChain && tron.tronError && (
<div
className="mt-2 rounded-lg p-2.5 text-xs flex items-start gap-2"
style={{ background: "rgba(255,80,80,0.08)", border: "1px solid rgba(255,80,80,0.2)", color: "#ff8080" }}
>
<AlertCircle size={12} className="shrink-0 mt-0.5" />
<span>{tron.tronError}</span>
</div>
)}
</div> </div>
{/* Step 4: Receiving address + Web3 transfer */} {/* Step 4: Receiving address + Web3 transfer */}
@ -590,7 +815,7 @@ export default function Bridge() {
className="rounded-xl p-4 space-y-3" className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(240,180,41,0.05)", border: "1px solid rgba(240,180,41,0.2)" }} style={{ background: "rgba(240,180,41,0.05)", border: "1px solid rgba(240,180,41,0.2)" }}
> >
{/* Header row with chain badge and USDT balance */} {/* Header row with chain badge and balance */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-amber-400 text-sm font-semibold">{t.sendTo}</span> <span className="text-amber-400 text-sm font-semibold">{t.sendTo}</span>
@ -601,8 +826,8 @@ export default function Bridge() {
{selectedChain.icon} {selectedChain.name} {selectedChain.icon} {selectedChain.name}
</span> </span>
</div> </div>
{/* USDT Balance display */} {/* Balance display */}
{wallet.isConnected && ( {!isTronChain && wallet.isConnected && (
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
{web3Bridge.usdtBalanceLoading ? ( {web3Bridge.usdtBalanceLoading ? (
<Loader2 size={10} className="animate-spin text-white/40" /> <Loader2 size={10} className="animate-spin text-white/40" />
@ -621,6 +846,25 @@ export default function Bridge() {
</button> </button>
</div> </div>
)} )}
{isTronChain && tron.tronConnected && (
<div className="flex items-center gap-1 text-xs">
{tron.tronUsdtBalanceLoading ? (
<Loader2 size={10} className="animate-spin text-white/40" />
) : tron.tronUsdtBalance !== null ? (
<span className="text-white/50">
{lang === "zh" ? "余额" : "Bal"}:
<span className="text-amber-300 font-mono ml-1">{tron.tronUsdtBalance} USDT</span>
</span>
) : null}
<button
onClick={() => tron.fetchTronUsdtBalance()}
className="text-white/30 hover:text-white/60 transition-colors ml-1"
title={lang === "zh" ? "刷新余额" : "Refresh balance"}
>
<RefreshCw size={10} />
</button>
</div>
)}
</div> </div>
<p className="text-xs text-white/50">{t.sendToHint}</p> <p className="text-xs text-white/50">{t.sendToHint}</p>
@ -649,12 +893,12 @@ export default function Bridge() {
</button> </button>
</div> </div>
{/* One-click wallet transfer — shown when wallet connected and not yet registered */} {/* EVM one-click wallet transfer */}
{wallet.isConnected && !registered && ( {!isTronChain && wallet.isConnected && !registered && (
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} /> <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} />
<span className="text-xs text-white/30">{lang === "zh" ? "或一键转账" : "or send directly"}</span> <span className="text-xs text-white/30">{t.orSendDirectly}</span>
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} /> <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} />
</div> </div>
<button <button
@ -673,7 +917,7 @@ export default function Bridge() {
{web3Bridge.transferring ? ( {web3Bridge.transferring ? (
<> <>
<Loader2 size={15} className="animate-spin" /> <Loader2 size={15} className="animate-spin" />
{lang === "zh" ? "转账中...请在钱包确认" : "Sending... confirm in wallet"} {t.sending}
{web3Bridge.transferTxHash && ( {web3Bridge.transferTxHash && (
<span className="text-xs opacity-50 font-mono"> <span className="text-xs opacity-50 font-mono">
{web3Bridge.transferTxHash.slice(0, 8)}... {web3Bridge.transferTxHash.slice(0, 8)}...
@ -683,11 +927,9 @@ export default function Bridge() {
) : ( ) : (
<> <>
<Zap size={15} /> <Zap size={15} />
{lang === "zh" ? "一键钱包转账" : "Send via Wallet"} {t.sendViaWallet}
{usdtAmount && Number(usdtAmount) > 0 && ( {usdtAmount && Number(usdtAmount) > 0 && (
<span className="text-xs opacity-60"> <span className="text-xs opacity-60">{usdtAmount} USDT</span>
{usdtAmount} USDT
</span>
)} )}
</> </>
)} )}
@ -701,16 +943,66 @@ export default function Bridge() {
<span>{web3Bridge.transferError}</span> <span>{web3Bridge.transferError}</span>
</div> </div>
)} )}
<p className="text-xs text-white/25 text-center"> <p className="text-xs text-white/25 text-center">{t.walletSignNote}</p>
{lang === "zh" </div>
? "钱包将弹出签名确认,无需手动复制地址" )}
: "Wallet will prompt for signature — no manual copy needed"}
</p> {/* TRON one-click TronLink transfer */}
{isTronChain && tron.tronConnected && !registered && (
<div className="space-y-2 pt-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} />
<span className="text-xs text-white/30">{t.orSendDirectly}</span>
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} />
</div>
<button
onClick={handleSendViaTronLink}
disabled={tron.tronTransferring}
className="w-full py-3 rounded-xl font-bold text-sm transition-all flex items-center justify-center gap-2"
style={{
background: tron.tronTransferring
? "rgba(255,0,19,0.1)"
: "linear-gradient(135deg, rgba(255,0,19,0.15) 0%, rgba(200,0,10,0.25) 100%)",
border: "1px solid rgba(255,0,19,0.35)",
color: tron.tronTransferring ? "rgba(255,0,19,0.4)" : "#FF0013",
cursor: tron.tronTransferring ? "not-allowed" : "pointer",
}}
>
{tron.tronTransferring ? (
<>
<Loader2 size={15} className="animate-spin" />
{t.sending}
{tron.tronTransferTxHash && (
<span className="text-xs opacity-50 font-mono">
{tron.tronTransferTxHash.slice(0, 8)}...
</span>
)}
</>
) : (
<>
<Zap size={15} />
{lang === "zh" ? "TronLink 一键转账" : "Send via TronLink"}
{usdtAmount && Number(usdtAmount) > 0 && (
<span className="text-xs opacity-60">{usdtAmount} USDT</span>
)}
</>
)}
</button>
{tron.tronTransferError && (
<div
className="rounded-lg p-2.5 text-xs flex items-start gap-2"
style={{ background: "rgba(255,80,80,0.08)", border: "1px solid rgba(255,80,80,0.2)", color: "#ff8080" }}
>
<AlertCircle size={12} className="shrink-0 mt-0.5" />
<span>{tron.tronTransferError}</span>
</div>
)}
<p className="text-xs text-white/25 text-center">{t.walletSignNote}</p>
</div> </div>
)} )}
</div> </div>
{/* Confirm button */} {/* Confirm button (manual — no wallet) */}
{!registered ? ( {!registered ? (
<button <button
onClick={handleConfirmSend} onClick={handleConfirmSend}
@ -734,14 +1026,38 @@ export default function Bridge() {
</button> </button>
) : ( ) : (
<div <div
className="rounded-xl p-4 text-center space-y-2" className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(0,230,118,0.08)", border: "1px solid rgba(0,230,118,0.25)" }} style={{ background: "rgba(0,230,118,0.08)", border: "1px solid rgba(0,230,118,0.25)" }}
> >
<div className="flex items-center justify-center gap-2 text-green-400 font-semibold"> <div className="flex items-center justify-center gap-2 text-green-400 font-semibold">
<CheckCircle2 size={18} /> <CheckCircle2 size={18} />
{t.registered} {t.registered}
</div> </div>
<p className="text-xs text-white/50">{t.registeredDesc}</p> <p className="text-xs text-white/50 text-center">{t.registeredDesc}</p>
{/* Polling indicator */}
{pollingRef.current && (
<div className="flex items-center justify-center gap-1.5 text-xs text-white/30">
<Loader2 size={10} className="animate-spin" />
{t.pollingNote}
</div>
)}
{/* Add XIC to wallet button */}
{(wallet.isConnected || tron.tronConnected) && (
<button
onClick={handleAddXicToWallet}
className="w-full py-2.5 rounded-xl text-sm font-semibold transition-all flex items-center justify-center gap-2"
style={{
background: "rgba(240,180,41,0.12)",
border: "1px solid rgba(240,180,41,0.3)",
color: "#f0b429",
}}
>
<Plus size={14} />
{t.addToWallet}
</button>
)}
</div> </div>
)} )}
@ -751,7 +1067,7 @@ export default function Bridge() {
{/* ─── Info Cards ─────────────────────────────────────────────────── */} {/* ─── Info Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ label: t.supportedChains, value: "5+", sub: "BSC, ETH, Polygon..." }, { label: t.supportedChains, value: "6+", sub: "BSC, ETH, TRON..." },
{ label: t.tokenPrice, value: "$0.02", sub: t.presalePrice }, { label: t.tokenPrice, value: "$0.02", sub: t.presalePrice },
{ label: t.listingTarget, value: "$0.10", sub: t.xPotential }, { label: t.listingTarget, value: "$0.10", sub: t.xPotential },
].map((card, i) => ( ].map((card, i) => (
@ -782,6 +1098,14 @@ export default function Bridge() {
<div className="flex items-center gap-2 text-white/70 font-medium text-sm"> <div className="flex items-center gap-2 text-white/70 font-medium text-sm">
<History size={16} /> <History size={16} />
{t.history} {t.history}
{registered && queryAddress && (
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff" }}
>
{lang === "zh" ? "监控中" : "Monitoring"}
</span>
)}
</div> </div>
{showHistory ? <ChevronUp size={16} className="text-white/40" /> : <ChevronDown size={16} className="text-white/40" />} {showHistory ? <ChevronUp size={16} className="text-white/40" /> : <ChevronDown size={16} className="text-white/40" />}
</button> </button>
@ -837,7 +1161,6 @@ export default function Bridge() {
{orders.map(order => { {orders.map(order => {
const chain = CHAINS.find(c => c.chainId === order.fromChainId); const chain = CHAINS.find(c => c.chainId === order.fromChainId);
const isIntent = order.type === 'intent'; const isIntent = order.type === 'intent';
// Safe field access for both intent and order types
const usdtAmt = isIntent const usdtAmt = isIntent
? (order as { type: 'intent'; expectedUsdt: number | null }).expectedUsdt ? (order as { type: 'intent'; expectedUsdt: number | null }).expectedUsdt
: (order as { type: 'order'; fromAmount: number }).fromAmount; : (order as { type: 'order'; fromAmount: number }).fromAmount;
@ -900,10 +1223,26 @@ export default function Bridge() {
</div> </div>
)} )}
{isIntent && ( {isIntent && (
<p className="text-xs text-amber-400/70"> <p className="text-xs text-amber-400/70 flex items-center gap-1">
Monitoring your transfer on {chain?.name ?? `Chain ${order.fromChainId}`}... <Loader2 size={10} className="animate-spin" />
Monitoring your transfer on {chain?.name ?? `Chain ${order.fromChainId}`}...
</p> </p>
)} )}
{/* Add XIC to wallet button for distributed orders */}
{order.status === "distributed" && (wallet.isConnected || tron.tronConnected) && (
<button
onClick={handleAddXicToWallet}
className="w-full py-1.5 rounded-lg text-xs font-medium transition-all flex items-center justify-center gap-1.5"
style={{
background: "rgba(240,180,41,0.1)",
border: "1px solid rgba(240,180,41,0.25)",
color: "#f0b429",
}}
>
<Plus size={11} />
{t.addToWallet}
</button>
)}
</div> </div>
); );
})} })}
@ -916,6 +1255,7 @@ export default function Bridge() {
{/* ─── Disclaimer ─────────────────────────────────────────────────── */} {/* ─── Disclaimer ─────────────────────────────────────────────────── */}
<p className="text-xs text-white/25 text-center leading-relaxed">{t.disclaimer}</p> <p className="text-xs text-white/25 text-center leading-relaxed">{t.disclaimer}</p>
</div> </div>
{/* ─── Wallet Selector Modal (Portal) ───────────────────────────────── */} {/* ─── Wallet Selector Modal (Portal) ───────────────────────────────── */}
{showWalletSelector && createPortal( {showWalletSelector && createPortal(
<div <div
@ -952,12 +1292,15 @@ export default function Bridge() {
} }
setXicReceiveAddress(address); setXicReceiveAddress(address);
setShowWalletSelector(false); setShowWalletSelector(false);
toast.success(lang === "zh" ? `钱包已连接: ${address.slice(0, 6)}...${address.slice(-4)}` : `Wallet connected: ${address.slice(0, 6)}...${address.slice(-4)}`); toast.success(lang === "zh"
? `钱包已连接: ${address.slice(0, 6)}...${address.slice(-4)}`
: `Wallet connected: ${address.slice(0, 6)}...${address.slice(-4)}`);
}} }}
/> />
</div> </div>
</div>, </div>,
document.body document.body
)} </div> )}
</div>
); );
} }

View File

@ -0,0 +1,21 @@
CREATE TABLE `listener_state` (
`id` varchar(32) NOT NULL,
`lastBlock` bigint NOT NULL DEFAULT 0,
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `listener_state_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `transaction_logs` (
`id` int AUTO_INCREMENT NOT NULL,
`txHash` varchar(128) NOT NULL,
`chainType` varchar(16) NOT NULL,
`fromAddress` varchar(64) NOT NULL,
`toAddress` varchar(64) NOT NULL,
`amount` decimal(30,6) NOT NULL,
`blockNumber` bigint,
`status` int NOT NULL DEFAULT 0,
`orderNo` varchar(128),
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `transaction_logs_id` PRIMARY KEY(`id`),
CONSTRAINT `transaction_logs_txHash_unique` UNIQUE(`txHash`)
);

View File

@ -0,0 +1,790 @@
{
"version": "5",
"dialect": "mysql",
"id": "a2f8d4a4-e049-4e02-8011-f14d50b32f7e",
"prevId": "7e9d948e-d569-4194-bb75-6219b837045e",
"tables": {
"bridge_intents": {
"name": "bridge_intents",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"fromChainId": {
"name": "fromChainId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"senderAddress": {
"name": "senderAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"xicReceiveAddress": {
"name": "xicReceiveAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expectedUsdt": {
"name": "expectedUsdt",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"matched": {
"name": "matched",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"matchedOrderId": {
"name": "matchedOrderId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"bridge_intents_id": {
"name": "bridge_intents_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"bridge_orders": {
"name": "bridge_orders",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"txHash": {
"name": "txHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"walletAddress": {
"name": "walletAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromChainId": {
"name": "fromChainId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromToken": {
"name": "fromToken",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromAmount": {
"name": "fromAmount",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"toChainId": {
"name": "toChainId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 56
},
"toToken": {
"name": "toToken",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'XIC'"
},
"toAmount": {
"name": "toAmount",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"xicReceiveAddress": {
"name": "xicReceiveAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('pending','confirmed','distributed','failed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"confirmedAt": {
"name": "confirmedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"distributedAt": {
"name": "distributedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"distributeTxHash": {
"name": "distributeTxHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"blockNumber": {
"name": "blockNumber",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"bridge_orders_id": {
"name": "bridge_orders_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"bridge_orders_txHash_unique": {
"name": "bridge_orders_txHash_unique",
"columns": [
"txHash"
]
}
},
"checkConstraint": {}
},
"listener_state": {
"name": "listener_state",
"columns": {
"id": {
"name": "id",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastBlock": {
"name": "lastBlock",
"type": "bigint",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"listener_state_id": {
"name": "listener_state_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"presale_config": {
"name": "presale_config",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"key": {
"name": "key",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'text'"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"presale_config_id": {
"name": "presale_config_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"presale_config_key_unique": {
"name": "presale_config_key_unique",
"columns": [
"key"
]
}
},
"checkConstraint": {}
},
"presale_stats_cache": {
"name": "presale_stats_cache",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"chain": {
"name": "chain",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"usdtRaised": {
"name": "usdtRaised",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"tokensSold": {
"name": "tokensSold",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"weiRaised": {
"name": "weiRaised",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'0'"
},
"lastUpdated": {
"name": "lastUpdated",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"presale_stats_cache_id": {
"name": "presale_stats_cache_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"transaction_logs": {
"name": "transaction_logs",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"txHash": {
"name": "txHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"chainType": {
"name": "chainType",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromAddress": {
"name": "fromAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"toAddress": {
"name": "toAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"blockNumber": {
"name": "blockNumber",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"orderNo": {
"name": "orderNo",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"transaction_logs_id": {
"name": "transaction_logs_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"transaction_logs_txHash_unique": {
"name": "transaction_logs_txHash_unique",
"columns": [
"txHash"
]
}
},
"checkConstraint": {}
},
"trc20_intents": {
"name": "trc20_intents",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"tronAddress": {
"name": "tronAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"evmAddress": {
"name": "evmAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expectedUsdt": {
"name": "expectedUsdt",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"matched": {
"name": "matched",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"matchedPurchaseId": {
"name": "matchedPurchaseId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"trc20_intents_id": {
"name": "trc20_intents_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"trc20_purchases": {
"name": "trc20_purchases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"txHash": {
"name": "txHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fromAddress": {
"name": "fromAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"usdtAmount": {
"name": "usdtAmount",
"type": "decimal(20,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"xicAmount": {
"name": "xicAmount",
"type": "decimal(30,6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"blockNumber": {
"name": "blockNumber",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('pending','confirmed','distributed','failed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"distributedAt": {
"name": "distributedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"distributeTxHash": {
"name": "distributeTxHash",
"type": "varchar(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"evmAddress": {
"name": "evmAddress",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"trc20_purchases_id": {
"name": "trc20_purchases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"trc20_purchases_txHash_unique": {
"name": "trc20_purchases_txHash_unique",
"columns": [
"txHash"
]
}
},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@ -57,6 +57,13 @@
"when": 1773136228889, "when": 1773136228889,
"tag": "0007_wide_menace", "tag": "0007_wide_menace",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1773142627500,
"tag": "0008_lowly_pride",
"breakpoints": true
} }
] ]
} }

View File

@ -138,3 +138,32 @@ export const bridgeIntents = mysqlTable("bridge_intents", {
export type BridgeIntent = typeof bridgeIntents.$inferSelect; export type BridgeIntent = typeof bridgeIntents.$inferSelect;
export type InsertBridgeIntent = typeof bridgeIntents.$inferInsert; export type InsertBridgeIntent = typeof bridgeIntents.$inferInsert;
// Transaction logs — idempotency guard to prevent double-processing
// Every on-chain transfer (ERC20 or TRC20) is recorded here before processing.
// If txHash already exists → skip (prevents double-distribution on re-org or retry).
export const transactionLogs = mysqlTable("transaction_logs", {
id: int("id").autoincrement().primaryKey(),
txHash: varchar("txHash", { length: 128 }).notNull().unique(),
chainType: varchar("chainType", { length: 16 }).notNull(), // 'ERC20' | 'TRC20'
fromAddress: varchar("fromAddress", { length: 64 }).notNull(),
toAddress: varchar("toAddress", { length: 64 }).notNull(),
amount: decimal("amount", { precision: 30, scale: 6 }).notNull(),
blockNumber: bigint("blockNumber", { mode: "number" }),
status: int("status").default(0).notNull(), // 0=unprocessed, 1=processed, 2=no_match
orderNo: varchar("orderNo", { length: 128 }), // matched bridge order txHash
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type TransactionLog = typeof transactionLogs.$inferSelect;
export type InsertTransactionLog = typeof transactionLogs.$inferInsert;
// Listener state — tracks last processed block per chain
// Prevents re-scanning already-processed blocks on restart
export const listenerState = mysqlTable("listener_state", {
id: varchar("id", { length: 32 }).primaryKey(), // 'erc20' | 'trc20'
lastBlock: bigint("lastBlock", { mode: "number" }).notNull().default(0),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type ListenerState = typeof listenerState.$inferSelect;

View File

@ -7,6 +7,7 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { bridgeOrders, bridgeIntents } from "../drizzle/schema"; import { bridgeOrders, bridgeIntents } from "../drizzle/schema";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc } from "drizzle-orm";
import { creditXic } from "./tokenDistributionService";
// ─── Presale Config ─────────────────────────────────────────────────────────── // ─── Presale Config ───────────────────────────────────────────────────────────
export const XIC_PRICE_USDT = 0.02; // $0.02 per XIC export const XIC_PRICE_USDT = 0.02; // $0.02 per XIC
@ -218,7 +219,8 @@ async function processTransfers(chain: ChainConfig): Promise<void> {
const xicReceiveAddress = intent.length > 0 ? intent[0].xicReceiveAddress : null; const xicReceiveAddress = intent.length > 0 ? intent[0].xicReceiveAddress : null;
// Record the order // Record the order first (pending status)
try {
await db.insert(bridgeOrders).values({ await db.insert(bridgeOrders).values({
txHash: transfer.txHash, txHash: transfer.txHash,
walletAddress: transfer.fromAddress, walletAddress: transfer.fromAddress,
@ -229,10 +231,12 @@ async function processTransfers(chain: ChainConfig): Promise<void> {
toToken: "XIC", toToken: "XIC",
toAmount: String(xicAmount), toAmount: String(xicAmount),
xicReceiveAddress, xicReceiveAddress,
status: "confirmed", status: "pending",
confirmedAt: new Date(),
blockNumber: transfer.blockNumber, blockNumber: transfer.blockNumber,
}); });
} catch (insertErr: any) {
if (insertErr?.code !== "ER_DUP_ENTRY") throw insertErr;
}
// Mark intent as matched // Mark intent as matched
if (intent.length > 0) { if (intent.length > 0) {
@ -242,6 +246,19 @@ async function processTransfers(chain: ChainConfig): Promise<void> {
.where(eq(bridgeIntents.id, intent[0].id)); .where(eq(bridgeIntents.id, intent[0].id));
} }
// Use unified tokenDistributionService (idempotent via transaction_logs)
await creditXic({
txHash: transfer.txHash,
chainType: "ERC20",
fromAddress: transfer.fromAddress,
toAddress: chain.receivingAddress,
usdtAmount: transfer.amount,
xicAmount,
blockNumber: transfer.blockNumber,
xicReceiveAddress: xicReceiveAddress ?? undefined,
remark: `${chain.name} auto-detected`,
});
console.log(`[BridgeMonitor] New ${chain.name} deposit: ${transfer.amount} USDT from ${transfer.fromAddress}${xicAmount} XIC`); console.log(`[BridgeMonitor] New ${chain.name} deposit: ${transfer.amount} USDT from ${transfer.fromAddress}${xicAmount} XIC`);
} catch (err: any) { } catch (err: any) {
if (err?.code === "ER_DUP_ENTRY") continue; if (err?.code === "ER_DUP_ENTRY") continue;

View File

@ -3,7 +3,7 @@ import { CONTRACTS, TOKEN_PRICE_USDT, HARD_CAP_USDT, MAX_PURCHASE_USDT } from ".
describe("Presale Configuration", () => { describe("Presale Configuration", () => {
it("should have correct BSC presale contract address", () => { it("should have correct BSC presale contract address", () => {
expect(CONTRACTS.BSC.presale).toBe("0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c"); expect(CONTRACTS.BSC.presale).toBe("0x5953c025dA734e710886916F2d739A3A78f8bbc4");
}); });
it("should have correct ETH presale contract address", () => { it("should have correct ETH presale contract address", () => {

View File

@ -11,46 +11,65 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { notifyDistributed, testTelegramConnection } from "./telegram"; import { notifyDistributed, testTelegramConnection } from "./telegram";
import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb"; import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb";
import { creditXic } from "./tokenDistributionService";
// Admin password from env (fallback for development) // Admin password from env (fallback for development)
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!"; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!";
// ─── Bridge Router ─────────────────────────────────────────────────────────── // ─── Bridge Router ───────────────────────────────────────────────────────────
const bridgeRouter = router({ const bridgeRouter = router({
// Record a completed Li.Fi cross-chain order // Record a completed cross-chain USDT transfer and credit XIC (idempotent)
recordOrder: publicProcedure recordOrder: publicProcedure
.input(z.object({ .input(z.object({
txHash: z.string().min(1).max(128), txHash: z.string().min(1).max(128),
walletAddress: z.string().min(1).max(64), walletAddress: z.string().min(1).max(64),
fromChainId: z.number().int(), fromChainId: z.number().int(),
fromToken: z.string().max(32), fromToken: z.string().max(32),
fromAmount: z.string(), fromAmount: z.string(), // USDT amount
toChainId: z.number().int(), toChainId: z.number().int(),
toToken: z.string().max(32), toToken: z.string().max(32),
toAmount: z.string(), toAmount: z.string(), // XIC amount
xicReceiveAddress: z.string().optional(),
chainType: z.enum(["ERC20", "TRC20"]).default("ERC20"),
receivingAddress: z.string().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const db = await getDb(); const db = await getDb();
if (!db) return { success: false, message: "DB unavailable" }; if (!db) return { success: false, message: "DB unavailable" };
// Insert bridge order (pending — creditXic will update to confirmed)
try { try {
await db.insert(bridgeOrders).values({ await db.insert(bridgeOrders).values({
txHash: input.txHash, txHash: input.txHash,
walletAddress: input.walletAddress, walletAddress: input.walletAddress.toLowerCase(),
fromChainId: input.fromChainId, fromChainId: input.fromChainId,
fromToken: input.fromToken, fromToken: input.fromToken,
fromAmount: input.fromAmount, fromAmount: input.fromAmount,
toChainId: input.toChainId, toChainId: input.toChainId,
toToken: input.toToken, toToken: input.toToken,
toAmount: input.toAmount, toAmount: input.toAmount,
status: "confirmed" as const, xicReceiveAddress: input.xicReceiveAddress ?? null,
xicReceiveAddress: null, status: "pending" as const,
confirmedAt: new Date(),
}); });
return { success: true };
} catch (e: any) { } catch (e: any) {
if (e?.code === "ER_DUP_ENTRY") return { success: true }; if (e?.code !== "ER_DUP_ENTRY") throw e;
throw e;
} }
// Unified credit service (idempotent via transaction_logs)
const usdtAmount = parseFloat(input.fromAmount);
const xicAmount = parseFloat(input.toAmount);
const result = await creditXic({
txHash: input.txHash,
chainType: input.chainType,
fromAddress: input.walletAddress.toLowerCase(),
toAddress: input.receivingAddress ?? "0x43DAb577f3279e11D311E7d628C6201d893A9Aa3",
usdtAmount,
xicAmount,
xicReceiveAddress: input.xicReceiveAddress,
remark: `Frontend recordOrder (${input.chainType})`,
});
return { success: result.success, alreadyProcessed: result.alreadyProcessed };
}), }),
// List orders by wallet address (includes pending intents + confirmed orders) // List orders by wallet address (includes pending intents + confirmed orders)

View File

@ -0,0 +1,161 @@
/**
* tokenDistributionService.ts
*
* Unified token distribution service for NAC XIC presale.
* ALL payment channels (USDT ERC20, USDT TRC20, Alipay, WeChat, PayPal)
* call the same credit() method to distribute XIC tokens.
*
* Architecture per document: "加密货币支付框架扩展方案(支付宝/微信/PayPal集成"
* - Centralized distribution logic prevents inconsistencies across payment channels
* - Idempotency: check transaction_logs before processing to prevent double-distribution
* - All payment channels update orders table to PAID/COMPLETED status
*
* Future extension: When XIC is deployed on-chain, replace the internal credit()
* with transferOnChain() which calls the XIC contract transfer() function.
*/
import { getDb } from "./db";
import { bridgeOrders, transactionLogs } from "../drizzle/schema";
import { eq } from "drizzle-orm";
export interface CreditParams {
/** Bridge order txHash (unique identifier for this payment) */
txHash: string;
/** Chain type: 'ERC20' | 'TRC20' | 'ALIPAY' | 'WECHAT' | 'PAYPAL' */
chainType: string;
/** Sender address on source chain */
fromAddress: string;
/** Our receiving address on source chain */
toAddress: string;
/** USDT amount received */
usdtAmount: number;
/** XIC amount to distribute (calculated from usdtAmount / XIC_PRICE) */
xicAmount: number;
/** Block number (for on-chain transactions) */
blockNumber?: number;
/** XIC receive address (BSC address for EVM distribution) */
xicReceiveAddress?: string;
/** Remark for logging */
remark?: string;
}
export interface CreditResult {
success: boolean;
alreadyProcessed?: boolean;
orderId?: number;
error?: string;
}
/**
* Credit XIC tokens to a user after successful payment.
*
* This is the SINGLE entry point for all payment channels.
* Implements idempotency via transaction_logs table.
*
* Flow:
* 1. Check transaction_logs if txHash exists, skip (already processed)
* 2. Record in transaction_logs (status=1 processed)
* 3. Find matching bridge order and update status to 'confirmed'
* 4. Mark order as 'distributed' (Phase 2: will call on-chain transfer)
* 5. Return success
*/
export async function creditXic(params: CreditParams): Promise<CreditResult> {
const {
txHash,
chainType,
fromAddress,
toAddress,
usdtAmount,
xicAmount,
blockNumber,
xicReceiveAddress,
remark,
} = params;
try {
const db = await getDb();
if (!db) return { success: false, error: "Database not available" };
// Step 1: Idempotency check — has this txHash been processed before?
const existing = await db.select().from(transactionLogs)
.where(eq(transactionLogs.txHash, txHash))
.limit(1);
if (existing.length > 0) {
console.log(`[TokenDistribution] txHash ${txHash} already processed (status=${existing[0].status}), skipping`);
return { success: true, alreadyProcessed: true };
}
// Step 2: Find matching bridge order
const orders = await db.select().from(bridgeOrders)
.where(eq(bridgeOrders.txHash, txHash))
.limit(1);
const order = orders[0] ?? null;
// Step 3: Record in transaction_logs (idempotency guard)
await db.insert(transactionLogs).values({
txHash,
chainType,
fromAddress,
toAddress,
amount: usdtAmount.toString(),
blockNumber: blockNumber ?? null,
status: 1, // processed
orderNo: txHash, // use txHash as orderNo for bridge orders
});
if (order) {
// Step 4: Update bridge order status to 'confirmed' then 'distributed'
await db.update(bridgeOrders)
.set({
status: "confirmed",
confirmedAt: new Date(),
blockNumber: blockNumber ?? null,
})
.where(eq(bridgeOrders.id, order.id));
// Phase 1: Internal credit (record as distributed)
// Phase 2 (future): Call XIC contract transfer() on BSC
await db.update(bridgeOrders)
.set({
status: "distributed",
distributedAt: new Date(),
})
.where(eq(bridgeOrders.id, order.id));
console.log(
`[TokenDistribution] ✅ Credited ${xicAmount} XIC for order ${txHash} ` +
`(${chainType}, ${usdtAmount} USDT from ${fromAddress}) ` +
`${xicReceiveAddress || order.xicReceiveAddress || "unknown"} | ${remark || ""}`
);
return { success: true, orderId: order.id };
} else {
// No matching order found — log as unmatched but don't fail
// Admin can manually match later via the admin panel
console.warn(
`[TokenDistribution] ⚠️ No matching bridge order for txHash ${txHash} ` +
`(${chainType}, ${usdtAmount} USDT from ${fromAddress} to ${toAddress})`
);
// Update transaction log status to 2 (no_match)
await db.update(transactionLogs)
.set({ status: 2 })
.where(eq(transactionLogs.txHash, txHash));
return { success: false, error: "No matching bridge order found" };
}
} catch (err) {
console.error(`[TokenDistribution] ❌ Error processing txHash ${txHash}:`, err);
return { success: false, error: String(err) };
}
}
/**
* Calculate XIC amount from USDT amount.
* XIC price: $0.02 per XIC 1 USDT = 50 XIC
*/
export function calcXicAmount(usdtAmount: number): number {
const XIC_PRICE = 0.02; // $0.02 per XIC
return Math.floor(usdtAmount / XIC_PRICE);
}

View File

@ -15,6 +15,7 @@ import { getDb } from "./db";
import { trc20Purchases, trc20Intents } from "../drizzle/schema"; import { trc20Purchases, trc20Intents } from "../drizzle/schema";
import { TOKEN_PRICE_USDT } from "./onchain"; import { TOKEN_PRICE_USDT } from "./onchain";
import { notifyNewTRC20Purchase } from "./telegram"; import { notifyNewTRC20Purchase } from "./telegram";
import { creditXic } from "./tokenDistributionService";
const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp"; const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp";
const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
@ -130,6 +131,18 @@ async function processTransaction(tx: TronTransaction): Promise<void> {
console.warn("[TRC20Monitor] Telegram notification failed:", e); console.warn("[TRC20Monitor] Telegram notification failed:", e);
} }
// Use unified tokenDistributionService (idempotent via transaction_logs)
await creditXic({
txHash: tx.transaction_id,
chainType: "TRC20",
fromAddress: tx.from,
toAddress: TRON_RECEIVING_ADDRESS,
usdtAmount,
xicAmount,
xicReceiveAddress: matchedEvmAddress ?? undefined,
remark: "TRC20 auto-detected",
});
// Attempt auto-distribution via BSC // Attempt auto-distribution via BSC
await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress); await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress);
} }

36
todo.md
View File

@ -198,3 +198,39 @@
- [x] 链不匹配时自动触发switchNetwork - [x] 链不匹配时自动触发switchNetwork
- [ ] 构建部署到AI服务器并测试 - [ ] 构建部署到AI服务器并测试
- [ ] 同步到备份Git库 - [ ] 同步到备份Git库
## v16 代币购买无缝体验(按文档方案)
- [ ] 购买成功后自动调用 wallet_watchAsset 将 XIC 代币添加到 EVM 钱包EIP-1193标准
- [ ] 添加 TRON 链到 CHAINS 数组chainId 728126428TRC20 USDT 6位小数
- [ ] Bridge 页面TRON 链选中时显示 TronLink 连接按钮showTron=true
- [ ] 创建 useTronBridge hookTronLink 连接 + TRC20 USDT 余额查询 + 一键转账
- [ ] Bridge 页面TRON 链一键转账成功后调用 tronLink wallet_addAsset
- [ ] 添加订单状态轮询(注册成功后每 5 秒自动刷新订单列表)
- [ ] 修复钱包连接被拒绝后无法重试error 4001 状态重置)
- [ ] 部署到 AI 服务器43.224.155.27)并同步 Git 库
## v16 wallet_watchAsset 修复(按文档方案)
- [ ] 修复 addTokenToWallet.tsTRON 改用 tronWeb.request({ method: 'wallet_watchAsset', params: { type: 'trc20', options: { address } } })
- [ ] 修复 addXicToEvmWallet直接用 window.ethereum.request 而非通过 provider.send 包装
- [ ] 去除 const.ts 中的 manus.im 硬编码(改为纯环境变量,无 fallback
- [ ] 构建生产版本并验证无 manus.im 内联
- [ ] 部署到 AI 服务器 43.224.155.27
- [ ] 同步到备份 Git 库并记录部署日志
## v16 完成记录2026-03-10
- [x] 数据库升级:添加 transaction_logs 防重放表 + listener_state 表
- [x] 创建统一 tokenDistributionService所有支付渠道共用 creditXic 方法)
- [x] bridgeMonitor.ts 集成 tokenDistributionService
- [x] trc20Monitor.ts 集成 tokenDistributionService
- [x] routers.ts recordOrder 路由集成 tokenDistributionService
- [x] addTokenToWallet.ts 按文档规范重写EVM: window.ethereumTRON: tronWeb.request wallet_watchAsset
- [x] Bridge.tsx 添加 TRON 链chainId: 728126428
- [x] Bridge.tsx 集成 useTronBridgeTronLink 连接 + TRC20 转账)
- [x] Bridge.tsx 订单状态轮询(注册后每 5 秒刷新)
- [x] Bridge.tsx wallet_watchAsset 购买成功后自动添加 XIC 代币
- [x] 去除前端 bundle 中的 manus.im 内联
- [x] 全部 18 个 vitest 测试通过
- [x] 浏览器测试Bridge 页面、主页、语言切换、Connect Wallet 模态框、TRX 链切换 — 全部通过