包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useEffect, useMemo } from "react";
|
|
import { useReadContracts } from 'wagmi';
|
|
import { useAccount } from 'wagmi';
|
|
import { formatUnits } from 'viem';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useApp } from "@/contexts/AppContext";
|
|
import { getContractAddress, abis } from '@/lib/contracts';
|
|
|
|
function formatUSD(v: number): string {
|
|
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
|
if (v >= 1_000) return `$${(v / 1_000).toFixed(2)}K`;
|
|
return `$${v.toFixed(2)}`;
|
|
}
|
|
|
|
export default function LiquidityAllocationTable({ refreshTrigger }: { refreshTrigger?: number }) {
|
|
const { t } = useApp();
|
|
const { chainId } = useAccount();
|
|
|
|
// Fetch all products from backend; filter non-stablecoins to build YT token list
|
|
const { data: products = [] } = useQuery<any[]>({
|
|
queryKey: ['products'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/fundmarket/products?token_list=1');
|
|
const json = await res.json();
|
|
return json.data ?? [];
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
// Build dynamic YT token list from DB products (only yt_token role)
|
|
// Use contractAddress and chainId directly from product data
|
|
const ytTokens = useMemo(() =>
|
|
products
|
|
.filter((p: any) => p.token_role === 'yt_token' && p.contractAddress)
|
|
.map((p: any) => ({
|
|
key: p.tokenSymbol as string,
|
|
address: p.contractAddress as string,
|
|
chainId: p.chainId as number,
|
|
meta: p,
|
|
})),
|
|
[products]);
|
|
|
|
// Derive target chainId from products, fallback to connected wallet
|
|
const targetChainId = ytTokens[0]?.chainId ?? chainId;
|
|
|
|
// System contract addresses from registry
|
|
const ytVaultAddr = targetChainId ? getContractAddress('YTVault', targetChainId) : undefined;
|
|
const usdcAddr = targetChainId ? getContractAddress('USDC', targetChainId) : undefined;
|
|
const priceFeedAddr = targetChainId ? getContractAddress('YTPriceFeed', targetChainId) : undefined;
|
|
|
|
const N = ytTokens.length;
|
|
const addressesReady = !!(ytVaultAddr && usdcAddr && priceFeedAddr && N > 0 && ytTokens.every(t => t.address));
|
|
|
|
// Dynamic batch on-chain reads — indices scale with N = ytTokens.length:
|
|
// [0] YTVault.getPoolValue(true)
|
|
// [1..N] YTVault.usdyAmounts(ytToken[i])
|
|
// [N+1..2N] YTPriceFeed.getPrice(ytToken[i], false)
|
|
// [2N+1] USDC.balanceOf(ytVaultAddr)
|
|
// [2N+2] YTPriceFeed.getPrice(usdcAddr, false)
|
|
// [2N+3] YTVault.totalTokenWeights()
|
|
// [2N+4..3N+3] YTVault.tokenWeights(ytToken[i])
|
|
// [3N+4] YTVault.tokenWeights(usdcAddr)
|
|
const { data: chainData, refetch: refetchChain } = useReadContracts({
|
|
contracts: [
|
|
{
|
|
address: ytVaultAddr,
|
|
abi: abis.YTVault as any,
|
|
functionName: 'getPoolValue',
|
|
args: [true],
|
|
chainId: targetChainId,
|
|
} as const,
|
|
...ytTokens.map(tk => ({
|
|
address: ytVaultAddr,
|
|
abi: abis.YTVault as any,
|
|
functionName: 'usdyAmounts',
|
|
args: [tk.address],
|
|
chainId: targetChainId,
|
|
} as const)),
|
|
...ytTokens.map(tk => ({
|
|
address: priceFeedAddr,
|
|
abi: abis.YTPriceFeed as any,
|
|
functionName: 'getPrice',
|
|
args: [tk.address, false],
|
|
chainId: targetChainId,
|
|
} as const)),
|
|
{
|
|
address: usdcAddr,
|
|
abi: abis.USDY as any,
|
|
functionName: 'balanceOf',
|
|
args: [ytVaultAddr],
|
|
chainId: targetChainId,
|
|
} as const,
|
|
{
|
|
address: priceFeedAddr,
|
|
abi: abis.YTPriceFeed as any,
|
|
functionName: 'getPrice',
|
|
args: [usdcAddr, false],
|
|
chainId: targetChainId,
|
|
} as const,
|
|
{
|
|
address: ytVaultAddr,
|
|
abi: abis.YTVault as any,
|
|
functionName: 'totalTokenWeights',
|
|
chainId: targetChainId,
|
|
} as const,
|
|
...ytTokens.map(tk => ({
|
|
address: ytVaultAddr,
|
|
abi: abis.YTVault as any,
|
|
functionName: 'tokenWeights',
|
|
args: [tk.address],
|
|
chainId: targetChainId,
|
|
} as const)),
|
|
{
|
|
address: ytVaultAddr,
|
|
abi: abis.YTVault as any,
|
|
functionName: 'tokenWeights',
|
|
args: [usdcAddr],
|
|
chainId: targetChainId,
|
|
} as const,
|
|
],
|
|
query: { refetchInterval: 30000, enabled: addressesReady },
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (refreshTrigger) refetchChain();
|
|
}, [refreshTrigger]);
|
|
|
|
// Parse results with dynamic indices
|
|
const totalPoolRaw = chainData?.[0]?.result as bigint | undefined;
|
|
const totalWeightsRaw = chainData?.[2 * N + 3]?.result as bigint | undefined;
|
|
const totalPool = totalPoolRaw ? parseFloat(formatUnits(totalPoolRaw, 18)) : 0;
|
|
const totalWeights = totalWeightsRaw ? Number(totalWeightsRaw) : 0;
|
|
|
|
const ytAllocations = ytTokens.map((token, i) => {
|
|
const usdyRaw = chainData?.[1 + i]?.result as bigint | undefined;
|
|
const priceRaw = chainData?.[N + 1 + i]?.result as bigint | undefined;
|
|
const twRaw = chainData?.[2 * N + 4 + i]?.result as bigint | undefined;
|
|
|
|
const poolSizeUSD = usdyRaw ? parseFloat(formatUnits(usdyRaw, 18)) : 0;
|
|
const ytPrice = priceRaw ? parseFloat(formatUnits(priceRaw, 30)) : 0;
|
|
const balance = ytPrice > 0 ? poolSizeUSD / ytPrice : 0;
|
|
const currentWeight = totalPool > 0 ? poolSizeUSD / totalPool * 100 : 0;
|
|
const targetWeight = totalWeights > 0 && twRaw != null ? Number(twRaw) / totalWeights * 100 : 0;
|
|
|
|
return {
|
|
key: token.key,
|
|
name: token.meta?.name ?? token.key,
|
|
category: token.meta?.category ?? '--',
|
|
iconUrl: token.meta?.iconUrl,
|
|
poolSizeUSD,
|
|
balance,
|
|
ytPrice,
|
|
currentWeight,
|
|
targetWeight,
|
|
isStable: false,
|
|
};
|
|
});
|
|
|
|
const usdcRaw = chainData?.[2 * N + 1]?.result as bigint | undefined;
|
|
const usdcPriceRaw = chainData?.[2 * N + 2]?.result as bigint | undefined;
|
|
const usdcTWRaw = chainData?.[3 * N + 4]?.result as bigint | undefined;
|
|
const usdcBalance = usdcRaw ? parseFloat(formatUnits(usdcRaw, 18)) : 0;
|
|
const usdcPrice = usdcPriceRaw ? parseFloat(formatUnits(usdcPriceRaw, 30)) : 0;
|
|
const usdcPoolSize = usdcBalance * usdcPrice;
|
|
const usdcCurWeight = totalPool > 0 ? usdcPoolSize / totalPool * 100 : 0;
|
|
const usdcTgtWeight = totalWeights > 0 && usdcTWRaw != null ? Number(usdcTWRaw) / totalWeights * 100 : 0;
|
|
const usdcMeta = products.find((p: any) => p.tokenSymbol === 'USDC');
|
|
|
|
const allocations = [
|
|
...ytAllocations,
|
|
{
|
|
key: 'USDC',
|
|
name: usdcMeta?.name ?? 'USD Coin',
|
|
category: usdcMeta?.category ?? 'Stablecoin',
|
|
iconUrl: usdcMeta?.iconUrl,
|
|
poolSizeUSD: usdcPoolSize,
|
|
balance: usdcBalance,
|
|
ytPrice: usdcPrice,
|
|
currentWeight: usdcCurWeight,
|
|
targetWeight: usdcTgtWeight,
|
|
isStable: true,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{/* Title */}
|
|
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
|
|
{t("alp.liquidityAllocation")}
|
|
</h2>
|
|
|
|
{/* 移动端:卡片布局 */}
|
|
<div className="md:hidden flex flex-col gap-3">
|
|
{allocations.map((item) => (
|
|
<div
|
|
key={item.key}
|
|
className="bg-bg-surface dark:bg-gray-800 rounded-2xl border border-border-gray dark:border-gray-700 p-4 flex flex-col gap-3"
|
|
>
|
|
{/* Token Header */}
|
|
<div className="flex items-center gap-3">
|
|
<Image
|
|
src={item.iconUrl || '/assets/tokens/default.svg'}
|
|
alt={item.key}
|
|
width={36}
|
|
height={36}
|
|
className="rounded-full"
|
|
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
|
|
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{item.category}</span>
|
|
</div>
|
|
</div>
|
|
{/* Info Grid */}
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.poolSize")}</span>
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
|
|
</span>
|
|
<span className="text-caption-tiny text-[#6b7280] dark:text-gray-400">
|
|
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">Weight</span>
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|
{totalPool > 0 ? `${item.currentWeight.toFixed(1)}%` : '--'}
|
|
</span>
|
|
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">
|
|
{item.targetWeight > 0 ? `→ ${item.targetWeight.toFixed(1)}%` : '--'}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.currentPrice")}</span>
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 桌面端:表格布局 */}
|
|
<div className="hidden md:block bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 overflow-hidden">
|
|
<div className="flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex border-b border-border-gray dark:border-gray-700 flex-shrink-0">
|
|
<div className="flex-1 px-6 py-4">
|
|
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.token")}</div>
|
|
</div>
|
|
<div className="flex-1 px-6 py-4">
|
|
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.poolSize")}</div>
|
|
</div>
|
|
<div className="flex-1 px-6 py-4">
|
|
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">Weight (Current / Target)</div>
|
|
</div>
|
|
<div className="flex-1 px-6 py-4">
|
|
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.currentPrice")}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
{allocations.map((item) => (
|
|
<div key={item.key} className="flex items-center border-b border-border-gray dark:border-gray-700 last:border-b-0">
|
|
<div className="flex-1 px-6 py-4 flex items-center gap-3">
|
|
<Image
|
|
src={item.iconUrl || '/assets/tokens/default.svg'}
|
|
alt={item.key}
|
|
width={32}
|
|
height={32}
|
|
className="rounded-full"
|
|
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
|
|
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{item.category}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 px-6 py-4">
|
|
<div className="flex flex-col">
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
|
|
</span>
|
|
<span className="text-caption-tiny font-regular text-[#6b7280] dark:text-gray-400">
|
|
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 px-6 py-4">
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|
{totalPool > 0 ? (
|
|
<>
|
|
{item.currentWeight.toFixed(1)}%
|
|
<span className="font-regular text-text-tertiary dark:text-gray-400">
|
|
{' / '}{item.targetWeight > 0 ? `${item.targetWeight.toFixed(1)}%` : '--'}
|
|
</span>
|
|
</>
|
|
) : '--'}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 px-6 py-4">
|
|
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
|
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|