Files
assetx/webapp/components/alp/PoolDepositPanel.tsx
default 2ee4553b71 init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
2026-03-27 11:26:43 +00:00

542 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Button, Tabs, Tab } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount, useReadContract } from 'wagmi';
import { parseUnits, formatUnits } from 'viem';
import { useTokenBalance, useYTLPBalance } from '@/hooks/useBalance';
import { usePoolDeposit } from '@/hooks/usePoolDeposit';
import { usePoolWithdraw } from '@/hooks/usePoolWithdraw';
import { toast } from "sonner";
import TokenSelector from '@/components/common/TokenSelector';
import { Token } from '@/lib/api/tokens';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { getContractAddress, abis } from '@/lib/contracts';
interface PoolDepositPanelProps {
onSuccess?: () => void;
}
export default function PoolDepositPanel({ onSuccess }: PoolDepositPanelProps) {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<string>("deposit");
const [selectedToken, setSelectedToken] = useState<string>('USDC');
const [selectedTokenObj, setSelectedTokenObj] = useState<Token | undefined>();
const [outputToken, setOutputToken] = useState<string>('USDC');
const [outputTokenObj, setOutputTokenObj] = useState<Token | undefined>();
const [depositAmount, setDepositAmount] = useState<string>("");
const [withdrawAmount, setWithdrawAmount] = useState<string>("");
const [mounted, setMounted] = useState(false);
const [withdrawCooldownLeft, setWithdrawCooldownLeft] = useState(0); // seconds remaining
useEffect(() => {
setMounted(true);
}, []);
// Track 15-min withdraw cooldown after deposit
useEffect(() => {
const COOLDOWN_MS = 15 * 60 * 1000;
const tick = () => {
const lastDeposit = parseInt(localStorage.getItem('alp_last_deposit_time') ?? '0', 10);
if (!lastDeposit) { setWithdrawCooldownLeft(0); return; }
const remaining = Math.ceil((lastDeposit + COOLDOWN_MS - Date.now()) / 1000);
setWithdrawCooldownLeft(remaining > 0 ? remaining : 0);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, []);
// 处理存入 Token 选择
const handleYTTokenSelect = useCallback((token: Token) => {
setSelectedTokenObj(token);
setSelectedToken(token.symbol);
setDepositAmount("");
}, []);
// 处理输出 Token 选择
const handleOutputTokenSelect = useCallback((token: Token) => {
setOutputTokenObj(token);
setOutputToken(token.symbol);
setWithdrawAmount("");
}, []);
// Web3 集成
const { address, isConnected, chainId } = useAccount();
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
const depositInputDecimals = useTokenDecimalsFromAddress(
selectedTokenObj?.contractAddress,
selectedTokenObj?.decimals ?? 18
);
const depositDisplayDecimals = Math.min(depositInputDecimals, 6);
const ytLPAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined;
const poolManagerAddress = chainId ? getContractAddress('YTPoolManager', chainId) : undefined;
const withdrawDecimals = useTokenDecimalsFromAddress(ytLPAddress, 18);
const withdrawDisplayDecimals = Math.min(withdrawDecimals, 6);
const outputDecimals = useTokenDecimalsFromAddress(outputTokenObj?.contractAddress, outputTokenObj?.decimals ?? 18);
// 存入估算getAddLiquidityOutput(token, amount) → [usdyAmount, ytLPMintAmount]
const depositAmountWei = depositAmount !== '' && parseFloat(depositAmount) > 0 && selectedTokenObj
? parseUnits(depositAmount, depositInputDecimals)
: undefined;
const { data: depositEstData, isLoading: isDepositEstLoading } = useReadContract({
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getAddLiquidityOutput',
args: [selectedTokenObj?.contractAddress as `0x${string}`, depositAmountWei ?? 0n],
query: { enabled: !!poolManagerAddress && !!selectedTokenObj?.contractAddress && !!depositAmountWei },
});
const estLPTokens = depositEstData
? parseFloat(formatUnits((depositEstData as [bigint, bigint])[1], 18))
: null;
// 提出估算getRemoveLiquidityOutput(tokenOut, ytLPAmount) → [usdyAmount, amountOut]
const withdrawAmountWei = withdrawAmount !== '' && parseFloat(withdrawAmount) > 0
? parseUnits(withdrawAmount, withdrawDecimals)
: undefined;
const { data: withdrawEstData, isLoading: isWithdrawEstLoading } = useReadContract({
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getRemoveLiquidityOutput',
args: [outputTokenObj?.contractAddress as `0x${string}`, withdrawAmountWei ?? 0n],
query: { enabled: !!poolManagerAddress && !!outputTokenObj?.contractAddress && !!withdrawAmountWei },
});
const estAmountOut = withdrawEstData
? parseFloat(formatUnits((withdrawEstData as [bigint, bigint])[1], outputDecimals))
: null;
const { formattedBalance: depositBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useTokenBalance(
selectedTokenObj?.contractAddress,
depositInputDecimals
);
const { formattedBalance: outputTokenBalance } = useTokenBalance(
outputTokenObj?.contractAddress,
outputDecimals
);
const { formattedBalance: lpBalance, refetch: refetchLP } = useYTLPBalance();
// Deposit hooks
const {
status: depositStatus,
error: depositError,
isLoading: isDepositLoading,
approveHash: depositApproveHash,
depositHash,
executeApproveAndDeposit,
reset: resetDeposit,
} = usePoolDeposit();
// Withdraw hooks
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
approveHash: withdrawApproveHash,
withdrawHash: withdrawTxHash,
executeApproveAndWithdraw,
reset: resetWithdraw,
} = usePoolWithdraw();
// 存款成功后刷新余额,并记录 15 分钟提现冷却开始时间
useEffect(() => {
if (depositStatus === 'success') {
toast.success(t("alp.toast.liquidityAdded"), {
description: t("alp.toast.liquidityAddedDesc"),
duration: 5000,
});
localStorage.setItem('alp_last_deposit_time', Date.now().toString());
refetchBalance();
refetchLP();
onSuccess?.();
const timer = setTimeout(() => {
resetDeposit();
setDepositAmount("");
}, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [depositStatus]);
// 提款成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("alp.toast.liquidityRemoved"), {
description: t("alp.toast.liquidityRemovedDesc"),
duration: 5000,
});
refetchBalance();
refetchLP();
onSuccess?.();
const timer = setTimeout(() => {
resetWithdraw();
setWithdrawAmount("");
}, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 显示错误提示
useEffect(() => {
if (depositError) {
if (depositError === 'Transaction cancelled') {
toast.warning(t("alp.toast.txCancelled"), {
description: t("alp.toast.txCancelledDesc"),
duration: 3000,
});
} else {
toast.error(t("alp.toast.depositFailed"), {
description: depositError,
duration: 5000,
});
}
}
}, [depositError]);
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.warning(t("alp.toast.txCancelled"), {
description: t("alp.toast.txCancelledDesc"),
duration: 3000,
});
} else {
toast.error(t("alp.toast.withdrawFailed"), {
description: withdrawError,
duration: 5000,
});
}
}
}, [withdrawError]);
// 显示交易hash toast
useEffect(() => {
if (depositApproveHash && depositStatus === 'approving') {
toast.info(t("alp.toast.approvalSubmitted"), {
description: t("alp.toast.approvingTokens"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositApproveHash}`, '_blank'),
},
duration: 10000,
});
}
}, [depositApproveHash, depositStatus]);
useEffect(() => {
if (depositHash && depositStatus === 'depositing') {
toast.info(t("alp.toast.depositSubmitted"), {
description: t("alp.toast.addingLiquidity"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositHash}`, '_blank'),
},
duration: 10000,
});
}
}, [depositHash, depositStatus]);
useEffect(() => {
if (withdrawApproveHash && withdrawStatus === 'approving') {
toast.info(t("alp.toast.approvalSubmitted"), {
description: t("alp.toast.approvingLP"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawApproveHash}`, '_blank'),
},
duration: 10000,
});
}
}, [withdrawApproveHash, withdrawStatus]);
useEffect(() => {
if (withdrawTxHash && withdrawStatus === 'withdrawing') {
toast.info(t("alp.toast.withdrawSubmitted"), {
description: t("alp.toast.removingLiquidity"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawTxHash}`, '_blank'),
},
duration: 10000,
});
}
}, [withdrawTxHash, withdrawStatus]);
// 切换Tab时重置错误状态和输入框
useEffect(() => {
resetDeposit();
resetWithdraw();
setDepositAmount("");
setWithdrawAmount("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleDepositPercentage = (pct: number) => {
const bal = parseFloat(depositBalance);
if (bal > 0) setDepositAmount(truncateDecimals((bal * pct / 100).toString(), depositDisplayDecimals));
};
const handleWithdrawPercentage = (pct: number) => {
const bal = parseFloat(lpBalance);
if (bal > 0) setWithdrawAmount(truncateDecimals((bal * pct / 100).toString(), withdrawDisplayDecimals));
};
const pctBtnClass = "flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-2.5 h-[22px] text-[10px] font-medium text-text-primary dark:text-white hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors";
const handleDepositAmountChange = (value: string) => {
if (value === '') { setDepositAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > depositDisplayDecimals) return;
if (parseFloat(value) > parseFloat(depositBalance)) { setDepositAmount(truncateDecimals(depositBalance, depositDisplayDecimals)); return; }
setDepositAmount(value);
};
const handleWithdrawAmountChange = (value: string) => {
if (value === '') { setWithdrawAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > withdrawDisplayDecimals) return;
if (parseFloat(value) > parseFloat(lpBalance)) { setWithdrawAmount(truncateDecimals(lpBalance, withdrawDisplayDecimals)); return; }
setWithdrawAmount(value);
};
const handleDeposit = () => {
if (depositAmount && parseFloat(depositAmount) > 0 && selectedTokenObj) {
executeApproveAndDeposit(selectedTokenObj.contractAddress, depositInputDecimals, depositAmount);
}
};
const handleWithdraw = () => {
if (withdrawAmount && parseFloat(withdrawAmount) > 0 && outputTokenObj) {
executeApproveAndWithdraw(outputTokenObj.contractAddress, outputTokenObj.decimals ?? 18, withdrawAmount);
}
};
const currentStatus = activeTab === 'deposit' ? depositStatus : withdrawStatus;
const isLoading = activeTab === 'deposit' ? isDepositLoading : isWithdrawLoading;
const currentAmount = activeTab === 'deposit' ? depositAmount : withdrawAmount;
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full">
{/* Content */}
<div className="flex flex-col gap-6 p-6 flex-1">
{/* Title */}
<div className="flex flex-col gap-1">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
{t("alp.liquidityPool")}
</h2>
<p className="text-body-small text-text-tertiary dark:text-gray-400">
{t("alp.subtitle")}
</p>
</div>
{/* Tabs */}
<Tabs
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(key as string)}
classNames={{
base: "w-full",
tabList: "w-full bg-bg-subtle dark:bg-gray-700 p-1 rounded-xl",
tab: "h-10",
cursor: "bg-white dark:bg-gray-600",
tabContent: "group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
<Tab key="deposit" title={t("alp.deposit")}>
{/* Deposit Panel */}
<div className="flex flex-col gap-4 mt-4">
{/* Input Area */}
<div className="flex flex-col gap-2">
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("mintSwap.deposit")}
</span>
<div className="flex items-center gap-1 sm:hidden">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => handleDepositPercentage(25)} className={pctBtnClass}>25%</button>
<button onClick={() => handleDepositPercentage(50)} className={pctBtnClass}>50%</button>
<button onClick={() => handleDepositPercentage(75)} className={pctBtnClass}>75%</button>
<button onClick={() => handleDepositPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={depositAmount}
onChange={(e) => handleDepositAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{depositAmount ? `$${depositAmount}` : "--"}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<TokenSelector
selectedToken={selectedTokenObj}
onSelect={handleYTTokenSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol={selectedToken}
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${selectedToken}` : (isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals })} ${selectedToken}`)}
</span>
</div>
</div>
</div>
</div>
{/* Pool Info */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
{t("alp.estimatedLP")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
{estLPTokens != null
? `${estLPTokens.toLocaleString('en-US', { maximumFractionDigits: 6 })} LP`
: depositAmount ? (isDepositEstLoading ? '...' : '--') : '--'}
</span>
</div>
</div>
</div>
</Tab>
<Tab key="withdraw" title={t("alp.withdraw")}>
{/* Withdraw Panel */}
<div className="flex flex-col gap-4 mt-4">
{/* Input Area */}
<div className="flex flex-col gap-2">
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("alp.amountToWithdraw")}
</span>
<div className="flex items-center gap-1 sm:hidden">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => handleWithdrawPercentage(25)} className={pctBtnClass}>25%</button>
<button onClick={() => handleWithdrawPercentage(50)} className={pctBtnClass}>50%</button>
<button onClick={() => handleWithdrawPercentage(75)} className={pctBtnClass}>75%</button>
<button onClick={() => handleWithdrawPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={withdrawAmount}
onChange={(e) => handleWithdrawAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{withdrawAmount ? `${withdrawAmount} LP` : "--"}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<TokenSelector
selectedToken={outputTokenObj}
onSelect={handleOutputTokenSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol={outputToken}
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${outputToken}` : `${parseFloat(truncateDecimals(outputTokenBalance, 6)).toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`}
</span>
</div>
</div>
</div>
</div>
{/* Withdraw Info */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
{t("alp.youWillReceive")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
{estAmountOut != null
? `${estAmountOut.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`
: withdrawAmount ? (isWithdrawEstLoading ? '...' : '--') : '--'}
</span>
</div>
</div>
</div>
</Tab>
</Tabs>
{/* Submit Button */}
{(() => {
const amountInvalid = !!currentAmount && !isValidAmount(currentAmount);
return (
<Button
isDisabled={!mounted || !isConnected || !isValidAmount(currentAmount) || isLoading || (activeTab === 'withdraw' && withdrawCooldownLeft > 0)}
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={activeTab === 'deposit' ? handleDeposit : handleWithdraw}
>
{!mounted && t("common.connectWallet")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && currentStatus === 'idle' && amountInvalid && t("common.invalidAmount")}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'deposit' && t("alp.addLiquidity")}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft > 0 && `15-min Withdrawal Lock (${String(Math.floor(withdrawCooldownLeft / 60)).padStart(2, '0')}:${String(withdrawCooldownLeft % 60).padStart(2, '0')})`}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft === 0 && t("alp.removeLiquidity")}
{mounted && currentStatus === 'approving' && t("common.approving")}
{mounted && currentStatus === 'approved' && (activeTab === 'deposit' ? t("alp.approvedAdding") : t("alp.approvedRemoving"))}
{mounted && (currentStatus === 'depositing' || currentStatus === 'withdrawing') && (activeTab === 'deposit' ? t("alp.adding") : t("alp.removing"))}
{mounted && currentStatus === 'success' && t("common.success")}
{mounted && currentStatus === 'error' && t("common.failed")}
</Button>
);
})()}
{/* Info Note */}
<div className="text-center">
<span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
{activeTab === 'deposit' ? t("alp.depositNote") : t("alp.withdrawNote")}
</span>
</div>
</div>
</div>
);
}