From f6bed914dffafd2209375fdf7270dd7d9cded119 Mon Sep 17 00:00:00 2001 From: Manus Date: Tue, 10 Mar 2026 08:00:39 -0400 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20v16=E5=AE=8C=E6=95=B4=E9=87=8D?= =?UTF-8?q?=E6=9E=84=EF=BC=9A=201.=20=E6=95=B0=E6=8D=AE=E5=BA=93=EF=BC=9A?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0transaction=5Flogs=E9=98=B2=E9=87=8D=E6=94=BE?= =?UTF-8?q?=E8=A1=A8+listener=5Fstate=E8=A1=A8=202.=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=9A=E7=BB=9F=E4=B8=80tokenDistributionService=EF=BC=88cre?= =?UTF-8?q?ditXic=E6=96=B9=E6=B3=95=EF=BC=89=EF=BC=8C=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E6=B8=A0=E9=81=93=E5=85=B1=E7=94=A8=203.=20b?= =?UTF-8?q?ridgeMonitor.ts=20+=20trc20Monitor.ts=20=E9=9B=86=E6=88=90token?= =?UTF-8?q?DistributionService=204.=20routers.ts=20recordOrder=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E9=9B=86=E6=88=90tokenDistributionService=205.=20addT?= =?UTF-8?q?okenToWallet.ts=E6=8C=89=E6=96=87=E6=A1=A3=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E9=87=8D=E5=86=99=EF=BC=88EVM:=20window.ethereum=EF=BC=8CTRON:?= =?UTF-8?q?=20tronWeb.request=20wallet=5FwatchAsset=EF=BC=89=206.=20Bridge?= =?UTF-8?q?.tsx=E6=B7=BB=E5=8A=A0TRON=E9=93=BE=EF=BC=88chainId:=2072812642?= =?UTF-8?q?8=EF=BC=89=EF=BC=8C=E9=9B=86=E6=88=90useTronBridge=207.=20Bridg?= =?UTF-8?q?e.tsx=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=81=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=EF=BC=88=E6=AF=8F5=E7=A7=92=EF=BC=89+=20wallet=5FwatchAsset?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B7=BB=E5=8A=A0XIC=E4=BB=A3=E5=B8=81=208.?= =?UTF-8?q?=20=E5=8E=BB=E9=99=A4=E5=89=8D=E7=AB=AFbundle=E4=B8=AD=E7=9A=84?= =?UTF-8?q?manus.im=E5=86=85=E8=81=94=209.=20=E5=85=A8=E9=83=A818=E4=B8=AA?= =?UTF-8?q?vitest=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87=2010.=20=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E6=B5=8B=E8=AF=95=E5=85=A8=E9=83=A8=E9=80=9A?= =?UTF-8?q?=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/const.ts | 9 +- client/src/hooks/useTronBridge.ts | 381 ++++++++++++++ client/src/lib/addTokenToWallet.ts | 194 +++++++ client/src/pages/Bridge.tsx | 453 +++++++++++++++-- drizzle/0008_lowly_pride.sql | 21 + drizzle/meta/0008_snapshot.json | 790 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 29 ++ server/bridgeMonitor.ts | 47 +- server/presale.test.ts | 2 +- server/routers.ts | 39 +- server/tokenDistributionService.ts | 161 ++++++ server/trc20Monitor.ts | 13 + todo.md | 36 ++ 14 files changed, 2099 insertions(+), 83 deletions(-) create mode 100644 client/src/hooks/useTronBridge.ts create mode 100644 client/src/lib/addTokenToWallet.ts create mode 100644 drizzle/0008_lowly_pride.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 server/tokenDistributionService.ts diff --git a/client/src/const.ts b/client/src/const.ts index 9999063..bee8daa 100644 --- a/client/src/const.ts +++ b/client/src/const.ts @@ -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. export const getLoginUrl = () => { - const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL; - const appId = import.meta.env.VITE_APP_ID; + const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL || ""; + const appId = import.meta.env.VITE_APP_ID || ""; const redirectUri = `${window.location.origin}/api/oauth/callback`; const state = btoa(redirectUri); + if (!oauthPortalUrl || !appId) { + // OAuth not configured — redirect to admin login page + return "/admin"; + } + const url = new URL(`${oauthPortalUrl}/app-auth`); url.searchParams.set("appId", appId); url.searchParams.set("redirectUri", redirectUri); diff --git a/client/src/hooks/useTronBridge.ts b/client/src/hooks/useTronBridge.ts new file mode 100644 index 0000000..27c4601 --- /dev/null +++ b/client/src/hooks/useTronBridge.ts @@ -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 }; + transfer: (to: string, amount: bigint) => { send: (options?: { feeLimit?: number }) => Promise }; +} + +interface TronWebInstance { + defaultAddress?: { base58?: string; hex?: string }; + ready?: boolean; + contract: (abi: unknown[], address: string) => Promise; + trx: { + getBalance: (address: string) => Promise; + }; +} + +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; + disconnectTron: () => void; + fetchTronUsdtBalance: () => Promise; + 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({ + 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, + }; +} diff --git a/client/src/lib/addTokenToWallet.ts b/client/src/lib/addTokenToWallet.ts new file mode 100644 index 0000000..f9bd4a0 --- /dev/null +++ b/client/src/lib/addTokenToWallet.ts @@ -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, + // 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 } | null +): Promise { + // 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 } }).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 { + const tronWeb = typeof window !== "undefined" + ? (window as unknown as { + tronWeb?: { + defaultAddress?: { base58?: string }; + request: (args: { method: string; params?: unknown }) => Promise; + } + }).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 } | null +): Promise { + if (chainType === "ERC20") { + return addXicToEvmWallet(chainId, evmProvider); + } else { + return addXicToTronWallet(); + } +} diff --git a/client/src/pages/Bridge.tsx b/client/src/pages/Bridge.tsx index 1526bac..138d752 100644 --- a/client/src/pages/Bridge.tsx +++ b/client/src/pages/Bridge.tsx @@ -1,21 +1,23 @@ // NAC Cross-Chain Bridge — Self-Developed -// v3: NAC native bridge, no third-party protocols -// User sends USDT on any chain to our receiving address → backend monitors → XIC distributed -// Supports: BSC, ETH, Polygon, Arbitrum, Avalanche +// v4: TRON/TRC20 support + wallet_watchAsset auto-add + order status polling +// User sends USDT on any chain → backend monitors → XIC distributed +// 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 { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { ArrowDown, ArrowLeft, Copy, CheckCheck, ExternalLink, Loader2, RefreshCw, History, ChevronDown, ChevronUp, - Wallet, AlertCircle, CheckCircle2, Clock, XCircle, Zap + Wallet, AlertCircle, CheckCircle2, Clock, XCircle, Zap, Plus } from "lucide-react"; import { Link } from "wouter"; import { WalletSelector } from "@/components/WalletSelector"; import { useWallet } from "@/hooks/useWallet"; import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3"; +import { useTronBridge } from "@/hooks/useTronBridge"; +import { addXicToEvmWallet, addXicToTronWallet } from "@/lib/addTokenToWallet"; // ─── Language ───────────────────────────────────────────────────────────────── type Lang = "zh" | "en"; @@ -23,7 +25,7 @@ type Lang = "zh" | "en"; const T = { zh: { title: "从任意链购买 XIC", - subtitle: "使用 BSC、ETH、Polygon、Arbitrum 或 Avalanche 上的 USDT 购买 XIC 代币", + subtitle: "使用 BSC、ETH、Polygon、Arbitrum、Avalanche 或 TRON 上的 USDT 购买 XIC 代币", fromChain: "选择来源链", youPay: "支付金额 (USDT)", youReceive: "您将获得 (XIC)", @@ -40,8 +42,10 @@ const T = { registered: "已登记!我们正在监控您的转账", registeredDesc: "转账确认后(通常 1-5 分钟),XIC 代币将自动分发到您的接收地址", connectWallet: "连接钱包", + connectTronLink: "连接 TronLink", connected: "已连接", walletConnected: "钱包已连接,地址已自动填入", + tronConnected: "TronLink 已连接", history: "我的交易记录", noHistory: "暂无交易记录", historyDesc: "输入钱包地址后可查看历史记录", @@ -57,6 +61,7 @@ const T = { xPotential: "5倍潜力", disclaimer: "本跨链桥为 NAC 自研系统。请确保发送正确金额的 USDT 到指定地址。最小转账金额:$10 USDT。", gasNote: "Gas 费用由 {symbol}({chain} 原生代币)支付,请确保钱包中有足够的 {symbol}", + tronGasNote: "TRON 网络需要 TRX 作为能量/带宽费用,请确保钱包中有足够的 TRX", minAmount: "最小转账金额:$10 USDT", calcXic: "按 $0.02/XIC 计算", step1: "第一步:选择来源链并输入金额", @@ -79,10 +84,18 @@ const T = { amountMin: "最小金额为 $10 USDT", intentSuccess: "转账意图已登记!请立即发送 USDT", intentFailed: "登记失败,请重试", + addToWallet: "添加 XIC 到钱包", + addToWalletSuccess: "XIC 代币已添加到钱包!", + addToWalletCancelled: "已取消添加", + sendViaWallet: "一键钱包转账", + sending: "转账中...请在钱包确认", + orSendDirectly: "或一键转账", + walletSignNote: "钱包将弹出签名确认,无需手动复制地址", + pollingNote: "正在监控链上转账...", }, en: { 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", youPay: "You Pay (USDT)", youReceive: "You Receive (XIC)", @@ -99,8 +112,10 @@ const T = { 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", connectWallet: "Connect Wallet", + connectTronLink: "Connect TronLink", connected: "Connected", walletConnected: "Wallet connected, address auto-filled", + tronConnected: "TronLink connected", history: "My Transactions", noHistory: "No transactions yet", historyDesc: "Enter your wallet address to view history", @@ -116,6 +131,7 @@ const T = { 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.", 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", calcXic: "Calculated at $0.02/XIC", step1: "Step 1: Select chain and enter amount", @@ -138,6 +154,14 @@ const T = { amountMin: "Minimum amount is $10 USDT", intentSuccess: "Intent registered! Please send USDT now", 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", usdtDecimals: 18, gasNote: "BNB", + isTron: false, }, { chainId: 1, @@ -166,6 +191,7 @@ const CHAINS = [ explorerUrl: "https://etherscan.io", usdtDecimals: 6, gasNote: "ETH", + isTron: false, }, { chainId: 137, @@ -178,6 +204,7 @@ const CHAINS = [ explorerUrl: "https://polygonscan.com", usdtDecimals: 6, gasNote: "MATIC", + isTron: false, }, { chainId: 42161, @@ -190,6 +217,7 @@ const CHAINS = [ explorerUrl: "https://arbiscan.io", usdtDecimals: 6, gasNote: "ETH", + isTron: false, }, { chainId: 43114, @@ -202,6 +230,21 @@ const CHAINS = [ explorerUrl: "https://snowtrace.io", usdtDecimals: 6, 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 selectedChain = CHAINS[selectedChainIdx]; + const isTronChain = selectedChain.isTron; const [usdtAmount, setUsdtAmount] = useState("100"); const [xicReceiveAddress, setXicReceiveAddress] = useState(""); @@ -228,11 +272,17 @@ export default function Bridge() { const [queryAddress, setQueryAddress] = useState(""); const [copiedHash, setCopiedHash] = useState(null); - // Wallet + // Order polling state + const pollingRef = useRef | null>(null); + + // EVM Wallet const wallet = useWallet(); 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( wallet.provider, wallet.signer, @@ -240,16 +290,19 @@ export default function Bridge() { wallet.chainId ); - // Auto-fill XIC receive address from connected wallet + // Auto-fill XIC receive address from connected EVM wallet useEffect(() => { if (wallet.address && !xicReceiveAddress) { setXicReceiveAddress(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(); - // tRPC: record completed on-chain order const recordOrder = trpc.bridge.recordOrder.useMutation(); // tRPC: query orders by wallet address @@ -258,6 +311,36 @@ export default function Bridge() { { 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 const xicAmount = usdtAmount && !isNaN(Number(usdtAmount)) && Number(usdtAmount) > 0 ? (Number(usdtAmount) / XIC_PRICE).toLocaleString(undefined, { maximumFractionDigits: 0 }) @@ -278,7 +361,34 @@ export default function Bridge() { 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 amount = Number(usdtAmount); if (!usdtAmount || isNaN(amount) || amount <= 0) { @@ -306,6 +416,7 @@ export default function Bridge() { await wallet.switchNetwork(selectedChain.chainId); return; } + web3Bridge.resetTransferState(); const result = await web3Bridge.sendUsdtTransfer({ toAddress: selectedChain.receivingAddress, @@ -313,8 +424,8 @@ export default function Bridge() { chainId: selectedChain.chainId, decimals: selectedChain.usdtDecimals, }); + if (result?.txHash) { - // Record the completed on-chain order try { await recordOrder.mutateAsync({ txHash: result.txHash, @@ -322,16 +433,29 @@ export default function Bridge() { fromChainId: selectedChain.chainId, fromToken: "USDT", fromAmount: amount.toString(), - toChainId: 56, // XIC is on BSC + toChainId: 56, toToken: "XIC", toAmount: (amount / XIC_PRICE).toFixed(0), }); setRegistered(true); + setQueryAddress(xicReceiveAddress || wallet.address || ""); + setHistoryAddress(xicReceiveAddress || wallet.address || ""); toast.success(lang === "zh" ? `转账成功!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 { setRegistered(true); 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 amount = Number(usdtAmount); if (!usdtAmount || isNaN(amount) || amount <= 0) { @@ -358,7 +546,7 @@ export default function Bridge() { toast.error(t.addressRequired); return; } - if (!/^0x[0-9a-fA-F]{40}$/.test(xicReceiveAddress)) { + if (!isTronChain && !/^0x[0-9a-fA-F]{40}$/.test(xicReceiveAddress)) { toast.error(t.invalidAddress); return; } @@ -367,17 +555,17 @@ export default function Bridge() { try { await registerIntent.mutateAsync({ fromChainId: selectedChain.chainId, - senderAddress: wallet.address || undefined, // only set if wallet connected + senderAddress: isTronChain ? (tron.tronAddress || undefined) : (wallet.address || undefined), xicReceiveAddress, expectedUsdt: amount, }); setRegistered(true); toast.success(t.intentSuccess); - // Auto-query history setQueryAddress(xicReceiveAddress); setHistoryAddress(xicReceiveAddress); - } catch (err: any) { - toast.error(t.intentFailed + ": " + (err?.message || "")); + } catch (err: unknown) { + const error = err as { message?: string }; + toast.error(t.intentFailed + ": " + (error?.message || "")); } finally { setRegistering(false); } @@ -461,7 +649,7 @@ export default function Bridge() { {CHAINS.map((chain, idx) => ( )} - {wallet.address && ( + {!isTronChain && wallet.address && ( {t.walletConnected} )} + {/* TronLink connect button (TRON chain) */} + {isTronChain && !tron.tronConnected && ( + + )} + {isTronChain && tron.tronConnected && ( + + + {t.tronConnected}: {tron.tronAddress?.slice(0, 6)}...{tron.tronAddress?.slice(-4)} + + )}

{t.xicReceiveAddrHint}

+ {/* TronLink error */} + {isTronChain && tron.tronError && ( +
+ + {tron.tronError} +
+ )} {/* Step 4: Receiving address + Web3 transfer */} @@ -590,7 +815,7 @@ export default function Bridge() { 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)" }} > - {/* Header row with chain badge and USDT balance */} + {/* Header row with chain badge and balance */}
{t.sendTo} @@ -601,8 +826,8 @@ export default function Bridge() { {selectedChain.icon} {selectedChain.name}
- {/* USDT Balance display */} - {wallet.isConnected && ( + {/* Balance display */} + {!isTronChain && wallet.isConnected && (
{web3Bridge.usdtBalanceLoading ? ( @@ -621,6 +846,25 @@ export default function Bridge() {
)} + {isTronChain && tron.tronConnected && ( +
+ {tron.tronUsdtBalanceLoading ? ( + + ) : tron.tronUsdtBalance !== null ? ( + + {lang === "zh" ? "余额" : "Bal"}: + {tron.tronUsdtBalance} USDT + + ) : null} + +
+ )}

{t.sendToHint}

@@ -649,12 +893,12 @@ export default function Bridge() { - {/* One-click wallet transfer — shown when wallet connected and not yet registered */} - {wallet.isConnected && !registered && ( + {/* EVM one-click wallet transfer */} + {!isTronChain && wallet.isConnected && !registered && (
- {lang === "zh" ? "或一键转账" : "or send directly"} + {t.orSendDirectly}
)} -

- {lang === "zh" - ? "钱包将弹出签名确认,无需手动复制地址" - : "Wallet will prompt for signature — no manual copy needed"} -

+

{t.walletSignNote}

+
+ )} + + {/* TRON one-click TronLink transfer */} + {isTronChain && tron.tronConnected && !registered && ( +
+
+
+ {t.orSendDirectly} +
+
+ + {tron.tronTransferError && ( +
+ + {tron.tronTransferError} +
+ )} +

{t.walletSignNote}

)}
- {/* Confirm button */} + {/* Confirm button (manual — no wallet) */} {!registered ? ( + )}
)} @@ -751,7 +1067,7 @@ export default function Bridge() { {/* ─── Info Cards ─────────────────────────────────────────────────── */}
{[ - { 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.listingTarget, value: "$0.10", sub: t.xPotential }, ].map((card, i) => ( @@ -782,6 +1098,14 @@ export default function Bridge() {
{t.history} + {registered && queryAddress && ( + + {lang === "zh" ? "监控中" : "Monitoring"} + + )}
{showHistory ? : } @@ -837,7 +1161,6 @@ export default function Bridge() { {orders.map(order => { const chain = CHAINS.find(c => c.chainId === order.fromChainId); const isIntent = order.type === 'intent'; - // Safe field access for both intent and order types const usdtAmt = isIntent ? (order as { type: 'intent'; expectedUsdt: number | null }).expectedUsdt : (order as { type: 'order'; fromAmount: number }).fromAmount; @@ -900,10 +1223,26 @@ export default function Bridge() {
)} {isIntent && ( -

- ⏳ Monitoring your transfer on {chain?.name ?? `Chain ${order.fromChainId}`}... +

+ + Monitoring your transfer on {chain?.name ?? `Chain ${order.fromChainId}`}...

)} + {/* Add XIC to wallet button for distributed orders */} + {order.status === "distributed" && (wallet.isConnected || tron.tronConnected) && ( + + )}
); })} @@ -916,6 +1255,7 @@ export default function Bridge() { {/* ─── Disclaimer ─────────────────────────────────────────────────── */}

{t.disclaimer}

+ {/* ─── Wallet Selector Modal (Portal) ───────────────────────────────── */} {showWalletSelector && createPortal(
, document.body - )} + )} + ); } diff --git a/drizzle/0008_lowly_pride.sql b/drizzle/0008_lowly_pride.sql new file mode 100644 index 0000000..a041a25 --- /dev/null +++ b/drizzle/0008_lowly_pride.sql @@ -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`) +); diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..83fdc1e --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ea4eee9..6ff197e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1773136228889, "tag": "0007_wide_menace", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1773142627500, + "tag": "0008_lowly_pride", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index efb7474..4d48fa1 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -138,3 +138,32 @@ export const bridgeIntents = mysqlTable("bridge_intents", { export type BridgeIntent = typeof bridgeIntents.$inferSelect; 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; diff --git a/server/bridgeMonitor.ts b/server/bridgeMonitor.ts index 82df195..5f98096 100644 --- a/server/bridgeMonitor.ts +++ b/server/bridgeMonitor.ts @@ -7,6 +7,7 @@ import { getDb } from "./db"; import { bridgeOrders, bridgeIntents } from "../drizzle/schema"; import { eq, and, desc } from "drizzle-orm"; +import { creditXic } from "./tokenDistributionService"; // ─── Presale Config ─────────────────────────────────────────────────────────── export const XIC_PRICE_USDT = 0.02; // $0.02 per XIC @@ -218,21 +219,24 @@ async function processTransfers(chain: ChainConfig): Promise { const xicReceiveAddress = intent.length > 0 ? intent[0].xicReceiveAddress : null; - // Record the order - await db.insert(bridgeOrders).values({ - txHash: transfer.txHash, - walletAddress: transfer.fromAddress, - fromChainId: chain.chainId, - fromToken: "USDT", - fromAmount: String(transfer.amount), - toChainId: 56, - toToken: "XIC", - toAmount: String(xicAmount), - xicReceiveAddress, - status: "confirmed", - confirmedAt: new Date(), - blockNumber: transfer.blockNumber, - }); + // Record the order first (pending status) + try { + await db.insert(bridgeOrders).values({ + txHash: transfer.txHash, + walletAddress: transfer.fromAddress, + fromChainId: chain.chainId, + fromToken: "USDT", + fromAmount: String(transfer.amount), + toChainId: 56, + toToken: "XIC", + toAmount: String(xicAmount), + xicReceiveAddress, + status: "pending", + blockNumber: transfer.blockNumber, + }); + } catch (insertErr: any) { + if (insertErr?.code !== "ER_DUP_ENTRY") throw insertErr; + } // Mark intent as matched if (intent.length > 0) { @@ -242,6 +246,19 @@ async function processTransfers(chain: ChainConfig): Promise { .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`); } catch (err: any) { if (err?.code === "ER_DUP_ENTRY") continue; diff --git a/server/presale.test.ts b/server/presale.test.ts index a0b8b21..4a72549 100644 --- a/server/presale.test.ts +++ b/server/presale.test.ts @@ -3,7 +3,7 @@ import { CONTRACTS, TOKEN_PRICE_USDT, HARD_CAP_USDT, MAX_PURCHASE_USDT } from ". describe("Presale Configuration", () => { 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", () => { diff --git a/server/routers.ts b/server/routers.ts index 1820c66..29285ab 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -11,46 +11,65 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { notifyDistributed, testTelegramConnection } from "./telegram"; import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb"; +import { creditXic } from "./tokenDistributionService"; // Admin password from env (fallback for development) const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!"; // ─── Bridge 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 .input(z.object({ txHash: z.string().min(1).max(128), walletAddress: z.string().min(1).max(64), fromChainId: z.number().int(), fromToken: z.string().max(32), - fromAmount: z.string(), + fromAmount: z.string(), // USDT amount toChainId: z.number().int(), 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 }) => { const db = await getDb(); if (!db) return { success: false, message: "DB unavailable" }; + + // Insert bridge order (pending — creditXic will update to confirmed) try { await db.insert(bridgeOrders).values({ txHash: input.txHash, - walletAddress: input.walletAddress, + walletAddress: input.walletAddress.toLowerCase(), fromChainId: input.fromChainId, fromToken: input.fromToken, fromAmount: input.fromAmount, toChainId: input.toChainId, toToken: input.toToken, toAmount: input.toAmount, - status: "confirmed" as const, - xicReceiveAddress: null, - confirmedAt: new Date(), + xicReceiveAddress: input.xicReceiveAddress ?? null, + status: "pending" as const, }); - return { success: true }; } catch (e: any) { - if (e?.code === "ER_DUP_ENTRY") return { success: true }; - throw e; + if (e?.code !== "ER_DUP_ENTRY") 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) diff --git a/server/tokenDistributionService.ts b/server/tokenDistributionService.ts new file mode 100644 index 0000000..58b81a3 --- /dev/null +++ b/server/tokenDistributionService.ts @@ -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 { + 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); +} diff --git a/server/trc20Monitor.ts b/server/trc20Monitor.ts index d0951ae..d1db719 100644 --- a/server/trc20Monitor.ts +++ b/server/trc20Monitor.ts @@ -15,6 +15,7 @@ import { getDb } from "./db"; import { trc20Purchases, trc20Intents } from "../drizzle/schema"; import { TOKEN_PRICE_USDT } from "./onchain"; import { notifyNewTRC20Purchase } from "./telegram"; +import { creditXic } from "./tokenDistributionService"; const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp"; const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; @@ -130,6 +131,18 @@ async function processTransaction(tx: TronTransaction): Promise { 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 await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress); } diff --git a/todo.md b/todo.md index 0a20c34..dca1936 100644 --- a/todo.md +++ b/todo.md @@ -198,3 +198,39 @@ - [x] 链不匹配时自动触发switchNetwork - [ ] 构建部署到AI服务器并测试 - [ ] 同步到备份Git库 + +## v16 代币购买无缝体验(按文档方案) + +- [ ] 购买成功后自动调用 wallet_watchAsset 将 XIC 代币添加到 EVM 钱包(EIP-1193标准) +- [ ] 添加 TRON 链到 CHAINS 数组(chainId 728126428,TRC20 USDT 6位小数) +- [ ] Bridge 页面:TRON 链选中时显示 TronLink 连接按钮(showTron=true) +- [ ] 创建 useTronBridge hook:TronLink 连接 + TRC20 USDT 余额查询 + 一键转账 +- [ ] Bridge 页面:TRON 链一键转账成功后调用 tronLink wallet_addAsset +- [ ] 添加订单状态轮询(注册成功后每 5 秒自动刷新订单列表) +- [ ] 修复钱包连接被拒绝后无法重试(error 4001 状态重置) +- [ ] 部署到 AI 服务器(43.224.155.27)并同步 Git 库 + +## v16 wallet_watchAsset 修复(按文档方案) + +- [ ] 修复 addTokenToWallet.ts:TRON 改用 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.ethereum,TRON: tronWeb.request wallet_watchAsset) +- [x] Bridge.tsx 添加 TRON 链(chainId: 728126428) +- [x] Bridge.tsx 集成 useTronBridge(TronLink 连接 + TRC20 转账) +- [x] Bridge.tsx 订单状态轮询(注册后每 5 秒刷新) +- [x] Bridge.tsx wallet_watchAsset 购买成功后自动添加 XIC 代币 +- [x] 去除前端 bundle 中的 manus.im 内联 +- [x] 全部 18 个 vitest 测试通过 +- [x] 浏览器测试:Bridge 页面、主页、语言切换、Connect Wallet 模态框、TRX 链切换 — 全部通过