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:
parent
45e1f886aa
commit
40be4636e9
|
|
@ -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<TOKEN>/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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,13 @@
|
|||
"when": 1772950356383,
|
||||
"tag": "0003_volatile_firestar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1772955197567,
|
||||
"tag": "0004_parallel_unus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
12
todo.md
|
|
@ -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] 验证导航栏和购买面板钱包状态同步
|
||||
- [ ] 完整域名浏览器购买测试验证
|
||||
|
|
|
|||
Loading…
Reference in New Issue