init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
317
webapp/components/alp/LiquidityAllocationTable.tsx
Normal file
317
webapp/components/alp/LiquidityAllocationTable.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user