Checkpoint: v5 完整功能升级:

1. 修复钱包连接状态共享问题(useWallet提升到Home顶层)
2. 配置BSC/ETH多节点RPC故障转移池(9+7个节点)
3. 添加TRC20购买Telegram通知(Bot Token/Chat ID通过管理后台配置)
4. 管理员后台新增Site Settings标签页(预售参数、首页内容、Telegram配置)
5. 修复Admin.tsx语法错误
This commit is contained in:
Manus 2026-03-08 03:45:55 -04:00
parent 45e1f886aa
commit 40be4636e9
12 changed files with 1349 additions and 49 deletions

View File

@ -119,13 +119,244 @@ function LoginForm({ onLogin }: { onLogin: (token: string) => void }) {
);
}
// ─── Main Dashboard ───────────────────────────────────────────────────────────
// ─── Main D// ─── Settings Panel ───────────────────────────────────────────────
function SettingsPanel({ token }: { token: string }) {
const { data: configData, refetch: refetchConfig, isLoading } = trpc.admin.getConfig.useQuery({ token });
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [savedKeys, setSavedKeys] = useState<Set<string>>(new Set());
const [telegramBotToken, setTelegramBotToken] = useState("");
const [telegramChatId, setTelegramChatId] = useState("");
const [telegramStatus, setTelegramStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [telegramError, setTelegramError] = useState("");
const setConfigMutation = trpc.admin.setConfig.useMutation({
onSuccess: (_, vars) => {
setSavedKeys(prev => { const s = new Set(Array.from(prev)); s.add(vars.key); return s; });
setSavingKey(null);
refetchConfig();
setTimeout(() => setSavedKeys(prev => { const n = new Set(Array.from(prev)); n.delete(vars.key); return n; }), 2000);
},
onError: (err) => {
setSavingKey(null);
alert(`Save failed: ${err.message}`);
},
});
const testTelegramMutation = trpc.admin.testTelegram.useMutation({
onSuccess: () => {
setTelegramStatus("success");
refetchConfig();
},
onError: (err) => {
setTelegramStatus("error");
setTelegramError(err.message);
},
});
// Initialize edit values from config
useEffect(() => {
if (configData) {
const vals: Record<string, string> = {};
configData.forEach(c => { vals[c.key] = c.value; });
setEditValues(vals);
// Pre-fill Telegram fields
const botToken = configData.find(c => c.key === "telegramBotToken")?.value || "";
const chatId = configData.find(c => c.key === "telegramChatId")?.value || "";
if (botToken) setTelegramBotToken(botToken);
if (chatId) setTelegramChatId(chatId);
}
}, [configData]);
const handleSave = (key: string) => {
setSavingKey(key);
setConfigMutation.mutate({ token, key, value: editValues[key] || "" });
};
const handleTestTelegram = () => {
if (!telegramBotToken || !telegramChatId) {
setTelegramStatus("error");
setTelegramError("Please enter both Bot Token and Chat ID");
return;
}
setTelegramStatus("testing");
setTelegramError("");
testTelegramMutation.mutate({ token, botToken: telegramBotToken, chatId: telegramChatId });
};
// Group configs by category
const presaleKeys = ["presaleEndDate", "tokenPrice", "hardCap", "listingPrice", "totalSupply", "maxPurchaseUsdt", "presaleStatus"];
const contentKeys = ["heroTitle", "heroSubtitle", "tronReceivingAddress"];
const telegramKeys = ["telegramBotToken", "telegramChatId"];
const renderConfigRow = (cfg: { key: string; value: string; label: string; description: string; type: string; updatedAt: Date | null }) => (
<div key={cfg.key} className="rounded-xl p-4 mb-3" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-white/80">{cfg.label}</span>
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.4)" }}>{cfg.type}</span>
</div>
<p className="text-xs text-white/40 mb-2">{cfg.description}</p>
{cfg.type === "text" && cfg.key !== "heroSubtitle" ? (
<input
type="text"
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : cfg.key === "heroSubtitle" ? (
<textarea
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
rows={3}
className="w-full px-3 py-2 rounded-lg text-sm resize-none"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : cfg.type === "number" ? (
<input
type="number"
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : cfg.type === "date" ? (
<input
type="datetime-local"
value={editValues[cfg.key] ? editValues[cfg.key].replace("Z", "").slice(0, 16) : ""}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value + ":00Z" }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
) : (
<input
type="text"
value={editValues[cfg.key] ?? cfg.value}
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
className="w-full px-3 py-2 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
)}
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<button
onClick={() => handleSave(cfg.key)}
disabled={savingKey === cfg.key}
className="px-4 py-2 rounded-lg text-xs font-semibold transition-all whitespace-nowrap"
style={{
background: savedKeys.has(cfg.key) ? "rgba(0,230,118,0.2)" : "rgba(240,180,41,0.15)",
border: savedKeys.has(cfg.key) ? "1px solid rgba(0,230,118,0.4)" : "1px solid rgba(240,180,41,0.3)",
color: savedKeys.has(cfg.key) ? "#00e676" : "#f0b429",
}}
>
{savingKey === cfg.key ? "Saving..." : savedKeys.has(cfg.key) ? "✓ Saved" : "Save"}
</button>
{cfg.updatedAt && (
<span className="text-xs text-white/25">{new Date(cfg.updatedAt).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
if (isLoading) {
return <div className="text-center py-12 text-white/40">Loading settings...</div>;
}
const getConfigItem = (key: string) => configData?.find(c => c.key === key);
return (
<div className="space-y-6">
{/* Presale Parameters */}
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(240,180,41,0.15)" }}>
<h3 className="text-sm font-semibold mb-4" style={{ color: "#f0b429" }}>Presale Parameters </h3>
{presaleKeys.map(key => {
const cfg = getConfigItem(key);
if (!cfg) return null;
return renderConfigRow(cfg);
})}
</div>
{/* Site Content */}
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(0,212,255,0.15)" }}>
<h3 className="text-sm font-semibold mb-4" style={{ color: "#00d4ff" }}>Site Content </h3>
{contentKeys.map(key => {
const cfg = getConfigItem(key);
if (!cfg) return null;
return renderConfigRow(cfg);
})}
</div>
{/* Telegram Notifications */}
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(0,230,118,0.15)" }}>
<h3 className="text-sm font-semibold mb-1" style={{ color: "#00e676" }}>Telegram Notifications</h3>
<p className="text-xs text-white/40 mb-4">
Set up Telegram Bot to receive instant alerts when new TRC20 purchases are confirmed.
Get your Bot Token from <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" style={{ color: "#00d4ff" }}>@BotFather</a>.
</p>
<div className="space-y-3 mb-4">
<div>
<label className="text-xs text-white/60 block mb-1">Bot Token (from @BotFather)</label>
<input
type="text"
value={telegramBotToken}
onChange={e => setTelegramBotToken(e.target.value)}
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
className="w-full px-3 py-2 rounded-lg text-sm font-mono"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
</div>
<div>
<label className="text-xs text-white/60 block mb-1">Chat ID (personal or group)</label>
<input
type="text"
value={telegramChatId}
onChange={e => setTelegramChatId(e.target.value)}
placeholder="-1001234567890 or 123456789"
className="w-full px-3 py-2 rounded-lg text-sm font-mono"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
/>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleTestTelegram}
disabled={telegramStatus === "testing"}
className="px-5 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: telegramStatus === "success" ? "rgba(0,230,118,0.2)" : "rgba(0,230,118,0.1)",
border: telegramStatus === "success" ? "1px solid rgba(0,230,118,0.5)" : "1px solid rgba(0,230,118,0.3)",
color: "#00e676",
}}
>
{telegramStatus === "testing" ? "Sending test..." : telegramStatus === "success" ? "✓ Connected & Saved!" : "Test & Save Connection"}
</button>
{telegramStatus === "error" && (
<span className="text-xs text-red-400">{telegramError}</span>
)}
{telegramStatus === "success" && (
<span className="text-xs text-green-400">Test message sent! Check your Telegram.</span>
)}
</div>
<div className="mt-4 rounded-lg p-3 text-xs text-white/50" style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)" }}>
<p className="font-semibold text-white/70 mb-1">How to get Chat ID:</p>
<p>1. Start a chat with your bot (send any message)</p>
<p>2. Visit: <code className="text-cyan-400">https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code></p>
<p>3. Find <code className="text-cyan-400">{'{"chat":{"id": YOUR_CHAT_ID}}'}</code> in the response</p> </div>
</div>
</div>
);
}
// ─── Main Dashboard ─────────────────────────────────────────────
function Dashboard({ token, onLogout }: { token: string; onLogout: () => void }) {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "confirmed" | "distributed" | "failed">("all");
const [markingId, setMarkingId] = useState<number | null>(null);
const [distributeTxInput, setDistributeTxInput] = useState<Record<number, string>>({});
const [activeTab, setActiveTab] = useState<"purchases" | "intents">("purchases");
const [activeTab, setActiveTab] = useState<"purchases" | "intents" | "settings">("purchases");
const { data: statsData, refetch: refetchStats } = trpc.admin.stats.useQuery({ token });
const { data: intentsData, isLoading: intentsLoading } = trpc.admin.listIntents.useQuery({ token, showAll: false });
@ -294,6 +525,17 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
</span>
)}
</button>
<button
onClick={() => setActiveTab("settings")}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: activeTab === "settings" ? "rgba(0,230,118,0.15)" : "rgba(255,255,255,0.04)",
border: activeTab === "settings" ? "1px solid rgba(0,230,118,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: activeTab === "settings" ? "#00e676" : "rgba(255,255,255,0.5)",
}}
>
Site Settings
</button>
</div>
{/* ── EVM Intents Table ── */}
@ -519,7 +761,13 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
</div>
)}
{/* ── Site Settings Panel ── */}
{activeTab === "settings" && (
<SettingsPanel token={token} />
)}
{/* ── Instructions ── */}
{activeTab !== "settings" && (
<div className="mt-6 rounded-2xl p-5" style={{ background: "rgba(0,212,255,0.04)", border: "1px solid rgba(0,212,255,0.15)" }}>
<h3 className="text-sm font-semibold text-cyan-400 mb-3">Distribution Workflow</h3>
<div className="space-y-2 text-sm text-white/60">
@ -530,6 +778,7 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
<p>5. <strong className="text-white/80">No EVM address?</strong> Contact buyer via Telegram/email to get their BSC address</p>
</div>
</div>
)}
</div>
</div>
);

View File

@ -234,10 +234,9 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
);
}
// ─── EVM Purchase Panel ───────────────────────────────────────────────────────
function EVMPurchasePanel({ network, lang }: { network: "BSC" | "ETH"; lang: Lang }) {
// ─── EVM Purchase Panel ─────────────────────────────────────────────────────
function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; lang: Lang; wallet: WalletHookReturn }) {
const { t } = useTranslation(lang);
const wallet = useWallet();
const { purchaseState, buyWithUSDT, reset, calcTokens, getUsdtBalance } = usePresale(wallet, network);
const [usdtInput, setUsdtInput] = useState("100");
const [usdtBalance, setUsdtBalance] = useState<number | null>(null);
@ -675,9 +674,9 @@ function ChatSupport({ lang }: { lang: Lang }) {
}
// ─── Navbar Wallet Button ─────────────────────────────────────────────────────
function NavWalletButton({ lang }: { lang: Lang }) {
type WalletHookReturn = ReturnType<typeof useWallet>;
function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookReturn }) {
const { t } = useTranslation(lang);
const wallet = useWallet();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -797,6 +796,9 @@ export default function Home() {
const stats = onChainStats || FALLBACK_STATS;
const progressPct = stats.progressPct || 0;
// 钱包状态提升到顶层共享给NavWalletButton和EVMPurchasePanel
const wallet = useWallet();
const networks: NetworkTab[] = ["BSC", "ETH", "TRON"];
return (
@ -820,7 +822,7 @@ export default function Home() {
</span>
</Link>
<LangToggle lang={lang} setLang={setLang} />
<NavWalletButton lang={lang} />
<NavWalletButton lang={lang} wallet={wallet} />
</div>
</nav>
@ -986,8 +988,8 @@ export default function Home() {
{/* Purchase Area */}
<div>
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} />}
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} />}
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} wallet={wallet} />}
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} wallet={wallet} />}
{activeNetwork === "TRON" && (
<div className="space-y-4">
<div className="space-y-2">

View File

@ -0,0 +1,11 @@
CREATE TABLE `presale_config` (
`id` int AUTO_INCREMENT NOT NULL,
`key` varchar(64) NOT NULL,
`value` text NOT NULL,
`label` varchar(128),
`description` varchar(256),
`type` varchar(32) DEFAULT 'text',
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `presale_config_id` PRIMARY KEY(`id`),
CONSTRAINT `presale_config_key_unique` UNIQUE(`key`)
);

View File

@ -0,0 +1,429 @@
{
"version": "5",
"dialect": "mysql",
"id": "6b25cb51-fd4a-43ff-9411-e1efd553f304",
"prevId": "58f17be6-1ea0-44cb-9d74-094dbec51be3",
"tables": {
"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": {}
},
"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

@ -29,6 +29,13 @@
"when": 1772950356383,
"tag": "0003_volatile_firestar",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1772955197567,
"tag": "0004_parallel_unus",
"breakpoints": true
}
]
}

View File

@ -81,4 +81,19 @@ export const trc20Intents = mysqlTable("trc20_intents", {
});
export type Trc20Intent = typeof trc20Intents.$inferSelect;
export type InsertTrc20Intent = typeof trc20Intents.$inferInsert;
export type InsertTrc20Intent = typeof trc20Intents.$inferInsert;
// Presale configuration — editable by admin from the admin panel
// Each row is a key-value pair (e.g. presaleEndDate, tokenPrice, hardCap, etc.)
export const presaleConfig = mysqlTable("presale_config", {
id: int("id").autoincrement().primaryKey(),
key: varchar("key", { length: 64 }).notNull().unique(),
value: text("value").notNull(),
label: varchar("label", { length: 128 }), // Human-readable label for admin UI
description: varchar("description", { length: 256 }), // Help text
type: varchar("type", { length: 32 }).default("text"), // text | number | date | boolean | url
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type PresaleConfig = typeof presaleConfig.$inferSelect;
export type InsertPresaleConfig = typeof presaleConfig.$inferInsert;

225
server/configDb.ts Normal file
View File

@ -0,0 +1,225 @@
/**
* Presale Configuration Database Helpers
*
* Manages the presale_config table a key-value store for
* admin-editable presale parameters.
*
* Default values are seeded on first access if not present.
*/
import { eq } from "drizzle-orm";
import { getDb } from "./db";
import { presaleConfig } from "../drizzle/schema";
// ─── Default configuration values ─────────────────────────────────────────────
export const DEFAULT_CONFIG: Array<{
key: string;
value: string;
label: string;
description: string;
type: string;
}> = [
{
key: "presaleEndDate",
value: "2026-06-30T23:59:59Z",
label: "预售结束时间 (Presale End Date)",
description: "ISO 8601 格式例如2026-06-30T23:59:59Z",
type: "date",
},
{
key: "tokenPrice",
value: "0.02",
label: "代币价格 (Token Price, USDT)",
description: "每枚 XIC 的 USDT 价格",
type: "number",
},
{
key: "hardCap",
value: "5000000",
label: "硬顶 (Hard Cap, USDT)",
description: "预售最大募资额USDT",
type: "number",
},
{
key: "listingPrice",
value: "0.10",
label: "上市目标价格 (Target Listing Price, USDT)",
description: "预计上市价格(仅展示用)",
type: "number",
},
{
key: "totalSupply",
value: "100000000000",
label: "总供应量 (Total Supply)",
description: "XIC 代币总供应量",
type: "number",
},
{
key: "maxPurchaseUsdt",
value: "50000",
label: "单笔最大购买额 (Max Purchase, USDT)",
description: "单笔购买最大 USDT 金额",
type: "number",
},
{
key: "presaleStatus",
value: "live",
label: "预售状态 (Presale Status)",
description: "live = 进行中paused = 暂停ended = 已结束",
type: "text",
},
{
key: "heroTitle",
value: "XIC Token Presale",
label: "首页标题 (Hero Title)",
description: "首页大标题文字",
type: "text",
},
{
key: "heroSubtitle",
value: "New AssetChain — The next-generation RWA native blockchain with AI-native compliance, CBPP consensus, and Charter smart contracts.",
label: "首页副标题 (Hero Subtitle)",
description: "首页副标题文字",
type: "text",
},
{
key: "tronReceivingAddress",
value: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp",
label: "TRC20 收款地址 (TRON Receiving Address)",
description: "接收 TRC20 USDT 的 TRON 地址",
type: "text",
},
{
key: "telegramBotToken",
value: "",
label: "Telegram Bot Token",
description: "从 @BotFather 获取的 Bot Token用于发送购买通知",
type: "text",
},
{
key: "telegramChatId",
value: "",
label: "Telegram Chat ID",
description: "接收通知的 Chat ID个人账号或群组",
type: "text",
},
];
export interface ConfigMap {
[key: string]: string;
}
/**
* Seed default config values if not already present.
*/
export async function seedDefaultConfig(): Promise<void> {
const db = await getDb();
if (!db) return;
for (const item of DEFAULT_CONFIG) {
try {
const existing = await db
.select()
.from(presaleConfig)
.where(eq(presaleConfig.key, item.key))
.limit(1);
if (existing.length === 0) {
await db.insert(presaleConfig).values({
key: item.key,
value: item.value,
label: item.label,
description: item.description,
type: item.type,
});
}
} catch (e) {
console.warn(`[Config] Failed to seed ${item.key}:`, e);
}
}
}
/**
* Get all config values as a key-value map.
* Falls back to DEFAULT_CONFIG values if DB is unavailable.
*/
export async function getAllConfig(): Promise<ConfigMap> {
// Build defaults map
const defaults: ConfigMap = {};
for (const item of DEFAULT_CONFIG) {
defaults[item.key] = item.value;
}
const db = await getDb();
if (!db) return defaults;
try {
const rows = await db.select().from(presaleConfig);
const result: ConfigMap = { ...defaults };
for (const row of rows) {
result[row.key] = row.value;
}
return result;
} catch (e) {
console.warn("[Config] Failed to read config:", e);
return defaults;
}
}
/**
* Get a single config value by key.
*/
export async function getConfig(key: string): Promise<string | null> {
const db = await getDb();
if (!db) {
const def = DEFAULT_CONFIG.find((c) => c.key === key);
return def?.value ?? null;
}
try {
const rows = await db
.select()
.from(presaleConfig)
.where(eq(presaleConfig.key, key))
.limit(1);
if (rows.length > 0) return rows[0].value;
// Fall back to default
const def = DEFAULT_CONFIG.find((c) => c.key === key);
return def?.value ?? null;
} catch (e) {
console.warn(`[Config] Failed to read ${key}:`, e);
return null;
}
}
/**
* Update a single config value.
*/
export async function setConfig(key: string, value: string): Promise<void> {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
const existing = await db
.select()
.from(presaleConfig)
.where(eq(presaleConfig.key, key))
.limit(1);
if (existing.length > 0) {
await db
.update(presaleConfig)
.set({ value })
.where(eq(presaleConfig.key, key));
} else {
const def = DEFAULT_CONFIG.find((c) => c.key === key);
await db.insert(presaleConfig).values({
key,
value,
label: def?.label ?? key,
description: def?.description ?? "",
type: def?.type ?? "text",
});
}
}

View File

@ -2,18 +2,45 @@
* On-chain data service
* Reads presale stats from BSC and ETH contracts using ethers.js
* Caches results in DB to avoid rate limiting
*
* RPC Strategy: Multi-node failover pool tries each node in order until one succeeds
*/
import { ethers } from "ethers";
import { eq } from "drizzle-orm";
import { getDb } from "./db";
import { presaleStatsCache } from "../drizzle/schema";
// ─── Multi-node RPC Pool ────────────────────────────────────────────────────────
// Multiple public RPC endpoints for each chain — tried in order, first success wins
const RPC_POOLS = {
BSC: [
"https://bsc-dataseed1.binance.org/",
"https://bsc-dataseed2.binance.org/",
"https://bsc-dataseed3.binance.org/",
"https://bsc-dataseed4.binance.org/",
"https://bsc-dataseed1.defibit.io/",
"https://bsc-dataseed2.defibit.io/",
"https://bsc.publicnode.com",
"https://binance.llamarpc.com",
"https://rpc.ankr.com/bsc",
],
ETH: [
"https://eth.llamarpc.com",
"https://ethereum.publicnode.com",
"https://rpc.ankr.com/eth",
"https://1rpc.io/eth",
"https://eth.drpc.org",
"https://cloudflare-eth.com",
"https://rpc.payload.de",
],
};
// ─── Contract Addresses ────────────────────────────────────────────────────────
export const CONTRACTS = {
BSC: {
presale: "0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c",
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
rpc: "https://bsc-dataseed1.binance.org/",
rpc: RPC_POOLS.BSC[0],
chainId: 56,
chainName: "BNB Smart Chain",
explorerUrl: "https://bscscan.com",
@ -22,7 +49,7 @@ export const CONTRACTS = {
ETH: {
presale: "0x85AB2F2d9f7ca7ecB272b5E8726c70f3fd45D1E3",
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
rpc: "https://eth.llamarpc.com",
rpc: RPC_POOLS.ETH[0],
chainId: 1,
chainName: "Ethereum",
explorerUrl: "https://etherscan.io",
@ -56,6 +83,7 @@ export interface PresaleStats {
tokensSold: number;
lastUpdated: Date;
fromCache: boolean;
rpcUsed?: string;
}
export interface CombinedStats {
@ -73,45 +101,89 @@ export interface CombinedStats {
// Cache TTL: 60 seconds
const CACHE_TTL_MS = 60_000;
async function fetchChainStats(chain: "BSC" | "ETH"): Promise<{ usdtRaised: number; tokensSold: number }> {
const cfg = CONTRACTS[chain];
const provider = new ethers.JsonRpcProvider(cfg.rpc);
const contract = new ethers.Contract(cfg.presale, PRESALE_ABI, provider);
// RPC timeout: 8 seconds per node
const RPC_TIMEOUT_MS = 8_000;
let usdtRaised = 0;
let tokensSold = 0;
/**
* Try each RPC node in the pool until one succeeds.
* Returns { usdtRaised, tokensSold, rpcUsed } or throws if all fail.
*/
async function fetchChainStatsWithFailover(
chain: "BSC" | "ETH"
): Promise<{ usdtRaised: number; tokensSold: number; rpcUsed: string }> {
const pool = RPC_POOLS[chain];
const presaleAddress = CONTRACTS[chain].presale;
const errors: string[] = [];
// Try different function names that might exist in the contract
try {
const raw = await contract.totalUSDTRaised();
usdtRaised = Number(ethers.formatUnits(raw, 6)); // USDT has 6 decimals
} catch {
for (const rpcUrl of pool) {
try {
const raw = await contract.usdtRaised();
usdtRaised = Number(ethers.formatUnits(raw, 6));
} catch {
try {
const raw = await contract.weiRaised();
usdtRaised = Number(ethers.formatUnits(raw, 6));
} catch {
console.warn(`[OnChain] Could not read usdtRaised from ${chain}`);
}
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
staticNetwork: true,
polling: false,
});
// Set a timeout for the provider
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`RPC timeout: ${rpcUrl}`)), RPC_TIMEOUT_MS)
);
const contract = new ethers.Contract(presaleAddress, PRESALE_ABI, provider);
let usdtRaised = 0;
let tokensSold = 0;
// Try different function names that might exist in the contract
const usdtPromise = (async () => {
try {
const raw = await contract.totalUSDTRaised();
return Number(ethers.formatUnits(raw, 6));
} catch {
try {
const raw = await contract.usdtRaised();
return Number(ethers.formatUnits(raw, 6));
} catch {
try {
const raw = await contract.weiRaised();
return Number(ethers.formatUnits(raw, 6));
} catch {
return 0;
}
}
}
})();
const tokensPromise = (async () => {
try {
const raw = await contract.totalTokensSold();
return Number(ethers.formatUnits(raw, 18));
} catch {
try {
const raw = await contract.tokensSold();
return Number(ethers.formatUnits(raw, 18));
} catch {
return 0;
}
}
})();
const [usdtResult, tokensResult] = await Promise.race([
Promise.all([usdtPromise, tokensPromise]),
timeoutPromise,
]);
usdtRaised = usdtResult;
tokensSold = tokensResult;
console.log(`[OnChain] ${chain} stats fetched via ${rpcUrl}: $${usdtRaised} USDT, ${tokensSold} XIC`);
return { usdtRaised, tokensSold, rpcUsed: rpcUrl };
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
errors.push(`${rpcUrl}: ${errMsg}`);
console.warn(`[OnChain] ${chain} RPC failed (${rpcUrl}): ${errMsg}`);
}
}
try {
const raw = await contract.totalTokensSold();
tokensSold = Number(ethers.formatUnits(raw, 18)); // XIC has 18 decimals
} catch {
try {
const raw = await contract.tokensSold();
tokensSold = Number(ethers.formatUnits(raw, 18));
} catch {
console.warn(`[OnChain] Could not read tokensSold from ${chain}`);
}
}
return { usdtRaised, tokensSold };
throw new Error(`All ${chain} RPC nodes failed:\n${errors.join("\n")}`);
}
export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStats> {
@ -144,15 +216,18 @@ export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStat
}
}
// Fetch fresh from chain
// Fetch fresh from chain with failover
let usdtRaised = 0;
let tokensSold = 0;
let rpcUsed = "";
try {
const data = await fetchChainStats(chain);
const data = await fetchChainStatsWithFailover(chain);
usdtRaised = data.usdtRaised;
tokensSold = data.tokensSold;
rpcUsed = data.rpcUsed;
} catch (e) {
console.error(`[OnChain] Failed to fetch ${chain} stats:`, e);
console.error(`[OnChain] All ${chain} RPC nodes exhausted:`, e);
}
// Update cache
@ -192,6 +267,7 @@ export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStat
tokensSold,
lastUpdated: new Date(),
fromCache: false,
rpcUsed,
};
}

View File

@ -9,6 +9,8 @@ import { trc20Purchases, trc20Intents } from "../drizzle/schema";
import { eq, desc, sql } from "drizzle-orm";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { notifyDistributed, testTelegramConnection } from "./telegram";
import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb";
// Admin password from env (fallback for development)
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!";
@ -190,6 +192,12 @@ export const appRouter = router({
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
const purchase = await db
.select()
.from(trc20Purchases)
.where(eq(trc20Purchases.id, input.purchaseId))
.limit(1);
await db
.update(trc20Purchases)
.set({
@ -200,6 +208,20 @@ export const appRouter = router({
})
.where(eq(trc20Purchases.id, input.purchaseId));
// Send Telegram notification
if (purchase[0]?.evmAddress) {
try {
await notifyDistributed({
txHash: purchase[0].txHash,
evmAddress: purchase[0].evmAddress,
xicAmount: Number(purchase[0].xicAmount),
distributeTxHash: input.distributeTxHash,
});
} catch (e) {
console.warn("[Admin] Telegram notification failed:", e);
}
}
return { success: true };
}),
@ -264,6 +286,75 @@ export const appRouter = router({
totalXic: Number(r.totalXic || 0),
}));
}),
// ─── Presale Configuration Management ─────────────────────────────────
// Get all config key-value pairs with metadata
getConfig: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
// Seed defaults first
await seedDefaultConfig();
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
const { presaleConfig } = await import("../drizzle/schema");
const rows = await db.select().from(presaleConfig);
// Merge with DEFAULT_CONFIG metadata
return DEFAULT_CONFIG.map(def => {
const row = rows.find(r => r.key === def.key);
return {
key: def.key,
value: row?.value ?? def.value,
label: def.label,
description: def.description,
type: def.type,
updatedAt: row?.updatedAt ?? null,
};
});
}),
// Update a single config value
setConfig: publicProcedure
.input(z.object({
token: z.string(),
key: z.string().min(1).max(64),
value: z.string(),
}))
.mutation(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
// Validate key is in allowed list
const allowed = DEFAULT_CONFIG.map(c => c.key);
if (!allowed.includes(input.key)) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown config key: ${input.key}` });
}
await setConfig(input.key, input.value);
return { success: true };
}),
// Test Telegram connection
testTelegram: publicProcedure
.input(z.object({
token: z.string(),
botToken: z.string().min(10),
chatId: z.string().min(1),
}))
.mutation(async ({ input }) => {
if (!input.token.startsWith("bmFjLWFkbWlu")) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
}
const result = await testTelegramConnection(input.botToken, input.chatId);
if (!result.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: result.error || "Test failed" });
}
// Save to config if test succeeds
await setConfig("telegramBotToken", input.botToken);
await setConfig("telegramChatId", input.chatId);
return { success: true };
}),
}),
});

169
server/telegram.ts Normal file
View File

@ -0,0 +1,169 @@
/**
* Telegram Notification Service
*
* Sends alerts to admin via Telegram Bot when:
* - New TRC20 purchase is confirmed
* - Purchase is marked as distributed
*
* Configuration (via environment variables or admin settings table):
* TELEGRAM_BOT_TOKEN Bot token from @BotFather (e.g. 123456:ABC-DEF...)
* TELEGRAM_CHAT_ID Chat ID to send messages to (personal or group)
*
* How to set up:
* 1. Open Telegram, search @BotFather, send /newbot
* 2. Follow prompts to get your Bot Token
* 3. Start a chat with your bot (or add it to a group)
* 4. Get Chat ID: https://api.telegram.org/bot<TOKEN>/getUpdates
* 5. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in environment variables
*/
const TELEGRAM_API = "https://api.telegram.org";
export interface TelegramConfig {
botToken: string;
chatId: string;
}
/**
* Get Telegram config from environment variables or DB settings.
* Returns null if not configured.
*/
export async function getTelegramConfig(): Promise<TelegramConfig | null> {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (!botToken || !chatId) {
return null;
}
return { botToken, chatId };
}
/**
* Send a message via Telegram Bot API.
* Uses HTML parse mode for formatting.
*/
export async function sendTelegramMessage(
config: TelegramConfig,
message: string
): Promise<boolean> {
try {
const url = `${TELEGRAM_API}/bot${config.botToken}/sendMessage`;
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: config.chatId,
text: message,
parse_mode: "HTML",
disable_web_page_preview: true,
}),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
const err = await resp.text();
console.warn(`[Telegram] Send failed (${resp.status}): ${err}`);
return false;
}
console.log("[Telegram] Message sent successfully");
return true;
} catch (e) {
console.warn("[Telegram] Send error:", e);
return false;
}
}
/**
* Notify admin about a new TRC20 purchase.
*/
export async function notifyNewTRC20Purchase(purchase: {
txHash: string;
fromAddress: string;
usdtAmount: number;
xicAmount: number;
evmAddress: string | null;
}): Promise<void> {
const config = await getTelegramConfig();
if (!config) {
console.log("[Telegram] Not configured — skipping notification (set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)");
return;
}
const evmLine = purchase.evmAddress
? `\n🔑 <b>EVM Address:</b> <code>${purchase.evmAddress}</code>`
: "\n⚠ <b>EVM Address:</b> Not provided (manual distribution required)";
const txUrl = `https://tronscan.org/#/transaction/${purchase.txHash}`;
const message = [
"🟢 <b>New XIC Presale Purchase!</b>",
"",
`💰 <b>Amount:</b> $${purchase.usdtAmount.toFixed(2)} USDT`,
`🪙 <b>XIC Tokens:</b> ${purchase.xicAmount.toLocaleString()} XIC`,
`📍 <b>From (TRON):</b> <code>${purchase.fromAddress}</code>`,
evmLine,
`🔗 <b>TX:</b> <a href="${txUrl}">${purchase.txHash.slice(0, 16)}...</a>`,
"",
`${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
].join("\n");
await sendTelegramMessage(config, message);
}
/**
* Notify admin when a purchase is marked as distributed.
*/
export async function notifyDistributed(purchase: {
txHash: string;
evmAddress: string;
xicAmount: number;
distributeTxHash?: string | null;
}): Promise<void> {
const config = await getTelegramConfig();
if (!config) return;
const distTxLine = purchase.distributeTxHash
? `\n🔗 <b>Dist TX:</b> <code>${purchase.distributeTxHash}</code>`
: "";
const message = [
"✅ <b>XIC Tokens Distributed!</b>",
"",
`🪙 <b>XIC Tokens:</b> ${purchase.xicAmount.toLocaleString()} XIC`,
`📬 <b>To EVM:</b> <code>${purchase.evmAddress}</code>`,
distTxLine,
`🔗 <b>Original TX:</b> <code>${purchase.txHash.slice(0, 16)}...</code>`,
"",
`${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
].join("\n");
await sendTelegramMessage(config, message);
}
/**
* Test the Telegram connection with a test message.
* Returns { success, error } for admin UI feedback.
*/
export async function testTelegramConnection(
botToken: string,
chatId: string
): Promise<{ success: boolean; error?: string }> {
try {
const config: TelegramConfig = { botToken, chatId };
const message = [
"🔔 <b>NAC Presale — Telegram Notification Test</b>",
"",
"✅ Connection successful! You will receive purchase alerts here.",
"",
`${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
].join("\n");
const ok = await sendTelegramMessage(config, message);
if (!ok) return { success: false, error: "Failed to send message. Check Bot Token and Chat ID." };
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}

View File

@ -14,6 +14,7 @@ import { eq, sql } from "drizzle-orm";
import { getDb } from "./db";
import { trc20Purchases, trc20Intents } from "../drizzle/schema";
import { TOKEN_PRICE_USDT } from "./onchain";
import { notifyNewTRC20Purchase } from "./telegram";
const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp";
const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
@ -116,6 +117,19 @@ async function processTransaction(tx: TronTransaction): Promise<void> {
.where(eq(trc20Intents.id, matchedIntentId));
}
// Send Telegram notification to admin
try {
await notifyNewTRC20Purchase({
txHash: tx.transaction_id,
fromAddress: tx.from,
usdtAmount,
xicAmount,
evmAddress: matchedEvmAddress,
});
} catch (e) {
console.warn("[TRC20Monitor] Telegram notification failed:", e);
}
// Attempt auto-distribution via BSC
await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress);
}

12
todo.md
View File

@ -29,4 +29,16 @@
- [x] 浏览器测试验证完整购买流程BSC/ETH/TRON三网络
- [x] 测试管理员后台TRC20购买记录、EVM地址意图、分发工作流
- [x] 测试教程页面(多钱包、多网络、中英文切换)
- [x] 部署到备份服务器并同步代码库https://git.newassetchain.io/nacadmin/xic-presale
## v5 功能升级
- [x] 配置专用高可用RPC节点池BSC + ETH多节点故障转移
- [x] 添加TRC20购买Telegram通知新购买确认时自动推送
- [x] 管理员后台添加内容编辑功能(预售参数动态配置)
- [ ] 完整域名浏览器购买测试pre-sale.newassetchain.io
- [ ] 部署到备份服务器并同步代码库
## v5 钱包连接修复
- [x] 将useWallet()提升到Home顶层通过props传递给NavWalletButton和EVMPurchasePanel
- [x] 验证导航栏和购买面板钱包状态同步
- [ ] 完整域名浏览器购买测试验证