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 = {
id: 'm-1',
marketAddress: '0xc0E24E152771C588B21AEB654b30B1cBAf381c1a',
marketAddress: '0x048E9a90C25ba2c4410425D282b19A472e076039',
title: 'Will Bitcoin break above $120,000 before end of Q4 2026?',
category: 'Crypto',
tier: 1,
outcomes: [
{ label: 'YES', price: 0.68, percentage: 68 },
{ label: 'NO', price: 0.32, percentage: 32 }
{ label: 'YES', price: 0.50, percentage: 50 },
{ label: 'NO', price: 0.50, percentage: 50 }
],
poolLiquidity: 1250000,
volume24h: 384000,
poolLiquidity: 1000,
volume24h: 0,
endTime: '2026-12-31',
status: 'ACTIVE'
};
+77 -42
View File
@@ -1,14 +1,19 @@
import React, { useEffect, useRef, useState } from 'react';
import { createChart, ColorType, IChartApi, ISeriesApi, CandlestickSeries } from 'lightweight-charts';
import { Activity, RefreshCw } from 'lucide-react';
import { Activity } from 'lucide-react';
interface TradingViewChartProps {
marketAddress: string;
outcomeIndex: number;
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> = ({
marketAddress,
outcomeIndex,
@@ -18,12 +23,32 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | 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');
// 同步最新真实价格到 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(() => {
if (!chartContainerRef.current) return;
// 1. 初始化 TradingView Lightweight Chart
// 1. 初始化 TradingView 图表
const chart = createChart(chartContainerRef.current, {
layout: {
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, {
upColor: '#10B981', // 涨 (绿色)
downColor: '#F43F5E', // 跌 (红色)
upColor: '#10B981',
downColor: '#F43F5E',
borderVisible: false,
wickUpColor: '#10B981',
wickDownColor: '#F43F5E',
@@ -66,26 +90,48 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
chartRef.current = chart;
seriesRef.current = candlestickSeries;
// 3. 生成初始秒级 K 线柱子
const now = Math.floor(Date.now() / 1000);
const initialData = [];
let p = currentPrice > 0 ? currentPrice : 0.50;
// 2. 从后端 API 异步获取真实 K 线(Indexer 聚合链上成交)
const loadRealKlines = async () => {
try {
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--) {
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;
}
loadRealKlines();
candlestickSeries.setData(initialData);
chart.timeScale().fitContent();
// 3. 每 5 秒用最新真实边际价打点(价格未变化则不重复写入,绝不伪造曲线)
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 = () => {
if (chartContainerRef.current && chartRef.current) {
chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth });
@@ -94,24 +140,13 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
window.addEventListener('resize', handleResize);
return () => {
clearInterval(interval);
window.removeEventListener('resize', handleResize);
chart.remove();
chartRef.current = null;
seriesRef.current = null;
};
}, [marketAddress, outcomeIndex]);
// 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]);
}, [marketAddress, outcomeIndex, timeframe]);
return (
<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">
<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">
${currentPrice.toFixed(3)}
{currentPrice > 0 ? `$${currentPrice.toFixed(3)}` : '--'}
</span>
</div>
<span className="text-[11px] text-slate-400">TradingView </span>
<span className="text-[11px] text-slate-400">51 &bull; &bull; </span>
</div>
</div>
{/* Timeframes */}
<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
key={tf}
onClick={() => setTimeframe(tf)}
@@ -149,7 +184,7 @@ export const TradingViewChart: React.FC<TradingViewChartProps> = ({
</div>
</div>
{/* TradingView Chart Container */}
{/* Chart Container */}
<div ref={chartContainerRef} className="w-full rounded-2xl overflow-hidden" />
</div>
);
@@ -17,48 +17,18 @@ export interface MarketItem {
const MOCK_FEATURED_MARKETS: MarketItem[] = [
{
id: 'm-1',
marketAddress: '0xc0E24E152771C588B21AEB654b30B1cBAf381c1a',
marketAddress: '0x048E9a90C25ba2c4410425D282b19A472e076039',
title: 'Will Bitcoin break above $120,000 before end of Q4 2026?',
category: 'Crypto',
tier: 1,
outcomes: [
{ label: 'YES', price: 0.68, percentage: 68 },
{ label: 'NO', price: 0.32, percentage: 32 }
{ label: 'YES', price: 0.50, percentage: 50 },
{ label: 'NO', price: 0.50, percentage: 50 }
],
poolLiquidity: 1250000,
volume24h: 384000,
poolLiquidity: 1000,
volume24h: 0,
endTime: '2026-12-31',
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 { MarketItem } from '../explore/MarketExplore';
import { DEPLOYMENTS, ABIS } from '@wtfx/contracts';
import { ethers } from 'ethers';
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 {
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 }) => {
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 [tradeSide, setTradeSide] = useState<'BUY' | 'SELL'>('BUY');
const [amount, setAmount] = useState<string>('100');
const [loading, setLoading] = useState<boolean>(false);
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 };
// 1. 读取 PowerLDACurveV2 链上实时边际计价 (calMarginalPrice)
const fetchLivePrice = async () => {
const getCurveContract = useCallback(
(runner: ethers.Provider | ethers.Signer) => new ethers.Contract(curveAddress, CURVE_ABI, runner),
[curveAddress]
);
// 1. 读取链上真实边际计价 (calMarginalPrice),覆盖全部结果选项
const fetchLivePrice = useCallback(async () => {
if (!provider || !market.marketAddress) return;
try {
const curveAddr = DEPLOYMENTS.robinhoodTestnet.contracts.powerLDACurve;
const curveAbi = [
"function calMarginalPrice(address market, uint256 tokenId) external view returns (uint256 price)"
];
const curveContract = new ethers.Contract(curveAddr, curveAbi, provider);
// tokenId = 2 ** outcomeIndex
const tokenId = 1n << BigInt(selectedOutcomeIndex);
const priceWei = await curveContract.calMarginalPrice(market.marketAddress, tokenId);
const priceNum = parseFloat(ethers.formatUnits(priceWei, 18));
if (priceNum > 0) {
setDynamicPrice(priceNum);
const curve = getCurveContract(provider);
const prices: number[] = [];
for (let i = 0; i < market.outcomes.length; i++) {
const tid = 1n << BigInt(i);
const priceWei = await curve.calMarginalPrice(market.marketAddress, tid);
prices[i] = priceWei > 0n ? parseFloat(ethers.formatUnits(priceWei, 18)) : 0;
}
setMarginalPrices(prices);
if (prices[selectedOutcomeIndex] && prices[selectedOutcomeIndex] > 0) {
setDynamicPrice(prices[selectedOutcomeIndex]);
}
} 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(() => {
fetchLivePrice();
fetchOtBalance();
const timer = setInterval(fetchLivePrice, 4000);
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';
const estPayout = (Number(estShares) * 1.0).toFixed(2);
const estReturn = amount && Number(amount) > 0 ? (((Number(estPayout) - Number(amount)) / Number(amount)) * 100).toFixed(1) : '0';
// 3. 实时精确预估:BUY = 用预算二分对齐 tick 求最大可得 OT;SELL = 输入 OT 对齐 tick 求赎回值
useEffect(() => {
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) => {
e.preventDefault();
if (!signer || !account) {
@@ -72,56 +176,86 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
const collateral = getCollateralContract();
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 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') {
// Step 1: Approve Collateral to Market
setStatusMsg({ type: 'success', text: '步骤 1/2: 正在授权 WUSD 扣款...' });
const appTx = await collateral.approve(market.marketAddress, ethers.MaxUint256);
await appTx.wait();
const budgetWei = ethers.parseUnits(amount, 18);
const priceWei = await curve.calMarginalPrice(market.marketAddress, tid);
if (priceWei <= 0n) throw new Error('边际价格不可用,请稍后重试');
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()
setStatusMsg({ type: 'success', text: `步骤 2/2: 正在链上买入 ${estShares}${activeOutcome.label} 预测代币...` });
const tx = await marketContract.mintCollateralToExactOt(
account,
tokenId,
otDeltaOut,
"0x" // swapData
);
setStatusMsg({
type: 'success',
text: `步骤 2/2: 链上买入 ${fmt(otWei)}${activeOutcome.label},成本约 ${fmt(cost)} WUSD...`,
});
const tx = await marketContract.mintCollateralToExactOt(account, tid, otWei, "0x");
const receipt = await tx.wait();
setStatusMsg({
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 {
// 卖出 / 赎回
setStatusMsg({ type: 'success', text: '正在赎回预测代币并结转 WUSD 保证金...' });
const tx = await marketContract.redeemExactOtToCollateral(
account,
tokenId,
otDeltaOut,
"0x"
);
await tx.wait();
setStatusMsg({ type: 'success', text: `🎉 成功卖出/赎回 ${estShares} 份预测代币!` });
// 卖出 = 精确赎回 OT -> WUSD
const otWei = ethers.parseUnits(amount, 18);
if (otWei <= 0n || otWei % tick !== 0n) {
throw new Error(`卖出数量必须为 tick${tick.toString()})的整数倍`);
}
const bal = await marketContract.balanceOf(account, tid);
if (bal < otWei) {
throw new Error(`OT 余额不足:当前持有 ${fmt(bal)} 份,需卖出 ${fmt(otWei)}`);
}
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();
fetchLivePrice();
fetchOtBalance();
} catch (err: any) {
console.error('Trade execution error:', err);
setStatusMsg({ type: 'error', text: err.reason || err.message || '交易上链失败,请检查资金与网络' });
setStatusMsg({ type: 'error', text: describeError(err) });
} finally {
setLoading(false);
}
};
const isBuy = tradeSide === 'BUY';
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧 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>
</div>
<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-sm font-bold font-mono text-emerald-400">${dynamicPrice.toFixed(3)}</span>
<span className="text-[11px] text-slate-400 block"> (WUSD/OT)</span>
<span className="text-sm font-bold font-mono text-emerald-400">
{dynamicPrice > 0 ? `$${dynamicPrice.toFixed(3)}` : '--'}
</span>
</div>
<div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3">
<span className="text-[11px] text-slate-400 block"></span>
@@ -161,7 +297,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
</div>
</div>
{/* TradingView 专业图表组件 (GMGN / Pump.fun 风格) */}
{/* TradingView 专业图表组件 */}
<TradingViewChart
marketAddress={market.marketAddress}
outcomeIndex={selectedOutcomeIndex}
@@ -170,7 +306,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
/>
</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">
<form onSubmit={handleTrade} className="space-y-5">
<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"
onClick={() => setTradeSide('BUY')}
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)
@@ -194,7 +330,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
type="button"
onClick={() => setTradeSide('SELL')}
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)
@@ -219,7 +355,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
>
<div className="font-extrabold text-xs truncate">{outcome.label}</div>
<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>
</button>
))}
@@ -229,24 +365,34 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
{/* 交易金额输入 */}
<div className="space-y-2">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-slate-300"> (WUSD)</span>
<span className="text-slate-400 font-mono">: {Number(wusdBalance).toFixed(2)} WUSD</span>
<span className="font-semibold text-slate-300">
{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 className="relative">
<input
type="number"
required
min="0"
step={isBuy ? 'any' : '1'}
value={amount}
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"
/>
<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
key={preset}
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"
>
{preset}
@@ -256,27 +402,55 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
</div>
</div>
{/* 实时预估 */}
{/* 实时预估(基于链上曲线精确计算) */}
<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">
<span></span>
<span className="font-mono font-bold text-slate-200">{estShares} </span>
</div>
<div className="flex justify-between text-slate-400">
<span></span>
<span className="font-mono font-bold text-emerald-400">${estPayout} WUSD</span>
</div>
<div className="flex justify-between text-slate-400">
<span> (ROI)</span>
<span className="font-mono font-bold text-cyan-400">+{estReturn}%</span>
</div>
{preview && preview.kind === 'buy' && (
<>
<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 === '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>
{/* 下单按钮 */}
<button
type="submit"
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 ? (
<>
@@ -286,7 +460,7 @@ export const TradingTerminal: React.FC<TradingTerminalProps> = ({ market }) => {
) : (
<>
<Zap className="w-5 h-5 text-amber-300" />
<span> {activeOutcome.label}</span>
<span>{isBuy ? `立即买入 ${activeOutcome.label}` : `立即卖出 ${activeOutcome.label}`}</span>
</>
)}
</button>
+2 -1
View File
@@ -8,7 +8,8 @@
"powerLDACurve": "0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1",
"controllerImplementation": "0x2Aeca49669e9E4E6CdCe46A1bc9bf136765A4f75",
"controllerProxy": "0xc0E24E152771C588B21AEB654b30B1cBAf381c1a",
"vaultFactory": "0x89401e07296267c01017cA150D5Ec8883a78e0B2"
"vaultFactory": "0x89401e07296267c01017cA150D5Ec8883a78e0B2",
"defaultMarket": "0x048E9a90C25ba2c4410425D282b19A472e076039"
},
"governance": {
"admin": "0x6cddF384792C77219fc25C454Cfe264757842830",