fix(web): real on-chain trading + honest 5s K-line

- TradingTerminal: allowance precheck (approve only when needed), exact OT estimation via calMintCostByOtDelta aligned to tick, real revert reason decoding, SELL requires OT balance
- TradingViewChart: priceRef to avoid stale closure, no synthetic candles, 5s heartbeat with real marginal price
- point default market to real WTFMarketV2 (0x048E..9039) instead of controller proxy
This commit is contained in:
Bot
2026-08-31 02:41:10 +08:00
parent 13c44afc51
commit 7f3c6a3775
5 changed files with 343 additions and 163 deletions
+5 -5
View File
@@ -10,16 +10,16 @@ import { UserMarketWizard } from '../features/create/UserMarketWizard';
const DEFAULT_MARKET: MarketItem = { const DEFAULT_MARKET: MarketItem = {
id: 'm-1', id: 'm-1',
marketAddress: '0xc0E24E152771C588B21AEB654b30B1cBAf381c1a', marketAddress: '0x048E9a90C25ba2c4410425D282b19A472e076039',
title: 'Will Bitcoin break above $120,000 before end of Q4 2026?', title: 'Will Bitcoin break above $120,000 before end of Q4 2026?',
category: 'Crypto', category: 'Crypto',
tier: 1, tier: 1,
outcomes: [ outcomes: [
{ label: 'YES', price: 0.68, percentage: 68 }, { label: 'YES', price: 0.50, percentage: 50 },
{ label: 'NO', price: 0.32, percentage: 32 } { label: 'NO', price: 0.50, percentage: 50 }
], ],
poolLiquidity: 1250000, poolLiquidity: 1000,
volume24h: 384000, volume24h: 0,
endTime: '2026-12-31', endTime: '2026-12-31',
status: 'ACTIVE' status: 'ACTIVE'
}; };
+77 -42
View File
@@ -1,14 +1,19 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { createChart, ColorType, IChartApi, ISeriesApi, CandlestickSeries } from 'lightweight-charts'; import { createChart, ColorType, IChartApi, ISeriesApi, CandlestickSeries } from 'lightweight-charts';
import { Activity, RefreshCw } from 'lucide-react'; import { Activity } from 'lucide-react';
interface TradingViewChartProps { interface TradingViewChartProps {
marketAddress: string; marketAddress: string;
outcomeIndex: number; outcomeIndex: number;
outcomeLabel: string; outcomeLabel: string;
currentPrice: number; currentPrice: number; // 真实链上边际价 (WUSD/OT),<= 0 表示尚未取得真实价格
} }
const API_BASE = 'https://api.wtfx.app';
const BAR_SECONDS = 5;
const barTimeOfNow = () => Math.floor(Math.floor(Date.now() / 1000) / BAR_SECONDS) * BAR_SECONDS;
export const TradingViewChart: React.FC<TradingViewChartProps> = ({ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
marketAddress, marketAddress,
outcomeIndex, outcomeIndex,
@@ -18,12 +23,32 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
const chartContainerRef = useRef<HTMLDivElement>(null); const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null); const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null); const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
// 用 ref 保活最新真实价格,避免 5s 定时器闭包捕获到过期值
const priceRef = useRef<number>(currentPrice);
const lastTickRef = useRef<{ time: number; price: number } | null>(null);
const [timeframe, setTimeframe] = useState<string>('5s'); const [timeframe, setTimeframe] = useState<string>('5s');
// 同步最新真实价格到 ref(同时立即打点一次,保证首根柱出现)
useEffect(() => {
priceRef.current = currentPrice;
const series = seriesRef.current;
if (currentPrice > 0 && series) {
const barTime = barTimeOfNow();
series.update({
time: barTime as any,
open: currentPrice,
high: currentPrice,
low: currentPrice,
close: currentPrice,
});
lastTickRef.current = { time: barTime, price: currentPrice };
}
}, [currentPrice, outcomeIndex]);
useEffect(() => { useEffect(() => {
if (!chartContainerRef.current) return; if (!chartContainerRef.current) return;
// 1. 初始化 TradingView Lightweight Chart // 1. 初始化 TradingView 图表
const chart = createChart(chartContainerRef.current, { const chart = createChart(chartContainerRef.current, {
layout: { layout: {
background: { type: ColorType.Solid, color: '#090A0F' }, background: { type: ColorType.Solid, color: '#090A0F' },
@@ -54,10 +79,9 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
}, },
}); });
// 2. 添加专业蜡烛图 (Candlestick Series - GMGN / Pump.fun 风格)
const candlestickSeries = chart.addSeries(CandlestickSeries, { const candlestickSeries = chart.addSeries(CandlestickSeries, {
upColor: '#10B981', // 涨 (绿色) upColor: '#10B981',
downColor: '#F43F5E', // 跌 (红色) downColor: '#F43F5E',
borderVisible: false, borderVisible: false,
wickUpColor: '#10B981', wickUpColor: '#10B981',
wickDownColor: '#F43F5E', wickDownColor: '#F43F5E',
@@ -66,26 +90,48 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
chartRef.current = chart; chartRef.current = chart;
seriesRef.current = candlestickSeries; seriesRef.current = candlestickSeries;
// 3. 生成初始秒级 K 线柱子 // 2. 从后端 API 异步获取真实 K 线(Indexer 聚合链上成交)
const now = Math.floor(Date.now() / 1000); const loadRealKlines = async () => {
const initialData = []; try {
let p = currentPrice > 0 ? currentPrice : 0.50; const res = await fetch(
`${API_BASE}/api/v1/charts/klines?market_address=${marketAddress}&outcome_index=${outcomeIndex}&timeframe=${timeframe}`
);
if (res.ok) {
const data = await res.json();
if (data.bars && data.bars.length > 0) {
candlestickSeries.setData(data.bars);
chart.timeScale().fitContent();
const last = data.bars[data.bars.length - 1];
lastTickRef.current = { time: last.time, price: Number(last.close) };
return;
}
}
} catch (e) {
console.warn('Load klines failed:', e);
}
// 无历史成交:不画任何假柱,等 5s 心跳用真实边际价打点
};
for (let i = 80; i >= 0; i--) { loadRealKlines();
const t = (now - i * 5) as any;
const noise = (Math.sin(i * 0.3) * 0.02) + ((Math.random() - 0.5) * 0.015);
const open = Math.max(0.01, Math.min(0.99, p + noise));
const close = Math.max(0.01, Math.min(0.99, open + ((Math.random() - 0.48) * 0.02)));
const high = Math.max(open, close) + Math.random() * 0.01;
const low = Math.min(open, close) - Math.random() * 0.01;
initialData.push({ time: t, open, high, low, close });
p = close;
}
candlestickSeries.setData(initialData); // 3. 每 5 秒用最新真实边际价打点(价格未变化则不重复写入,绝不伪造曲线)
chart.timeScale().fitContent(); const interval = setInterval(() => {
const p = priceRef.current;
const series = seriesRef.current;
if (p <= 0 || !series) return;
const barTime = barTimeOfNow();
const last = lastTickRef.current;
if (last && last.time === barTime && last.price === p) return;
series.update({
time: barTime as any,
open: p,
high: p,
low: p,
close: p,
});
lastTickRef.current = { time: barTime, price: p };
}, BAR_SECONDS * 1000);
// 4. 自适应窗口大小
const handleResize = () => { const handleResize = () => {
if (chartContainerRef.current && chartRef.current) { if (chartContainerRef.current && chartRef.current) {
chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth }); chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth });
@@ -94,24 +140,13 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => { return () => {
clearInterval(interval);
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
chart.remove(); chart.remove();
chartRef.current = null;
seriesRef.current = null;
}; };
}, [marketAddress, outcomeIndex]); }, [marketAddress, outcomeIndex, timeframe]);
// 5. 实时价格脉冲更新
useEffect(() => {
if (seriesRef.current && currentPrice > 0) {
const now = Math.floor(Date.now() / 1000) as any;
seriesRef.current.update({
time: now,
open: currentPrice,
high: currentPrice * 1.01,
low: currentPrice * 0.99,
close: currentPrice,
});
}
}, [currentPrice]);
return ( return (
<div className="bg-[#0f111d] border border-slate-800 rounded-3xl p-5 shadow-2xl space-y-4"> <div className="bg-[#0f111d] border border-slate-800 rounded-3xl p-5 shadow-2xl space-y-4">
@@ -124,16 +159,16 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="font-extrabold text-sm text-white">{outcomeLabel} K 线</span> <span className="font-extrabold text-sm text-white">{outcomeLabel} K 线</span>
<span className="font-mono text-xs font-bold px-2 py-0.5 rounded bg-emerald-950/60 text-emerald-400 border border-emerald-800/50"> <span className="font-mono text-xs font-bold px-2 py-0.5 rounded bg-emerald-950/60 text-emerald-400 border border-emerald-800/50">
${currentPrice.toFixed(3)} {currentPrice > 0 ? `$${currentPrice.toFixed(3)}` : '--'}
</span> </span>
</div> </div>
<span className="text-[11px] text-slate-400">TradingView </span> <span className="text-[11px] text-slate-400">51 &bull; &bull; </span>
</div> </div>
</div> </div>
{/* Timeframes */} {/* Timeframes */}
<div className="flex space-x-1 bg-slate-950 p-1 rounded-xl border border-slate-800 text-xs font-mono"> <div className="flex space-x-1 bg-slate-950 p-1 rounded-xl border border-slate-800 text-xs font-mono">
{['1s', '5s', '1m', '15m', '1h'].map((tf) => ( {['5s', '1m', '15m', '1h'].map((tf) => (
<button <button
key={tf} key={tf}
onClick={() => setTimeframe(tf)} onClick={() => setTimeframe(tf)}
@@ -149,7 +184,7 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
</div> </div>
</div> </div>
{/* TradingView Chart Container */} {/* Chart Container */}
<div ref={chartContainerRef} className="w-full rounded-2xl overflow-hidden" /> <div ref={chartContainerRef} className="w-full rounded-2xl overflow-hidden" />
</div> </div>
); );
@@ -17,48 +17,18 @@ export interface MarketItem {
const MOCK_FEATURED_MARKETS: MarketItem[] = [ const MOCK_FEATURED_MARKETS: MarketItem[] = [
{ {
id: 'm-1', id: 'm-1',
marketAddress: '0xc0E24E152771C588B21AEB654b30B1cBAf381c1a', marketAddress: '0x048E9a90C25ba2c4410425D282b19A472e076039',
title: 'Will Bitcoin break above $120,000 before end of Q4 2026?', title: 'Will Bitcoin break above $120,000 before end of Q4 2026?',
category: 'Crypto', category: 'Crypto',
tier: 1, tier: 1,
outcomes: [ outcomes: [
{ label: 'YES', price: 0.68, percentage: 68 }, { label: 'YES', price: 0.50, percentage: 50 },
{ label: 'NO', price: 0.32, percentage: 32 } { label: 'NO', price: 0.50, percentage: 50 }
], ],
poolLiquidity: 1250000, poolLiquidity: 1000,
volume24h: 384000, volume24h: 0,
endTime: '2026-12-31', endTime: '2026-12-31',
status: 'ACTIVE' status: 'ACTIVE'
},
{
id: 'm-2',
marketAddress: '0x2Aeca49669e9E4E6CdCe46A1bc9bf136765A4f75',
title: 'Ethereum Layer-2 Total TVL to reach $50 Billion in 2026?',
category: 'Crypto',
tier: 2,
outcomes: [
{ label: 'YES', price: 0.81, percentage: 81 },
{ label: 'NO', price: 0.19, percentage: 19 }
],
poolLiquidity: 890000,
volume24h: 192000,
endTime: '2026-11-30',
status: 'ACTIVE'
},
{
id: 'm-3',
marketAddress: '0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1',
title: 'US Federal Reserve Interest Rate cut > 50 bps in next FOMC?',
category: 'Macro',
tier: 1,
outcomes: [
{ label: 'YES (>50bps)', price: 0.35, percentage: 35 },
{ label: 'NO (<=50bps)', price: 0.65, percentage: 65 }
],
poolLiquidity: 620000,
volume24h: 98000,
endTime: '2026-09-30',
status: 'ACTIVE'
} }
]; ];
+254 -80
View File
@@ -1,59 +1,163 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useWeb3 } from '../../context/Web3Context'; import { useWeb3 } from '../../context/Web3Context';
import { MarketItem } from '../explore/MarketExplore'; import { MarketItem } from '../explore/MarketExplore';
import { DEPLOYMENTS, ABIS } from '@wtfx/contracts'; import { DEPLOYMENTS, ABIS } from '@wtfx/contracts';
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import { TradingViewChart } from '../../components/TradingViewChart'; import { TradingViewChart } from '../../components/TradingViewChart';
import { TrendingUp, ArrowDownUp, Check, AlertCircle, Loader2, Sparkles, Activity, ShieldCheck, Zap } from 'lucide-react'; import { TrendingUp, Check, AlertCircle, Loader2, Zap } from 'lucide-react';
interface TradingTerminalProps { interface TradingTerminalProps {
market: MarketItem; market: MarketItem;
} }
// PowerLDACurveV2 读取接口(前端只读,用于精确估算)
const CURVE_ABI = [
"function calMarginalPrice(address market, uint256 tokenId) view returns (uint256 price)",
"function calMintCostByOtDelta(address market, uint256 tokenId, uint256 otDelta, bytes data) view returns (uint256 collateralFromUser, uint256 collateralToTreasury)",
"function calRedeemValueByOtDelta(address market, uint256 tokenId, uint256 otDelta, bytes data) view returns (uint256 collateralToUser, uint256 collateralToTreasury)",
"function readMarketState(address market, uint256 tokenId) view returns (tuple(address redeem, uint256 premium, uint256 feeRate, uint256 otCurrent, uint256 tick))",
];
// 精确到小数点后 6 位的格式化(显示足够、避免浮点噪声)
const fmt = (wei: bigint) => ethers.formatUnits(wei, 18).slice(0, 18).replace(/(\.\d{6})\d+/, '$1').replace(/\.?0+$/, '') || '0';
export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => { export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
const { account, signer, provider, getCollateralContract, wusdBalance, vaultAddress, isSessionActive, refreshBalance, isCorrectNetwork } = useWeb3(); const { account, signer, provider, getCollateralContract, wusdBalance, refreshBalance, isCorrectNetwork } = useWeb3();
const [selectedOutcomeIndex, setSelectedOutcomeIndex] = useState<number>(0); const [selectedOutcomeIndex, setSelectedOutcomeIndex] = useState<number>(0);
const [tradeSide, setTradeSide] = useState<'BUY' | 'SELL'>('BUY'); const [tradeSide, setTradeSide] = useState<'BUY' | 'SELL'>('BUY');
const [amount, setAmount] = useState<string>('100'); const [amount, setAmount] = useState<string>('100');
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [dynamicPrice, setDynamicPrice] = useState<number>(market.outcomes[0]?.price || 0.50); const [marginalPrices, setMarginalPrices] = useState<number[]>([]);
const [dynamicPrice, setDynamicPrice] = useState<number>(0);
const [otBalance, setOtBalance] = useState<string>('0');
const [preview, setPreview] = useState<{ kind: 'buy' | 'sell' | 'error'; ot: string; cost: string; fee: string } | null>(null);
const curveAddress = DEPLOYMENTS.robinhoodTestnet.contracts.powerLDACurve;
const tokenId = 1n << BigInt(selectedOutcomeIndex);
const activeOutcome = market.outcomes[selectedOutcomeIndex] || { label: `选项 ${selectedOutcomeIndex}`, price: dynamicPrice, percentage: 50 }; const activeOutcome = market.outcomes[selectedOutcomeIndex] || { label: `选项 ${selectedOutcomeIndex}`, price: dynamicPrice, percentage: 50 };
// 1. 读取 PowerLDACurveV2 链上实时边际计价 (calMarginalPrice) const getCurveContract = useCallback(
const fetchLivePrice = async () => { (runner: ethers.Provider | ethers.Signer) => new ethers.Contract(curveAddress, CURVE_ABI, runner),
[curveAddress]
);
// 1. 读取链上真实边际计价 (calMarginalPrice),覆盖全部结果选项
const fetchLivePrice = useCallback(async () => {
if (!provider || !market.marketAddress) return; if (!provider || !market.marketAddress) return;
try { try {
const curveAddr = DEPLOYMENTS.robinhoodTestnet.contracts.powerLDACurve; const curve = getCurveContract(provider);
const curveAbi = [ const prices: number[] = [];
"function calMarginalPrice(address market, uint256 tokenId) external view returns (uint256 price)" for (let i = 0; i < market.outcomes.length; i++) {
]; const tid = 1n << BigInt(i);
const curveContract = new ethers.Contract(curveAddr, curveAbi, provider); const priceWei = await curve.calMarginalPrice(market.marketAddress, tid);
// tokenId = 2 ** outcomeIndex prices[i] = priceWei > 0n ? parseFloat(ethers.formatUnits(priceWei, 18)) : 0;
const tokenId = 1n << BigInt(selectedOutcomeIndex); }
const priceWei = await curveContract.calMarginalPrice(market.marketAddress, tokenId); setMarginalPrices(prices);
const priceNum = parseFloat(ethers.formatUnits(priceWei, 18)); if (prices[selectedOutcomeIndex] && prices[selectedOutcomeIndex] > 0) {
if (priceNum > 0) { setDynamicPrice(prices[selectedOutcomeIndex]);
setDynamicPrice(priceNum);
} }
} catch (e) { } catch (e) {
console.warn('Fallback to market default price:', e); console.warn('Fetch live price failed:', e);
} }
}; }, [provider, market.marketAddress, market.outcomes.length, selectedOutcomeIndex, getCurveContract]);
// 2. 读取用户持有的该结果 OT 份额 (ERC6909)
const fetchOtBalance = useCallback(async () => {
if ((!provider && !signer) || !account || !market.marketAddress) return;
try {
const marketContract = new ethers.Contract(market.marketAddress, ABIS.WTFMarketV2, signer || provider);
const bal = await marketContract.balanceOf(account, tokenId);
setOtBalance(ethers.formatUnits(bal, 18));
} catch (e) {
console.warn('Fetch OT balance failed:', e);
}
}, [provider, signer, account, market.marketAddress, tokenId]);
useEffect(() => { useEffect(() => {
fetchLivePrice(); fetchLivePrice();
fetchOtBalance();
const timer = setInterval(fetchLivePrice, 4000); const timer = setInterval(fetchLivePrice, 4000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [market.marketAddress, selectedOutcomeIndex, provider]); }, [market.marketAddress, selectedOutcomeIndex, provider, fetchLivePrice, fetchOtBalance]);
const estShares = amount && Number(amount) > 0 ? (Number(amount) / (dynamicPrice || 0.5)).toFixed(2) : '0'; // 3. 实时精确预估:BUY = 用预算二分对齐 tick 求最大可得 OT;SELL = 输入 OT 对齐 tick 求赎回值
const estPayout = (Number(estShares) * 1.0).toFixed(2); useEffect(() => {
const estReturn = amount && Number(amount) > 0 ? (((Number(estPayout) - Number(amount)) / Number(amount)) * 100).toFixed(1) : '0'; let cancelled = false;
const timer = setTimeout(async () => {
if (!provider || !market.marketAddress || !amount || Number(amount) <= 0) {
setPreview(null);
return;
}
try {
const curve = getCurveContract(provider);
const tid = 1n << BigInt(selectedOutcomeIndex);
const state = await curve.readMarketState(market.marketAddress, tid);
const tick = BigInt(state.tick) > 0n ? BigInt(state.tick) : 1n;
const priceWei = await curve.calMarginalPrice(market.marketAddress, tid);
const budgetWei = ethers.parseUnits(amount, 18);
// 2. 真金白银链上下单 / 0 弹窗极速交易 if (tradeSide === 'BUY') {
if (priceWei <= 0n) {
if (!cancelled) setPreview({ kind: 'error', ot: '', cost: '0', fee: '0' });
return;
}
// 估算 OT = 预算 / 边际价,向下对齐 tick
let otWei = (budgetWei * 10n ** 18n) / priceWei;
otWei = (otWei / tick) * tick;
if (otWei <= 0n) {
if (!cancelled) setPreview({ kind: 'error', ot: '', cost: '0', fee: '0' });
return;
}
let [cost, fee] = await curve.calMintCostByOtDelta(market.marketAddress, tid, otWei, "0x");
if (cost > budgetWei && otWei > tick) {
otWei -= tick;
[cost, fee] = await curve.calMintCostByOtDelta(market.marketAddress, tid, otWei, "0x");
}
if (!cancelled) setPreview({ kind: 'buy', ot: otWei.toString(), cost: cost.toString(), fee: fee.toString() });
} else {
const otWei = ethers.parseUnits(amount, 18);
if (otWei <= 0n || otWei % tick !== 0n) {
if (!cancelled) setPreview({ kind: 'error', ot: '', cost: '0', fee: '0' });
return;
}
const [toUser, fee] = await curve.calRedeemValueByOtDelta(market.marketAddress, tid, otWei, "0x");
if (!cancelled) setPreview({ kind: 'sell', ot: otWei.toString(), cost: toUser.toString(), fee: fee.toString() });
}
} catch (e) {
if (!cancelled) setPreview(null);
}
}, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [amount, tradeSide, selectedOutcomeIndex, market.marketAddress, provider, getCurveContract, marginalPrices]);
// 4. 解码真实 revert 原因(自定义错误优先)
const describeError = (err: any): string => {
if (!err) return '未知错误';
if (err.reason) return `链上拒绝:${err.reason}`;
if (err.shortMessage) return err.shortMessage;
if (err.data) {
try {
const marketContract = new ethers.Contract(market.marketAddress, ABIS.WTFMarketV2, signer || provider);
const parsed = marketContract.interface.parseError(err.data);
if (parsed) {
const args = parsed.args.map((a: any) => (typeof a === 'bigint' ? a.toString() : String(a))).join(', ');
return `链上拒绝:${parsed.name}(${args})`;
}
} catch {
/* ignore decode failure */
}
}
const msg = err.message || String(err);
return msg.split('\n')[0];
};
// 5. 真金白银链上下单
const handleTrade = async (e: React.FormEvent) => { const handleTrade = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!signer || !account) { if (!signer || !account) {
@@ -72,56 +176,86 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
const collateral = getCollateralContract(); const collateral = getCollateralContract();
if (!collateral) throw new Error('Collateral contract not loaded'); if (!collateral) throw new Error('Collateral contract not loaded');
const amountWei = ethers.parseUnits(amount, 18);
const tokenId = 1n << BigInt(selectedOutcomeIndex);
const otDeltaOut = ethers.parseUnits(estShares, 18);
// WTFMarketV2 合约实例
const marketContract = new ethers.Contract(market.marketAddress, ABIS.WTFMarketV2, signer); const marketContract = new ethers.Contract(market.marketAddress, ABIS.WTFMarketV2, signer);
const curve = getCurveContract(signer);
const tid = 1n << BigInt(selectedOutcomeIndex);
const state = await curve.readMarketState(market.marketAddress, tid);
const tick = BigInt(state.tick) > 0n ? BigInt(state.tick) : 1n;
if (tradeSide === 'BUY') { if (tradeSide === 'BUY') {
// Step 1: Approve Collateral to Market const budgetWei = ethers.parseUnits(amount, 18);
setStatusMsg({ type: 'success', text: '步骤 1/2: 正在授权 WUSD 扣款...' }); const priceWei = await curve.calMarginalPrice(market.marketAddress, tid);
const appTx = await collateral.approve(market.marketAddress, ethers.MaxUint256); if (priceWei <= 0n) throw new Error('边际价格不可用,请稍后重试');
await appTx.wait();
let otWei = (budgetWei * 10n ** 18n) / priceWei;
otWei = (otWei / tick) * tick;
if (otWei <= 0n) {
const minCost = await curve.calMintCostByOtDelta(market.marketAddress, tid, tick, "0x");
throw new Error(`金额过小:单笔最小为 1 tick(约 ${fmt(minCost[0])} WUSD`);
}
let [cost, fee] = await curve.calMintCostByOtDelta(market.marketAddress, tid, otWei, "0x");
if (cost > budgetWei && otWei > tick) {
otWei -= tick;
[cost, fee] = await curve.calMintCostByOtDelta(market.marketAddress, tid, otWei, "0x");
}
if (cost > budgetWei) throw new Error('金额不足以覆盖最小 tick 成本');
// Step 1: 仅当授权不足时才触发 approve(避免每次都弹授权)
const allowance = await collateral.allowance(account, market.marketAddress);
if (allowance < cost) {
setStatusMsg({ type: 'success', text: '步骤 1/2: 请在钱包确认授权 WUSD 给市场合约(仅首次需要)' });
const appTx = await collateral.approve(market.marketAddress, ethers.MaxUint256);
await appTx.wait();
}
// Step 2: 真实调用 WTFMarketV2.mintCollateralToExactOt() // Step 2: 真实调用 WTFMarketV2.mintCollateralToExactOt()
setStatusMsg({ type: 'success', text: `步骤 2/2: 正在链上买入 ${estShares}${activeOutcome.label} 预测代币...` }); setStatusMsg({
const tx = await marketContract.mintCollateralToExactOt( type: 'success',
account, text: `步骤 2/2: 链上买入 ${fmt(otWei)}${activeOutcome.label},成本约 ${fmt(cost)} WUSD...`,
tokenId, });
otDeltaOut, const tx = await marketContract.mintCollateralToExactOt(account, tid, otWei, "0x");
"0x" // swapData
);
const receipt = await tx.wait(); const receipt = await tx.wait();
setStatusMsg({ setStatusMsg({
type: 'success', type: 'success',
text: `🎉 真实链上下单成功!已通过 Power LDA 曲线买入 ${estShares},哈希: ${receipt.hash.slice(0, 10)}...` text: `买入成功:${fmt(otWei)}${activeOutcome.label}(成本 ${fmt(cost)} WUSD,手续费 ${fmt(fee)},哈希 ${receipt.hash.slice(0, 10)}...`,
}); });
} else { } else {
// 卖出 / 赎回 // 卖出 = 精确赎回 OT -> WUSD
setStatusMsg({ type: 'success', text: '正在赎回预测代币并结转 WUSD 保证金...' }); const otWei = ethers.parseUnits(amount, 18);
const tx = await marketContract.redeemExactOtToCollateral( if (otWei <= 0n || otWei % tick !== 0n) {
account, throw new Error(`卖出数量必须为 tick${tick.toString()})的整数倍`);
tokenId, }
otDeltaOut, const bal = await marketContract.balanceOf(account, tid);
"0x" if (bal < otWei) {
); throw new Error(`OT 余额不足:当前持有 ${fmt(bal)} 份,需卖出 ${fmt(otWei)}`);
await tx.wait(); }
setStatusMsg({ type: 'success', text: `🎉 成功卖出/赎回 ${estShares} 份预测代币!` }); const [toUser, fee] = await curve.calRedeemValueByOtDelta(market.marketAddress, tid, otWei, "0x");
setStatusMsg({
type: 'success',
text: `链上卖出 ${fmt(otWei)}${activeOutcome.label},预估到账 ${fmt(toUser)} WUSD...`,
});
const tx = await marketContract.redeemExactOtToCollateral(account, tid, otWei, "0x");
const receipt = await tx.wait();
setStatusMsg({
type: 'success',
text: `卖出成功:${fmt(otWei)} 份 → 到账 ${fmt(toUser)} WUSD(手续费 ${fmt(fee)}),哈希 ${receipt.hash.slice(0, 10)}...`,
});
} }
// 刷新余额与实时曲线价格 // 刷新余额与链上价格
refreshBalance(); refreshBalance();
fetchLivePrice(); fetchLivePrice();
fetchOtBalance();
} catch (err: any) { } catch (err: any) {
console.error('Trade execution error:', err); console.error('Trade execution error:', err);
setStatusMsg({ type: 'error', text: err.reason || err.message || '交易上链失败,请检查资金与网络' }); setStatusMsg({ type: 'error', text: describeError(err) });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const isBuy = tradeSide === 'BUY';
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧 2 列:市场头部 + TradingView 级 K 线图表 */} {/* 左侧 2 列:市场头部 + TradingView 级 K 线图表 */}
@@ -151,8 +285,10 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
<span className="text-sm font-bold font-mono text-cyan-400">${market.poolLiquidity.toLocaleString()}</span> <span className="text-sm font-bold font-mono text-cyan-400">${market.poolLiquidity.toLocaleString()}</span>
</div> </div>
<div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3"> <div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3">
<span className="text-[11px] text-slate-400 block"></span> <span className="text-[11px] text-slate-400 block"> (WUSD/OT)</span>
<span className="text-sm font-bold font-mono text-emerald-400">${dynamicPrice.toFixed(3)}</span> <span className="text-sm font-bold font-mono text-emerald-400">
{dynamicPrice > 0 ? `$${dynamicPrice.toFixed(3)}` : '--'}
</span>
</div> </div>
<div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3"> <div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3">
<span className="text-[11px] text-slate-400 block"></span> <span className="text-[11px] text-slate-400 block"></span>
@@ -161,7 +297,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
</div> </div>
</div> </div>
{/* TradingView 专业图表组件 (GMGN / Pump.fun 风格) */} {/* TradingView 专业图表组件 */}
<TradingViewChart <TradingViewChart
marketAddress={market.marketAddress} marketAddress={market.marketAddress}
outcomeIndex={selectedOutcomeIndex} outcomeIndex={selectedOutcomeIndex}
@@ -170,7 +306,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
/> />
</div> </div>
{/* 右侧:极速下单与非托管金库连击 */} {/* 右侧:极速下单面板 */}
<div className="bg-[#0f111d] border border-slate-800 rounded-3xl p-6 sm:p-7 shadow-xl space-y-6 flex flex-col justify-between"> <div className="bg-[#0f111d] border border-slate-800 rounded-3xl p-6 sm:p-7 shadow-xl space-y-6 flex flex-col justify-between">
<form onSubmit={handleTrade} className="space-y-5"> <form onSubmit={handleTrade} className="space-y-5">
<div className="border-b border-slate-800 pb-4 flex items-center justify-between"> <div className="border-b border-slate-800 pb-4 flex items-center justify-between">
@@ -185,7 +321,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
type="button" type="button"
onClick={() => setTradeSide('BUY')} onClick={() => setTradeSide('BUY')}
className={`px-3 py-1 text-xs font-bold rounded-lg transition-all ${ className={`px-3 py-1 text-xs font-bold rounded-lg transition-all ${
tradeSide === 'BUY' ? 'bg-cyan-600 text-white' : 'text-slate-400 hover:text-slate-200' isBuy ? 'bg-cyan-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`} }`}
> >
(Buy) (Buy)
@@ -194,7 +330,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
type="button" type="button"
onClick={() => setTradeSide('SELL')} onClick={() => setTradeSide('SELL')}
className={`px-3 py-1 text-xs font-bold rounded-lg transition-all ${ className={`px-3 py-1 text-xs font-bold rounded-lg transition-all ${
tradeSide === 'SELL' ? 'bg-rose-600 text-white' : 'text-slate-400 hover:text-slate-200' !isBuy ? 'bg-rose-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`} }`}
> >
(Sell) (Sell)
@@ -219,7 +355,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
> >
<div className="font-extrabold text-xs truncate">{outcome.label}</div> <div className="font-extrabold text-xs truncate">{outcome.label}</div>
<div className="text-[11px] font-mono mt-1 text-cyan-400 font-bold"> <div className="text-[11px] font-mono mt-1 text-cyan-400 font-bold">
${selectedOutcomeIndex === idx ? dynamicPrice.toFixed(3) : outcome.price.toFixed(2)} {marginalPrices[idx] > 0 ? `$${marginalPrices[idx].toFixed(3)}` : '--'}
</div> </div>
</button> </button>
))} ))}
@@ -229,24 +365,34 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
{/* 交易金额输入 */} {/* 交易金额输入 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="font-semibold text-slate-300"> (WUSD)</span> <span className="font-semibold text-slate-300">
<span className="text-slate-400 font-mono">: {Number(wusdBalance).toFixed(2)} WUSD</span> {isBuy ? '买入金额 (WUSD)' : '卖出份额 (OT)'}
</span>
<span className="text-slate-400 font-mono">
{isBuy
? `余额: ${Number(wusdBalance).toFixed(2)} WUSD`
: `持有: ${Number(otBalance).toFixed(4)} OT`}
</span>
</div> </div>
<div className="relative"> <div className="relative">
<input <input
type="number" type="number"
required required
min="0"
step={isBuy ? 'any' : '1'}
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
placeholder="100" placeholder={isBuy ? '100' : '100'}
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 text-base text-slate-100 font-mono focus:outline-none focus:border-cyan-500" className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 text-base text-slate-100 font-mono focus:outline-none focus:border-cyan-500"
/> />
<div className="absolute right-3 top-3 flex space-x-1"> <div className="absolute right-3 top-3 flex space-x-1">
{['50', '100', '500', 'MAX'].map((preset) => ( {['50', '100', '500', isBuy ? 'MAX' : 'ALL'].map((preset) => (
<button <button
key={preset} key={preset}
type="button" type="button"
onClick={() => setAmount(preset === 'MAX' ? wusdBalance : preset)} onClick={() =>
setAmount(preset === 'MAX' ? wusdBalance : preset === 'ALL' ? otBalance : preset)
}
className="px-2 py-0.5 text-[10px] font-bold rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 font-mono" className="px-2 py-0.5 text-[10px] font-bold rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 font-mono"
> >
{preset} {preset}
@@ -256,27 +402,55 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
</div> </div>
</div> </div>
{/* 实时预估 */} {/* 实时预估(基于链上曲线精确计算) */}
<div className="bg-slate-950 border border-slate-800 rounded-2xl p-4 space-y-2.5 text-xs"> <div className="bg-slate-950 border border-slate-800 rounded-2xl p-4 space-y-2.5 text-xs">
<div className="flex justify-between text-slate-400"> {preview && preview.kind === 'buy' && (
<span></span> <>
<span className="font-mono font-bold text-slate-200">{estShares} </span> <div className="flex justify-between text-slate-400">
</div> <span></span>
<div className="flex justify-between text-slate-400"> <span className="font-mono font-bold text-slate-200">{fmt(BigInt(preview.ot))} OT</span>
<span></span> </div>
<span className="font-mono font-bold text-emerald-400">${estPayout} WUSD</span> <div className="flex justify-between text-slate-400">
</div> <span></span>
<div className="flex justify-between text-slate-400"> <span className="font-mono font-bold text-emerald-400">{fmt(BigInt(preview.cost))} WUSD</span>
<span> (ROI)</span> </div>
<span className="font-mono font-bold text-cyan-400">+{estReturn}%</span> <div className="flex justify-between text-slate-400">
</div> <span></span>
<span className="font-mono font-bold text-amber-400">{fmt(BigInt(preview.fee))} WUSD</span>
</div>
</>
)}
{preview && preview.kind === 'sell' && (
<>
<div className="flex justify-between text-slate-400">
<span></span>
<span className="font-mono font-bold text-slate-200">{fmt(BigInt(preview.ot))} OT</span>
</div>
<div className="flex justify-between text-slate-400">
<span></span>
<span className="font-mono font-bold text-emerald-400">{fmt(BigInt(preview.cost))} WUSD</span>
</div>
<div className="flex justify-between text-slate-400">
<span></span>
<span className="font-mono font-bold text-amber-400">{fmt(BigInt(preview.fee))} WUSD</span>
</div>
</>
)}
{preview && preview.kind === 'error' && (
<div className="text-rose-400 font-bold"> tick </div>
)}
{!preview && <div className="text-slate-500">线...</div>}
</div> </div>
{/* 下单按钮 */} {/* 下单按钮 */}
<button <button
type="submit" type="submit"
disabled={loading || !amount || Number(amount) <= 0} disabled={loading || !amount || Number(amount) <= 0}
className="w-full py-3.5 bg-gradient-to-r from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 text-white font-bold rounded-xl shadow-lg shadow-cyan-500/25 flex items-center justify-center space-x-2 transition-all disabled:opacity-50" className={`w-full py-3.5 bg-gradient-to-r text-white font-bold rounded-xl shadow-lg flex items-center justify-center space-x-2 transition-all disabled:opacity-50 ${
isBuy
? 'from-cyan-500 to-indigo-600 hover:from-cyan-400 hover:to-indigo-500 shadow-cyan-500/25'
: 'from-rose-500 to-orange-600 hover:from-rose-400 hover:to-orange-500 shadow-rose-500/25'
}`}
> >
{loading ? ( {loading ? (
<> <>
@@ -286,7 +460,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
) : ( ) : (
<> <>
<Zap className="w-5 h-5 text-amber-300" /> <Zap className="w-5 h-5 text-amber-300" />
<span> {activeOutcome.label}</span> <span>{isBuy ? `立即买入 ${activeOutcome.label}` : `立即卖出 ${activeOutcome.label}`}</span>
</> </>
)} )}
</button> </button>
+2 -1
View File
@@ -8,7 +8,8 @@
"powerLDACurve": "0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1", "powerLDACurve": "0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1",
"controllerImplementation": "0x2Aeca49669e9E4E6CdCe46A1bc9bf136765A4f75", "controllerImplementation": "0x2Aeca49669e9E4E6CdCe46A1bc9bf136765A4f75",
"controllerProxy": "0xc0E24E152771C588B21AEB654b30B1cBAf381c1a", "controllerProxy": "0xc0E24E152771C588B21AEB654b30B1cBAf381c1a",
"vaultFactory": "0x89401e07296267c01017cA150D5Ec8883a78e0B2" "vaultFactory": "0x89401e07296267c01017cA150D5Ec8883a78e0B2",
"defaultMarket": "0x048E9a90C25ba2c4410425D282b19A472e076039"
}, },
"governance": { "governance": {
"admin": "0x6cddF384792C77219fc25C454Cfe264757842830", "admin": "0x6cddF384792C77219fc25C454Cfe264757842830",