init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
127
webapp/hooks/useBalance.ts
Normal file
127
webapp/hooks/useBalance.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useAccount, useReadContract } from 'wagmi'
|
||||
import { formatUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { useTokenList } from './useTokenList'
|
||||
|
||||
/**
|
||||
* 查询 USDC 余额
|
||||
*/
|
||||
export function useUSDCBalance() {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const contractAddress = chainId ? getContractAddress('USDC', chainId) : undefined
|
||||
|
||||
const { data: balance, isLoading, error, refetch } = useReadContract({
|
||||
address: contractAddress,
|
||||
abi: abis.USDY,
|
||||
functionName: 'balanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!chainId && !!contractAddress,
|
||||
refetchInterval: 10000,
|
||||
}
|
||||
})
|
||||
|
||||
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
|
||||
const formattedBalance = balance ? formatUnits(balance as bigint, usdcDecimals) : '0'
|
||||
|
||||
return {
|
||||
balance: balance as bigint | undefined,
|
||||
formattedBalance,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 YT LP Token 余额
|
||||
*/
|
||||
export function useYTLPBalance() {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const lpToken = bySymbol['YTLPToken'] ?? bySymbol['LP']
|
||||
const contractAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined
|
||||
|
||||
const { data: balance, isLoading, error, refetch } = useReadContract({
|
||||
address: contractAddress,
|
||||
abi: abis.YTLPToken,
|
||||
functionName: 'balanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!chainId && !!contractAddress,
|
||||
refetchInterval: 10000,
|
||||
}
|
||||
})
|
||||
|
||||
const lpDecimals = lpToken?.onChainDecimals ?? lpToken?.decimals ?? 18
|
||||
const formattedBalance = balance ? formatUnits(balance as bigint, lpDecimals) : '0'
|
||||
|
||||
return {
|
||||
balance: balance as bigint | undefined,
|
||||
formattedBalance,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 ERC20 余额查询(按合约地址)
|
||||
*/
|
||||
export function useTokenBalance(contractAddress: string | undefined, decimals: number = 18) {
|
||||
const { address } = useAccount()
|
||||
|
||||
const { data: balance, isLoading, error, refetch } = useReadContract({
|
||||
address: contractAddress as `0x${string}` | undefined,
|
||||
abi: abis.YTToken,
|
||||
functionName: 'balanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!contractAddress,
|
||||
refetchInterval: 10000,
|
||||
}
|
||||
})
|
||||
|
||||
const formattedBalance = balance ? formatUnits(balance as bigint, decimals) : '0'
|
||||
|
||||
return {
|
||||
balance: balance as bigint | undefined,
|
||||
formattedBalance,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 USDC 授权额度
|
||||
*/
|
||||
export function useUSDCAllowance(spenderAddress?: `0x${string}`) {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const contractAddress = chainId ? getContractAddress('USDC', chainId) : undefined
|
||||
|
||||
const { data: allowance, isLoading, error, refetch } = useReadContract({
|
||||
address: contractAddress,
|
||||
abi: abis.USDY,
|
||||
functionName: 'allowance',
|
||||
args: address && spenderAddress ? [address, spenderAddress] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!chainId && !!spenderAddress && !!contractAddress,
|
||||
}
|
||||
})
|
||||
|
||||
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
|
||||
const formattedAllowance = allowance ? formatUnits(allowance as bigint, usdcDecimals) : '0'
|
||||
|
||||
return {
|
||||
allowance: allowance as bigint | undefined,
|
||||
formattedAllowance,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
267
webapp/hooks/useCollateral.ts
Normal file
267
webapp/hooks/useCollateral.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useAccount, useReadContract, useReadContracts } from 'wagmi'
|
||||
import { formatUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { Token } from '@/lib/api/tokens'
|
||||
import { useTokenList } from './useTokenList'
|
||||
|
||||
/**
|
||||
* Hook to query user's collateral balance for a specific YT token
|
||||
*/
|
||||
export function useCollateralBalance(token: Token | undefined) {
|
||||
const { address, chainId } = useAccount()
|
||||
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: collateralBalance, refetch, isLoading } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'getCollateral',
|
||||
args: address && token?.contractAddress ? [address, token.contractAddress as `0x${string}`] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!token?.contractAddress && !!lendingProxyAddress,
|
||||
},
|
||||
})
|
||||
|
||||
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
|
||||
const formattedBalance = collateralBalance
|
||||
? formatUnits(collateralBalance as bigint, decimals)
|
||||
: '0'
|
||||
|
||||
return {
|
||||
balance: collateralBalance as bigint | undefined,
|
||||
formattedBalance,
|
||||
isLoading,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query user's borrow balance (in USDC)
|
||||
*/
|
||||
export function useBorrowBalance() {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: borrowBalance, refetch, isLoading } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'borrowBalanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!chainId && !!lendingProxyAddress,
|
||||
},
|
||||
})
|
||||
|
||||
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
|
||||
const formattedBalance = borrowBalance
|
||||
? formatUnits(borrowBalance as bigint, usdcDecimals)
|
||||
: '0.00'
|
||||
|
||||
return {
|
||||
balance: borrowBalance as bigint | undefined,
|
||||
formattedBalance,
|
||||
isLoading,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query user's balance (positive = supply, negative = borrow)
|
||||
*/
|
||||
export function useAccountBalance() {
|
||||
const { address, chainId } = useAccount()
|
||||
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: supplyBalance, refetch: refetchSupply, isLoading: isLoadingSupply } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'supplyBalanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!chainId && !!lendingProxyAddress,
|
||||
},
|
||||
})
|
||||
|
||||
const { data: borrowBalance, refetch: refetchBorrow, isLoading: isLoadingBorrow } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'borrowBalanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!chainId && !!lendingProxyAddress,
|
||||
},
|
||||
})
|
||||
|
||||
const supplyValue = (supplyBalance as bigint) || 0n
|
||||
const borrowValue = (borrowBalance as bigint) || 0n
|
||||
const netBalance = supplyValue - borrowValue
|
||||
|
||||
return {
|
||||
balance: netBalance,
|
||||
isSupply: supplyValue > 0n,
|
||||
isBorrow: borrowValue > 0n,
|
||||
isLoading: isLoadingSupply || isLoadingBorrow,
|
||||
refetch: () => {
|
||||
refetchSupply()
|
||||
refetchBorrow()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate LTV (Loan-to-Value) ratio
|
||||
* LTV = (Borrow Balance / Collateral Value) * 100
|
||||
*/
|
||||
export function useLTV() {
|
||||
const { bySymbol } = useTokenList()
|
||||
const ytA = bySymbol['YT-A']
|
||||
const ytB = bySymbol['YT-B']
|
||||
const ytC = bySymbol['YT-C']
|
||||
|
||||
const { formattedBalance: borrowBalance } = useBorrowBalance()
|
||||
const { value: valueA } = useCollateralValue(ytA)
|
||||
const { value: valueB } = useCollateralValue(ytB)
|
||||
const { value: valueC } = useCollateralValue(ytC)
|
||||
|
||||
const totalCollateralValue = parseFloat(valueA) + parseFloat(valueB) + parseFloat(valueC)
|
||||
const borrowValue = parseFloat(borrowBalance)
|
||||
|
||||
const ltv = totalCollateralValue > 0
|
||||
? (borrowValue / totalCollateralValue) * 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
ltv: ltv.toFixed(2),
|
||||
ltvRaw: ltv,
|
||||
isLoading: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get YT token price from its contract
|
||||
*/
|
||||
export function useYTPrice(token: Token | undefined) {
|
||||
const { data: ytPrice, isLoading } = useReadContract({
|
||||
address: token?.contractAddress as `0x${string}` | undefined,
|
||||
abi: abis.YTToken,
|
||||
functionName: 'ytPrice',
|
||||
query: {
|
||||
enabled: !!token?.contractAddress,
|
||||
},
|
||||
})
|
||||
|
||||
// ytPrice 返回 30 位精度的价格(PRICE_PRECISION = 1e30)
|
||||
const formattedPrice = ytPrice
|
||||
? formatUnits(ytPrice as bigint, 30)
|
||||
: '0'
|
||||
|
||||
return {
|
||||
price: ytPrice as bigint | undefined,
|
||||
formattedPrice,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate collateral value in USD
|
||||
*/
|
||||
export function useCollateralValue(token: Token | undefined) {
|
||||
const { formattedBalance } = useCollateralBalance(token)
|
||||
const { formattedPrice } = useYTPrice(token)
|
||||
|
||||
const value = parseFloat(formattedBalance) * parseFloat(formattedPrice)
|
||||
|
||||
return {
|
||||
value: value.toFixed(2),
|
||||
valueRaw: value,
|
||||
}
|
||||
}
|
||||
|
||||
type AssetConfig = {
|
||||
asset: `0x${string}`
|
||||
decimals: number
|
||||
borrowCollateralFactor: bigint
|
||||
liquidateCollateralFactor: bigint
|
||||
liquidationFactor: bigint
|
||||
supplyCap: bigint
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate total borrow capacity and available borrowable amount
|
||||
*/
|
||||
export function useMaxBorrowable() {
|
||||
const { chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const ytA = bySymbol['YT-A']
|
||||
const ytB = bySymbol['YT-B']
|
||||
const ytC = bySymbol['YT-C']
|
||||
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
// Collateral balances
|
||||
const { formattedBalance: balA, refetch: refetchA } = useCollateralBalance(ytA)
|
||||
const { formattedBalance: balB, refetch: refetchB } = useCollateralBalance(ytB)
|
||||
const { formattedBalance: balC, refetch: refetchC } = useCollateralBalance(ytC)
|
||||
|
||||
// YT token prices (ytPrice(), 30-decimal precision)
|
||||
const { formattedPrice: priceA } = useYTPrice(ytA)
|
||||
const { formattedPrice: priceB } = useYTPrice(ytB)
|
||||
const { formattedPrice: priceC } = useYTPrice(ytC)
|
||||
|
||||
// Borrow collateral factors from lendingProxy.assetConfigs
|
||||
const ytAddresses = [ytA, ytB, ytC]
|
||||
.filter(t => !!t?.contractAddress)
|
||||
.map(t => t!.contractAddress as `0x${string}`)
|
||||
|
||||
const configContracts = ytAddresses.map((addr) => ({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'assetConfigs' as const,
|
||||
args: [addr],
|
||||
}))
|
||||
|
||||
const { data: configData } = useReadContracts({
|
||||
contracts: configContracts as any,
|
||||
query: { enabled: !!lendingProxyAddress && configContracts.length > 0 },
|
||||
})
|
||||
|
||||
type ConfigTuple = readonly [string, number, bigint, bigint, bigint, bigint]
|
||||
|
||||
const getConfig = (i: number): ConfigTuple | undefined =>
|
||||
configData?.[i]?.result as ConfigTuple | undefined
|
||||
|
||||
const calcCapacity = (bal: string, price: string, config: ConfigTuple | undefined) => {
|
||||
if (!config || !config[2]) return 0
|
||||
const factor = Number(config[2]) / 1e18
|
||||
return parseFloat(bal) * parseFloat(price) * factor
|
||||
}
|
||||
|
||||
const capacityA = calcCapacity(balA, priceA, getConfig(0))
|
||||
const capacityB = calcCapacity(balB, priceB, getConfig(1))
|
||||
const capacityC = calcCapacity(balC, priceC, getConfig(2))
|
||||
|
||||
const totalCapacity = capacityA + capacityB + capacityC
|
||||
|
||||
const { formattedBalance: borrowedBalance, refetch: refetchBorrow } = useBorrowBalance()
|
||||
const currentBorrow = parseFloat(borrowedBalance) || 0
|
||||
const available = Math.max(0, totalCapacity - currentBorrow)
|
||||
|
||||
const refetch = () => {
|
||||
refetchA()
|
||||
refetchB()
|
||||
refetchC()
|
||||
refetchBorrow()
|
||||
}
|
||||
|
||||
return {
|
||||
totalCapacity,
|
||||
available,
|
||||
formattedCapacity: totalCapacity.toFixed(2),
|
||||
formattedAvailable: available.toFixed(2),
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
27
webapp/hooks/useContractRegistry.ts
Normal file
27
webapp/hooks/useContractRegistry.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchContracts } from '@/lib/api/contracts'
|
||||
import { setContractAddressDynamic } from '@/lib/contracts/registry'
|
||||
|
||||
/**
|
||||
* Fetches contract addresses from the backend and populates the dynamic registry.
|
||||
* Call once near the app root (e.g., inside Providers or AppContext).
|
||||
* All existing getContractAddress() callers automatically see updated values.
|
||||
*/
|
||||
export function useContractRegistry() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['contract-registry'],
|
||||
queryFn: fetchContracts,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
for (const c of data) {
|
||||
if (c.address) setContractAddressDynamic(c.name, c.chain_id, c.address)
|
||||
}
|
||||
}, [data])
|
||||
}
|
||||
167
webapp/hooks/useDeposit.ts
Normal file
167
webapp/hooks/useDeposit.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits } from 'viem'
|
||||
import { abis } from '@/lib/contracts'
|
||||
import { Token } from '@/lib/api/tokens'
|
||||
import { useTokenList } from './useTokenList'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
import { DEFAULT_GAS_LIMIT } from '@/lib/constants'
|
||||
|
||||
type DepositStatus = 'idle' | 'approving' | 'approved' | 'depositing' | 'success' | 'error'
|
||||
|
||||
export function useDeposit(ytToken: Token | undefined) {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const [status, setStatus] = useState<DepositStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pendingAmount, setPendingAmount] = useState<string>('')
|
||||
|
||||
const {
|
||||
writeContractAsync: approveWrite,
|
||||
data: approveHash,
|
||||
isPending: isApprovePending,
|
||||
reset: resetApprove,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isApproveConfirming,
|
||||
isSuccess: isApproveSuccess,
|
||||
isError: isApproveError,
|
||||
error: approveReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: approveHash })
|
||||
|
||||
const {
|
||||
writeContractAsync: depositWrite,
|
||||
data: depositHash,
|
||||
isPending: isDepositPending,
|
||||
reset: resetDeposit,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isDepositConfirming,
|
||||
isSuccess: isDepositSuccess,
|
||||
isError: isDepositError,
|
||||
error: depositReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: depositHash })
|
||||
|
||||
const executeApprove = useCallback(async (amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!address || !ytToken?.contractAddress || !usdcToken?.contractAddress || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('approving')
|
||||
|
||||
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
|
||||
const amountInWei = parseUnits(amount, usdcDecimals)
|
||||
|
||||
await approveWrite({
|
||||
address: usdcToken.contractAddress as `0x${string}`,
|
||||
abi: abis.USDY,
|
||||
functionName: 'approve',
|
||||
args: [ytToken.contractAddress as `0x${string}`, amountInWei],
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Approve failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, approveWrite, ytToken, usdcToken, status])
|
||||
|
||||
const executeDeposit = useCallback(async (amount: string) => {
|
||||
if (status === 'depositing' || status === 'success' || status === 'error') return false
|
||||
if (!address || !ytToken?.contractAddress || !usdcToken || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('depositing')
|
||||
|
||||
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
|
||||
const amountInWei = parseUnits(amount, usdcDecimals)
|
||||
|
||||
await depositWrite({
|
||||
address: ytToken.contractAddress as `0x${string}`,
|
||||
abi: abis.YTToken,
|
||||
functionName: 'depositYT',
|
||||
args: [amountInWei],
|
||||
gas: DEFAULT_GAS_LIMIT,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Deposit failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, depositWrite, ytToken, usdcToken, status])
|
||||
|
||||
const executeApproveAndDeposit = useCallback(async (amount: string) => {
|
||||
if (status !== 'idle') return
|
||||
setPendingAmount(amount)
|
||||
const approveSuccess = await executeApprove(amount)
|
||||
if (!approveSuccess) return
|
||||
}, [executeApprove, status])
|
||||
|
||||
// Auto-execute deposit when approve is successful
|
||||
useEffect(() => {
|
||||
if (isApproveSuccess && status === 'approving' && pendingAmount) {
|
||||
setStatus('approved')
|
||||
executeDeposit(pendingAmount)
|
||||
}
|
||||
}, [isApproveSuccess, status, pendingAmount, executeDeposit])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveError && status === 'approving') {
|
||||
setError(parseContractError(approveReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isApproveError, status, approveReceiptError])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDepositSuccess && status === 'depositing') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isDepositSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDepositError && status === 'depositing') {
|
||||
setError(parseContractError(depositReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isDepositError, status, depositReceiptError])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
setPendingAmount('')
|
||||
resetApprove()
|
||||
resetDeposit()
|
||||
}, [resetApprove, resetDeposit])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isApprovePending || isApproveConfirming || isDepositPending || isDepositConfirming,
|
||||
approveHash,
|
||||
depositHash,
|
||||
executeApprove,
|
||||
executeDeposit,
|
||||
executeApproveAndDeposit,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
450
webapp/hooks/useHealthFactor.ts
Normal file
450
webapp/hooks/useHealthFactor.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useAccount, useReadContract, useReadContracts } from 'wagmi'
|
||||
import { formatUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Token } from '@/lib/api/tokens'
|
||||
import { useTokenList } from './useTokenList'
|
||||
|
||||
/**
|
||||
* 健康因子状态
|
||||
*/
|
||||
export type HealthFactorStatus = 'safe' | 'warning' | 'danger' | 'critical'
|
||||
|
||||
/**
|
||||
* 健康因子结果
|
||||
*/
|
||||
export interface HealthFactorResult {
|
||||
healthFactor: bigint
|
||||
formattedHealthFactor: string
|
||||
status: HealthFactorStatus
|
||||
utilization: number // 利用率百分比
|
||||
borrowValue: bigint // 借款价值(USD,1e18精度)
|
||||
collateralValue: bigint // 抵押品价值(USD,1e18精度)
|
||||
isLoading: boolean
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户健康因子
|
||||
*
|
||||
* 健康因子定义:
|
||||
* - > 1.5: 安全(绿色)
|
||||
* - 1.2 ~ 1.5: 警告(黄色)
|
||||
* - 1.0 ~ 1.2: 危险(橙色)
|
||||
* - < 1.0: 临界/可被清算(红色)
|
||||
* - MaxUint256: 无债务
|
||||
*/
|
||||
export function useHealthFactor(): HealthFactorResult {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
// 1. 获取用户基本信息(principal)
|
||||
const { data: userBasic, refetch: refetchUserBasic } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'userBasic',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!lendingProxyAddress,
|
||||
},
|
||||
})
|
||||
|
||||
const principal = userBasic !== undefined ? (userBasic as bigint) : 0n
|
||||
|
||||
// 如果没有借款(principal >= 0),提前返回
|
||||
const hasDebt = principal < 0n
|
||||
const shouldCalculate = hasDebt && !!address && !!lendingProxyAddress
|
||||
|
||||
// 2. 获取借款索引
|
||||
const { data: borrowIndex, refetch: refetchBorrowIndex } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'borrowIndex',
|
||||
query: {
|
||||
enabled: shouldCalculate,
|
||||
},
|
||||
})
|
||||
|
||||
// 3. 获取价格预言机地址
|
||||
const { data: priceFeedAddress } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'lendingPriceSource',
|
||||
query: {
|
||||
enabled: shouldCalculate,
|
||||
},
|
||||
})
|
||||
|
||||
// 4. 获取 baseToken 地址
|
||||
const { data: baseToken } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'baseToken',
|
||||
query: {
|
||||
enabled: shouldCalculate,
|
||||
},
|
||||
})
|
||||
|
||||
// 5. 直接获取资产列表(YT-A, YT-B, YT-C)
|
||||
const assetIndices = [0, 1, 2] as const
|
||||
|
||||
const assetListContracts = assetIndices.map((i) => ({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'assetList' as const,
|
||||
args: [BigInt(i)],
|
||||
}))
|
||||
|
||||
const { data: assetListData } = useReadContracts({
|
||||
contracts: assetListContracts as any,
|
||||
query: {
|
||||
enabled: shouldCalculate && assetListContracts.length > 0,
|
||||
},
|
||||
})
|
||||
|
||||
const assetList = assetListData?.map((item) => item.result as `0x${string}`).filter(Boolean) || []
|
||||
|
||||
// 7. 使用 useState 来管理计算结果
|
||||
const [result, setResult] = useState<HealthFactorResult>({
|
||||
healthFactor: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), // MaxUint256
|
||||
formattedHealthFactor: '∞',
|
||||
status: 'safe',
|
||||
utilization: 0,
|
||||
borrowValue: 0n,
|
||||
collateralValue: 0n,
|
||||
isLoading: true,
|
||||
refetch: () => {
|
||||
refetchUserBasic()
|
||||
refetchBorrowIndex()
|
||||
refetchCollateral()
|
||||
refetchPrices()
|
||||
},
|
||||
})
|
||||
|
||||
// 8. 获取所有抵押品余额和配置
|
||||
const collateralContracts = assetList.flatMap((asset) => [
|
||||
// 获取用户抵押品余额
|
||||
{
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'userCollateral' as const,
|
||||
args: [address, asset],
|
||||
},
|
||||
// 获取资产配置
|
||||
{
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'assetConfigs' as const,
|
||||
args: [asset],
|
||||
},
|
||||
])
|
||||
|
||||
const { data: collateralData, refetch: refetchCollateral } = useReadContracts({
|
||||
contracts: collateralContracts as any,
|
||||
query: {
|
||||
enabled: shouldCalculate && assetList.length > 0,
|
||||
},
|
||||
})
|
||||
|
||||
// 9. 获取所有资产价格(包括 baseToken)
|
||||
const priceContracts = [baseToken, ...assetList].filter(Boolean).map((token) => ({
|
||||
address: priceFeedAddress,
|
||||
abi: [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'getPrice',
|
||||
inputs: [{ name: 'asset', type: 'address' }],
|
||||
outputs: [{ name: '', type: 'uint256' }],
|
||||
stateMutability: 'view',
|
||||
},
|
||||
],
|
||||
functionName: 'getPrice' as const,
|
||||
args: [token],
|
||||
}))
|
||||
|
||||
const { data: priceData, refetch: refetchPrices } = useReadContracts({
|
||||
contracts: priceContracts as any,
|
||||
query: {
|
||||
enabled: shouldCalculate && !!priceFeedAddress && priceContracts.length > 0,
|
||||
},
|
||||
})
|
||||
|
||||
// 10. 计算健康因子
|
||||
useEffect(() => {
|
||||
if (!shouldCalculate) {
|
||||
// 没有债务,返回最大健康因子
|
||||
setResult({
|
||||
healthFactor: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
|
||||
formattedHealthFactor: '∞',
|
||||
status: 'safe',
|
||||
utilization: 0,
|
||||
borrowValue: 0n,
|
||||
collateralValue: 0n,
|
||||
isLoading: false,
|
||||
refetch: () => {
|
||||
refetchUserBasic()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!borrowIndex || !priceData || !collateralData || !baseToken || !chainId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算实际债务(principal * borrowIndex / 1e18)
|
||||
const balance = (principal * (borrowIndex as bigint)) / BigInt(1e18)
|
||||
const debt = -balance // 转为正数
|
||||
|
||||
// 获取 baseToken 价格(第一个价格)
|
||||
const basePrice = priceData[0]?.result as bigint
|
||||
if (!basePrice) return
|
||||
|
||||
// 计算债务价值(USD)
|
||||
const baseDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
|
||||
const debtValue = (debt * basePrice) / BigInt(10 ** baseDecimals)
|
||||
|
||||
if (debtValue === 0n) {
|
||||
setResult({
|
||||
healthFactor: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
|
||||
formattedHealthFactor: '∞',
|
||||
status: 'safe',
|
||||
utilization: 0,
|
||||
borrowValue: 0n,
|
||||
collateralValue: 0n,
|
||||
isLoading: false,
|
||||
refetch: () => {
|
||||
refetchUserBasic()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算抵押品总价值(使用 liquidateCollateralFactor)
|
||||
let totalCollateralValue = 0n
|
||||
|
||||
// assetConfigs 返回元组: [asset, decimals, borrowCollateralFactor, liquidateCollateralFactor, liquidationFactor, supplyCap]
|
||||
type AssetConfigTuple = readonly [string, number, bigint, bigint, bigint, bigint]
|
||||
|
||||
for (let i = 0; i < assetList.length; i++) {
|
||||
const collateralAmount = collateralData[i * 2]?.result as bigint
|
||||
const assetConfig = collateralData[i * 2 + 1]?.result as AssetConfigTuple | undefined
|
||||
const assetPrice = priceData[i + 1]?.result as bigint
|
||||
|
||||
if (collateralAmount > 0n && assetConfig && assetPrice) {
|
||||
// assetConfig[1] = decimals, assetConfig[3] = liquidateCollateralFactor
|
||||
const assetScale = 10n ** BigInt(Number(assetConfig[1]))
|
||||
const collateralValue = (collateralAmount * assetPrice) / assetScale
|
||||
|
||||
// 应用清算折扣系数(liquidateCollateralFactor)
|
||||
const discountedValue = (collateralValue * assetConfig[3]) / BigInt(1e18)
|
||||
totalCollateralValue += discountedValue
|
||||
}
|
||||
}
|
||||
|
||||
// 计算健康因子 = 抵押品价值 / 债务价值(1e18 精度)
|
||||
const healthFactor = (totalCollateralValue * BigInt(1e18)) / debtValue
|
||||
|
||||
// 计算利用率(债务/抵押品)
|
||||
const utilization = totalCollateralValue > 0n
|
||||
? Number((debtValue * BigInt(10000)) / totalCollateralValue) / 100
|
||||
: 0
|
||||
|
||||
// 确定健康因子状态
|
||||
let status: HealthFactorStatus
|
||||
const hfNumber = Number(formatUnits(healthFactor, 18))
|
||||
if (hfNumber >= 1.5) {
|
||||
status = 'safe'
|
||||
} else if (hfNumber >= 1.2) {
|
||||
status = 'warning'
|
||||
} else if (hfNumber >= 1.0) {
|
||||
status = 'danger'
|
||||
} else {
|
||||
status = 'critical'
|
||||
}
|
||||
|
||||
// 格式化健康因子显示
|
||||
const formattedHealthFactor = hfNumber >= 10
|
||||
? hfNumber.toFixed(1)
|
||||
: hfNumber >= 1
|
||||
? hfNumber.toFixed(2)
|
||||
: hfNumber.toFixed(3)
|
||||
|
||||
setResult({
|
||||
healthFactor,
|
||||
formattedHealthFactor,
|
||||
status,
|
||||
utilization,
|
||||
borrowValue: debtValue,
|
||||
collateralValue: totalCollateralValue,
|
||||
isLoading: false,
|
||||
refetch: () => {
|
||||
refetchUserBasic()
|
||||
refetchBorrowIndex()
|
||||
refetchCollateral()
|
||||
refetchPrices()
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Health factor calculation error:', error)
|
||||
setResult((prev) => ({ ...prev, isLoading: false }))
|
||||
}
|
||||
}, [
|
||||
shouldCalculate,
|
||||
principal,
|
||||
borrowIndex,
|
||||
priceData,
|
||||
collateralData,
|
||||
baseToken,
|
||||
chainId,
|
||||
assetList.length,
|
||||
refetchUserBasic,
|
||||
])
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户借款余额
|
||||
*/
|
||||
export function useBorrowBalance() {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: borrowBalance, refetch } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'borrowBalanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!lendingProxyAddress,
|
||||
},
|
||||
})
|
||||
|
||||
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
|
||||
const formattedBalance = borrowBalance ? formatUnits(borrowBalance as bigint, usdcDecimals) : '0.00'
|
||||
|
||||
return {
|
||||
balance: borrowBalance as bigint | undefined,
|
||||
formattedBalance,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在特定资产的抵押品余额
|
||||
*/
|
||||
export function useCollateralBalance(token: Token | undefined) {
|
||||
const { address, chainId } = useAccount()
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: collateralBalance, refetch } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'userCollateral',
|
||||
args: address && token?.contractAddress ? [address, token.contractAddress as `0x${string}`] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!lendingProxyAddress && !!token?.contractAddress,
|
||||
},
|
||||
})
|
||||
|
||||
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
|
||||
const formattedBalance = collateralBalance ? formatUnits(collateralBalance as bigint, decimals) : '0.00'
|
||||
|
||||
return {
|
||||
balance: collateralBalance as bigint | undefined,
|
||||
formattedBalance,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取协议利用率
|
||||
*/
|
||||
export function useProtocolUtilization() {
|
||||
const { chainId } = useAccount()
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: utilization } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'getUtilization',
|
||||
query: {
|
||||
enabled: !!lendingProxyAddress,
|
||||
},
|
||||
})
|
||||
|
||||
// utilization 是 1e18 精度的百分比
|
||||
const utilizationPercent = utilization ? Number(formatUnits(utilization as bigint, 18)) * 100 : 0
|
||||
|
||||
return {
|
||||
utilization: utilization as bigint | undefined,
|
||||
utilizationPercent,
|
||||
}
|
||||
}
|
||||
|
||||
const SECONDS_PER_YEAR = 365 * 24 * 60 * 60
|
||||
|
||||
function calcAPY(annualAprBigint: bigint | undefined): { apr: number; apy: number } {
|
||||
if (!annualAprBigint) return { apr: 0, apy: 0 }
|
||||
// getSupplyRate / getBorrowRate return annual APR with 1e18 precision
|
||||
const annualApr = Number(formatUnits(annualAprBigint, 18)) // e.g. 0.05 = 5%
|
||||
const perSecondRate = annualApr / SECONDS_PER_YEAR
|
||||
const apy = perSecondRate > 0
|
||||
? (Math.pow(1 + perSecondRate, SECONDS_PER_YEAR) - 1) * 100
|
||||
: 0
|
||||
return { apr: annualApr * 100, apy }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前供应 APY
|
||||
* getSupplyRate() 返回年化 APR(1e18 精度,uint64)
|
||||
*/
|
||||
export function useSupplyAPY() {
|
||||
const { chainId } = useAccount()
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: supplyRate } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'getSupplyRate',
|
||||
query: { enabled: !!lendingProxyAddress },
|
||||
})
|
||||
|
||||
const { apr, apy } = calcAPY(supplyRate as bigint | undefined)
|
||||
|
||||
return {
|
||||
supplyRate: supplyRate as bigint | undefined,
|
||||
apr,
|
||||
apy,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前借款 APR/APY
|
||||
* getBorrowRate() 返回年化 APR(1e18 精度,uint64)
|
||||
*/
|
||||
export function useBorrowAPR() {
|
||||
const { chainId } = useAccount()
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const { data: borrowRate } = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'getBorrowRate',
|
||||
query: { enabled: !!lendingProxyAddress },
|
||||
})
|
||||
|
||||
const { apr, apy } = calcAPY(borrowRate as bigint | undefined)
|
||||
|
||||
return {
|
||||
borrowRate: borrowRate as bigint | undefined,
|
||||
apr,
|
||||
apy,
|
||||
}
|
||||
}
|
||||
329
webapp/hooks/useLendingCollateral.ts
Normal file
329
webapp/hooks/useLendingCollateral.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi'
|
||||
import { parseUnits, formatUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { Token } from '@/lib/api/tokens'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
|
||||
type WithdrawCollateralStatus = 'idle' | 'withdrawing' | 'success' | 'error'
|
||||
|
||||
/**
|
||||
* Hook for withdrawing collateral (YT tokens) from lending protocol
|
||||
*/
|
||||
export function useWithdrawCollateral() {
|
||||
const { address, chainId } = useAccount()
|
||||
const [status, setStatus] = useState<WithdrawCollateralStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
writeContractAsync: withdrawWrite,
|
||||
data: withdrawHash,
|
||||
isPending: isWithdrawPending,
|
||||
reset: resetWithdraw,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isWithdrawConfirming,
|
||||
isSuccess: isWithdrawSuccess,
|
||||
isError: isWithdrawError,
|
||||
error: withdrawReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: withdrawHash })
|
||||
|
||||
const executeWithdrawCollateral = useCallback(async (token: Token, amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!address || !chainId || !token?.contractAddress || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('withdrawing')
|
||||
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
|
||||
if (!lendingProxyAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
const decimals = token.onChainDecimals ?? token.decimals
|
||||
const amountInWei = parseUnits(amount, decimals)
|
||||
await withdrawWrite({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'withdrawCollateral',
|
||||
args: [token.contractAddress as `0x${string}`, amountInWei],
|
||||
})
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Withdraw collateral failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, chainId, withdrawWrite, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawSuccess && status === 'withdrawing') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isWithdrawSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawError && status === 'withdrawing') {
|
||||
setError(parseContractError(withdrawReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isWithdrawError, status, withdrawReceiptError])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
resetWithdraw()
|
||||
}, [resetWithdraw])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isWithdrawPending || isWithdrawConfirming,
|
||||
withdrawHash,
|
||||
executeWithdrawCollateral,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
type CollateralStatus = 'idle' | 'approving' | 'approved' | 'supplying' | 'success' | 'error'
|
||||
|
||||
/**
|
||||
* Hook for supplying collateral (YT tokens) to lending protocol
|
||||
*/
|
||||
export function useLendingCollateral() {
|
||||
const { address, chainId } = useAccount()
|
||||
const [status, setStatus] = useState<CollateralStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pendingToken, setPendingToken] = useState<Token | null>(null)
|
||||
const [pendingAmount, setPendingAmount] = useState<string>('')
|
||||
|
||||
const {
|
||||
writeContractAsync: approveWrite,
|
||||
data: approveHash,
|
||||
isPending: isApprovePending,
|
||||
reset: resetApprove,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isApproveConfirming,
|
||||
isSuccess: isApproveSuccess,
|
||||
isError: isApproveError,
|
||||
error: approveReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: approveHash })
|
||||
|
||||
const {
|
||||
writeContractAsync: supplyWrite,
|
||||
data: supplyHash,
|
||||
isPending: isSupplyPending,
|
||||
reset: resetSupply,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isSupplyConfirming,
|
||||
isSuccess: isSupplySuccess,
|
||||
isError: isSupplyError,
|
||||
error: supplyReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: supplyHash })
|
||||
|
||||
const executeApprove = useCallback(async (token: Token, amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!address || !chainId || !token?.contractAddress || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('approving')
|
||||
|
||||
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
|
||||
if (!lendingProxyAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const decimals = token.onChainDecimals ?? token.decimals
|
||||
const amountInWei = parseUnits(amount, decimals)
|
||||
|
||||
await approveWrite({
|
||||
address: token.contractAddress as `0x${string}`,
|
||||
abi: abis.YTToken,
|
||||
functionName: 'approve',
|
||||
args: [lendingProxyAddress, amountInWei],
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Approve failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, chainId, approveWrite, status])
|
||||
|
||||
const executeSupplyCollateral = useCallback(async (token: Token, amount: string) => {
|
||||
if (status === 'supplying' || status === 'success' || status === 'error') return false
|
||||
if (!address || !chainId || !token?.contractAddress || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('supplying')
|
||||
|
||||
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
|
||||
if (!lendingProxyAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const decimals = token.onChainDecimals ?? token.decimals
|
||||
const amountInWei = parseUnits(amount, decimals)
|
||||
|
||||
await supplyWrite({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'supplyCollateral',
|
||||
args: [token.contractAddress as `0x${string}`, amountInWei],
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Supply collateral failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, chainId, supplyWrite, status])
|
||||
|
||||
const executeApproveAndSupply = useCallback(async (token: Token, amount: string) => {
|
||||
if (status !== 'idle') return
|
||||
setPendingToken(token)
|
||||
setPendingAmount(amount)
|
||||
const approveSuccess = await executeApprove(token, amount)
|
||||
if (!approveSuccess) return
|
||||
}, [executeApprove, status])
|
||||
|
||||
// Auto-execute supply when approve is successful
|
||||
useEffect(() => {
|
||||
if (isApproveSuccess && status === 'approving' && pendingToken && pendingAmount) {
|
||||
setStatus('approved')
|
||||
executeSupplyCollateral(pendingToken, pendingAmount)
|
||||
}
|
||||
}, [isApproveSuccess, status, pendingToken, pendingAmount, executeSupplyCollateral])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveError && status === 'approving') {
|
||||
setError(parseContractError(approveReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isApproveError, status, approveReceiptError])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupplySuccess && status === 'supplying') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isSupplySuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupplyError && status === 'supplying') {
|
||||
setError(parseContractError(supplyReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isSupplyError, status, supplyReceiptError])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
setPendingToken(null)
|
||||
setPendingAmount('')
|
||||
resetApprove()
|
||||
resetSupply()
|
||||
}, [resetApprove, resetSupply])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isApprovePending || isApproveConfirming || isSupplyPending || isSupplyConfirming,
|
||||
approveHash,
|
||||
supplyHash,
|
||||
executeApprove,
|
||||
executeSupplyCollateral,
|
||||
executeApproveAndSupply,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query user's wallet balance of a specific YT token (ERC20 balanceOf)
|
||||
*/
|
||||
export function useYTWalletBalance(token: Token | undefined) {
|
||||
const { address } = useAccount()
|
||||
|
||||
const { data: balance, isLoading, refetch } = useReadContract({
|
||||
address: token?.contractAddress as `0x${string}` | undefined,
|
||||
abi: abis.YTToken,
|
||||
functionName: 'balanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!token?.contractAddress,
|
||||
retry: false,
|
||||
},
|
||||
})
|
||||
|
||||
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
|
||||
const formattedBalance = balance
|
||||
? formatUnits(balance as bigint, decimals)
|
||||
: '0.00'
|
||||
|
||||
return {
|
||||
balance: balance as bigint || 0n,
|
||||
formattedBalance,
|
||||
isLoading,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query collateral balance for a specific YT token
|
||||
*/
|
||||
export function useCollateralBalance(token: Token | undefined) {
|
||||
const { address, chainId } = useAccount()
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const {
|
||||
data: balance,
|
||||
isLoading,
|
||||
refetch
|
||||
} = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'userCollateral',
|
||||
args: address && token?.contractAddress ? [address, token.contractAddress as `0x${string}`] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!lendingProxyAddress && !!token?.contractAddress,
|
||||
retry: false,
|
||||
},
|
||||
})
|
||||
|
||||
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
|
||||
const formattedBalance = balance
|
||||
? formatUnits(balance as bigint, decimals)
|
||||
: '0.00'
|
||||
|
||||
return {
|
||||
balance: balance as bigint || 0n,
|
||||
formattedBalance,
|
||||
isLoading,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
215
webapp/hooks/useLendingSupply.ts
Normal file
215
webapp/hooks/useLendingSupply.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi'
|
||||
import { parseUnits, formatUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { useTokenList } from './useTokenList'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
|
||||
type SupplyStatus = 'idle' | 'approving' | 'approved' | 'supplying' | 'success' | 'error'
|
||||
|
||||
export function useLendingSupply() {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const [status, setStatus] = useState<SupplyStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pendingAmount, setPendingAmount] = useState<string>('')
|
||||
|
||||
const {
|
||||
writeContractAsync: approveWrite,
|
||||
data: approveHash,
|
||||
isPending: isApprovePending,
|
||||
reset: resetApprove,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isApproveConfirming,
|
||||
isSuccess: isApproveSuccess,
|
||||
isError: isApproveError,
|
||||
error: approveReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: approveHash })
|
||||
|
||||
const {
|
||||
writeContractAsync: supplyWrite,
|
||||
data: supplyHash,
|
||||
isPending: isSupplyPending,
|
||||
reset: resetSupply,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isSupplyConfirming,
|
||||
isSuccess: isSupplySuccess,
|
||||
isError: isSupplyError,
|
||||
error: supplyReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: supplyHash })
|
||||
|
||||
const executeApprove = useCallback(async (amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!address || !chainId || !usdcToken?.contractAddress || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('approving')
|
||||
|
||||
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
|
||||
if (!lendingProxyAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
|
||||
const amountInWei = parseUnits(amount, usdcDecimals)
|
||||
|
||||
await approveWrite({
|
||||
address: usdcToken.contractAddress as `0x${string}`,
|
||||
abi: abis.USDY,
|
||||
functionName: 'approve',
|
||||
args: [lendingProxyAddress, amountInWei],
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Approve failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, chainId, approveWrite, usdcToken, status])
|
||||
|
||||
const executeSupply = useCallback(async (amount: string) => {
|
||||
if (status === 'supplying' || status === 'success' || status === 'error') return false
|
||||
if (!address || !chainId || !usdcToken || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('supplying')
|
||||
|
||||
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
|
||||
if (!lendingProxyAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
|
||||
const amountInWei = parseUnits(amount, usdcDecimals)
|
||||
|
||||
await supplyWrite({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'supply',
|
||||
args: [amountInWei],
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Supply failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, chainId, supplyWrite, usdcToken, status])
|
||||
|
||||
const executeApproveAndSupply = useCallback(async (amount: string) => {
|
||||
if (status !== 'idle') return
|
||||
setPendingAmount(amount)
|
||||
const approveSuccess = await executeApprove(amount)
|
||||
if (!approveSuccess) return
|
||||
}, [executeApprove, status])
|
||||
|
||||
// Auto-execute supply when approve is successful
|
||||
useEffect(() => {
|
||||
if (isApproveSuccess && status === 'approving' && pendingAmount) {
|
||||
setStatus('approved')
|
||||
executeSupply(pendingAmount)
|
||||
}
|
||||
}, [isApproveSuccess, status, pendingAmount, executeSupply])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveError && status === 'approving') {
|
||||
setError(parseContractError(approveReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isApproveError, status, approveReceiptError])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupplySuccess && status === 'supplying') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isSupplySuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSupplyError && status === 'supplying') {
|
||||
setError(parseContractError(supplyReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isSupplyError, status, supplyReceiptError])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
setPendingAmount('')
|
||||
resetApprove()
|
||||
resetSupply()
|
||||
}, [resetApprove, resetSupply])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isApprovePending || isApproveConfirming || isSupplyPending || isSupplyConfirming,
|
||||
approveHash,
|
||||
supplyHash,
|
||||
executeApprove,
|
||||
executeSupply,
|
||||
executeApproveAndSupply,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
// Query supplied balance
|
||||
export function useSuppliedBalance() {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
|
||||
|
||||
const {
|
||||
data: balance,
|
||||
error: queryError,
|
||||
isError,
|
||||
isLoading,
|
||||
refetch
|
||||
} = useReadContract({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'supplyBalanceOf',
|
||||
args: address ? [address] : undefined,
|
||||
query: {
|
||||
enabled: !!address && !!lendingProxyAddress,
|
||||
retry: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (isError && queryError) {
|
||||
console.error('[useSuppliedBalance] Query error:', queryError)
|
||||
}
|
||||
|
||||
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
|
||||
const suppliedBalance = balance ? (balance as bigint) : 0n
|
||||
const formattedBalance = suppliedBalance > 0n
|
||||
? (Number(suppliedBalance) / Math.pow(10, usdcDecimals)).toFixed(2)
|
||||
: '0.00'
|
||||
|
||||
return {
|
||||
balance: suppliedBalance,
|
||||
formattedBalance,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
97
webapp/hooks/useLendingWithdraw.ts
Normal file
97
webapp/hooks/useLendingWithdraw.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { useTokenList } from './useTokenList'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
import { WITHDRAW_GAS_LIMIT } from '@/lib/constants'
|
||||
|
||||
type WithdrawStatus = 'idle' | 'withdrawing' | 'success' | 'error'
|
||||
|
||||
export function useLendingWithdraw() {
|
||||
const { address, chainId } = useAccount()
|
||||
const { bySymbol } = useTokenList()
|
||||
const usdcToken = bySymbol['USDC']
|
||||
const [status, setStatus] = useState<WithdrawStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
writeContractAsync: withdrawWrite,
|
||||
data: withdrawHash,
|
||||
isPending: isWithdrawPending,
|
||||
reset: resetWithdraw,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isWithdrawConfirming,
|
||||
isSuccess: isWithdrawSuccess,
|
||||
isError: isWithdrawError,
|
||||
error: withdrawReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: withdrawHash })
|
||||
|
||||
const executeWithdraw = useCallback(async (amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!address || !chainId || !usdcToken || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('withdrawing')
|
||||
|
||||
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
|
||||
if (!lendingProxyAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
|
||||
const amountInWei = parseUnits(amount, usdcDecimals)
|
||||
|
||||
await withdrawWrite({
|
||||
address: lendingProxyAddress,
|
||||
abi: abis.lendingProxy,
|
||||
functionName: 'withdraw',
|
||||
args: [amountInWei],
|
||||
gas: WITHDRAW_GAS_LIMIT,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Withdraw failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, chainId, withdrawWrite, usdcToken, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawSuccess && status === 'withdrawing') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isWithdrawSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawError && status === 'withdrawing') {
|
||||
setError(parseContractError(withdrawReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isWithdrawError, status, withdrawReceiptError])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
resetWithdraw()
|
||||
}, [resetWithdraw])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isWithdrawPending || isWithdrawConfirming,
|
||||
withdrawHash,
|
||||
executeWithdraw,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
177
webapp/hooks/usePoolDeposit.ts
Normal file
177
webapp/hooks/usePoolDeposit.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
import { SWAP_GAS_LIMIT } from '@/lib/constants'
|
||||
|
||||
type DepositStatus = 'idle' | 'approving' | 'approved' | 'depositing' | 'success' | 'error'
|
||||
|
||||
/**
|
||||
* Hook for adding liquidity to YT Pool
|
||||
* Accepts token contract address and decimals directly — no hardcoded token symbols.
|
||||
*/
|
||||
export function usePoolDeposit() {
|
||||
const { address, chainId } = useAccount()
|
||||
const [status, setStatus] = useState<DepositStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [approveHash, setApproveHash] = useState<`0x${string}` | undefined>()
|
||||
const [depositHash, setDepositHash] = useState<`0x${string}` | undefined>()
|
||||
|
||||
const { writeContractAsync: approveWrite } = useWriteContract()
|
||||
const { writeContractAsync: depositWrite } = useWriteContract()
|
||||
|
||||
const { isLoading: isApproving, isSuccess: isApproveSuccess, isError: isApproveError, error: approveReceiptError } = useWaitForTransactionReceipt({
|
||||
hash: approveHash,
|
||||
})
|
||||
|
||||
const { isLoading: isDepositing, isSuccess: isDepositSuccess, isError: isDepositError, error: depositReceiptError } = useWaitForTransactionReceipt({
|
||||
hash: depositHash,
|
||||
})
|
||||
|
||||
const executeApprove = async (tokenAddress: string, tokenDecimals: number, amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!chainId) {
|
||||
setError('Please connect your wallet')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('approving')
|
||||
setError(null)
|
||||
|
||||
const rewardRouterAddress = getContractAddress('YTRewardRouter', chainId)
|
||||
|
||||
if (!tokenAddress || !rewardRouterAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const amountInWei = parseUnits(amount, tokenDecimals)
|
||||
|
||||
const hash = await approveWrite({
|
||||
address: tokenAddress as `0x${string}`,
|
||||
abi: abis.USDY,
|
||||
functionName: 'approve',
|
||||
args: [rewardRouterAddress, amountInWei],
|
||||
})
|
||||
|
||||
setApproveHash(hash)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Approval failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const executeDeposit = async (
|
||||
tokenAddress: string,
|
||||
tokenDecimals: number,
|
||||
amount: string,
|
||||
minUsdy: string = '0',
|
||||
minYtLP: string = '0'
|
||||
) => {
|
||||
if (status === 'depositing' || status === 'success' || status === 'error') return false
|
||||
if (!chainId) {
|
||||
setError('Please connect your wallet')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('depositing')
|
||||
setError(null)
|
||||
|
||||
const rewardRouterAddress = getContractAddress('YTRewardRouter', chainId)
|
||||
|
||||
if (!tokenAddress || !rewardRouterAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const amountInWei = parseUnits(amount, tokenDecimals)
|
||||
const minUsdyInWei = parseUnits(minUsdy, 18)
|
||||
const minYtLPInWei = parseUnits(minYtLP, 18)
|
||||
|
||||
const hash = await depositWrite({
|
||||
address: rewardRouterAddress,
|
||||
abi: abis.YTRewardRouter,
|
||||
functionName: 'addLiquidity',
|
||||
args: [tokenAddress as `0x${string}`, amountInWei, minUsdyInWei, minYtLPInWei],
|
||||
gas: SWAP_GAS_LIMIT,
|
||||
})
|
||||
|
||||
setDepositHash(hash)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Transaction failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const executeApproveAndDeposit = async (
|
||||
tokenAddress: string,
|
||||
tokenDecimals: number,
|
||||
amount: string,
|
||||
minUsdy: string = '0',
|
||||
minYtLP: string = '0'
|
||||
) => {
|
||||
if (status !== 'idle') return
|
||||
const approved = await executeApprove(tokenAddress, tokenDecimals, amount)
|
||||
if (!approved) return
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
await executeDeposit(tokenAddress, tokenDecimals, amount, minUsdy, minYtLP)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
setApproveHash(undefined)
|
||||
setDepositHash(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveSuccess && status === 'approving') {
|
||||
setStatus('approved')
|
||||
}
|
||||
}, [isApproveSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveError && status === 'approving') {
|
||||
setError(parseContractError(approveReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isApproveError, status, approveReceiptError])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDepositSuccess && status === 'depositing') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isDepositSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDepositError && status === 'depositing') {
|
||||
setError(parseContractError(depositReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isDepositError, status, depositReceiptError])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isApproving || isDepositing,
|
||||
approveHash,
|
||||
depositHash,
|
||||
executeApprove,
|
||||
executeDeposit,
|
||||
executeApproveAndDeposit,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
175
webapp/hooks/usePoolWithdraw.ts
Normal file
175
webapp/hooks/usePoolWithdraw.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
import { SWAP_GAS_LIMIT } from '@/lib/constants'
|
||||
|
||||
type WithdrawStatus = 'idle' | 'approving' | 'approved' | 'withdrawing' | 'success' | 'error'
|
||||
|
||||
/**
|
||||
* Hook for removing liquidity from YT Pool
|
||||
* User burns YT LP tokens and receives USDC back
|
||||
*/
|
||||
export function usePoolWithdraw() {
|
||||
const { address, chainId } = useAccount()
|
||||
const [status, setStatus] = useState<WithdrawStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [approveHash, setApproveHash] = useState<`0x${string}` | undefined>()
|
||||
const [withdrawHash, setWithdrawHash] = useState<`0x${string}` | undefined>()
|
||||
|
||||
const { writeContractAsync: approveWrite } = useWriteContract()
|
||||
const { writeContractAsync: withdrawWrite } = useWriteContract()
|
||||
|
||||
const { isLoading: isApproving, isSuccess: isApproveSuccess, isError: isApproveError, error: approveReceiptError } = useWaitForTransactionReceipt({
|
||||
hash: approveHash,
|
||||
})
|
||||
|
||||
const { isLoading: isWithdrawing, isSuccess: isWithdrawSuccess, isError: isWithdrawError, error: withdrawReceiptError } = useWaitForTransactionReceipt({
|
||||
hash: withdrawHash,
|
||||
})
|
||||
|
||||
const executeApprove = async (amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!chainId) {
|
||||
setError('Please connect your wallet')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('approving')
|
||||
setError(null)
|
||||
|
||||
const ytLPAddress = getContractAddress('YTLPToken', chainId)
|
||||
const rewardRouterAddress = getContractAddress('YTRewardRouter', chainId)
|
||||
|
||||
if (!ytLPAddress || !rewardRouterAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const amountInWei = parseUnits(amount, 18)
|
||||
|
||||
const hash = await approveWrite({
|
||||
address: ytLPAddress,
|
||||
abi: abis.YTLPToken,
|
||||
functionName: 'approve',
|
||||
args: [rewardRouterAddress, amountInWei],
|
||||
})
|
||||
|
||||
setApproveHash(hash)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Approval failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const executeWithdraw = async (
|
||||
tokenOutAddress: string,
|
||||
tokenOutDecimals: number,
|
||||
amount: string,
|
||||
minOut: string = '0'
|
||||
) => {
|
||||
if (status === 'withdrawing' || status === 'success' || status === 'error') return false
|
||||
if (!chainId || !address) {
|
||||
setError('Please connect your wallet')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('withdrawing')
|
||||
setError(null)
|
||||
|
||||
const rewardRouterAddress = getContractAddress('YTRewardRouter', chainId)
|
||||
|
||||
if (!tokenOutAddress || !rewardRouterAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const amountInWei = parseUnits(amount, 18)
|
||||
const minOutWei = parseUnits(minOut, tokenOutDecimals)
|
||||
|
||||
const hash = await withdrawWrite({
|
||||
address: rewardRouterAddress,
|
||||
abi: abis.YTRewardRouter,
|
||||
functionName: 'removeLiquidity',
|
||||
args: [tokenOutAddress as `0x${string}`, amountInWei, minOutWei, address],
|
||||
gas: SWAP_GAS_LIMIT,
|
||||
})
|
||||
|
||||
setWithdrawHash(hash)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Transaction failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const executeApproveAndWithdraw = async (
|
||||
tokenOutAddress: string,
|
||||
tokenOutDecimals: number,
|
||||
amount: string,
|
||||
minOut: string = '0'
|
||||
) => {
|
||||
if (status !== 'idle') return
|
||||
const approved = await executeApprove(amount)
|
||||
if (!approved) return
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
await executeWithdraw(tokenOutAddress, tokenOutDecimals, amount, minOut)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
setApproveHash(undefined)
|
||||
setWithdrawHash(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveSuccess && status === 'approving') {
|
||||
setStatus('approved')
|
||||
}
|
||||
}, [isApproveSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveError && status === 'approving') {
|
||||
setError(parseContractError(approveReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isApproveError, status, approveReceiptError])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawSuccess && status === 'withdrawing') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isWithdrawSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawError && status === 'withdrawing') {
|
||||
setError(parseContractError(withdrawReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isWithdrawError, status, withdrawReceiptError])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isApproving || isWithdrawing,
|
||||
approveHash,
|
||||
withdrawHash,
|
||||
executeApprove,
|
||||
executeWithdraw,
|
||||
executeApproveAndWithdraw,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
179
webapp/hooks/useSwap.ts
Normal file
179
webapp/hooks/useSwap.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits } from 'viem'
|
||||
import { abis, getContractAddress } from '@/lib/contracts'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
import { SWAP_GAS_LIMIT } from '@/lib/constants'
|
||||
|
||||
type SwapStatus = 'idle' | 'approving' | 'approved' | 'swapping' | 'success' | 'error'
|
||||
|
||||
/**
|
||||
* Hook for swapping tokens via YTRewardRouter
|
||||
* Accepts contract addresses and decimals directly — no hardcoded token symbols.
|
||||
*/
|
||||
export function useSwap() {
|
||||
const { address, chainId } = useAccount()
|
||||
const [status, setStatus] = useState<SwapStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [approveHash, setApproveHash] = useState<`0x${string}` | undefined>()
|
||||
const [swapHash, setSwapHash] = useState<`0x${string}` | undefined>()
|
||||
|
||||
const { writeContractAsync: approveWrite } = useWriteContract()
|
||||
const { writeContractAsync: swapWrite } = useWriteContract()
|
||||
|
||||
const { isLoading: isApproving, isSuccess: isApproveSuccess, isError: isApproveError, error: approveReceiptError } = useWaitForTransactionReceipt({
|
||||
hash: approveHash,
|
||||
})
|
||||
|
||||
const { isLoading: isSwapping, isSuccess: isSwapSuccess, isError: isSwapError, error: swapReceiptError } = useWaitForTransactionReceipt({
|
||||
hash: swapHash,
|
||||
})
|
||||
|
||||
const executeApprove = async (tokenInAddress: string, tokenInDecimals: number, amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!chainId || !address) {
|
||||
setError('Please connect your wallet')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('approving')
|
||||
setError(null)
|
||||
|
||||
const rewardRouterAddress = getContractAddress('YTRewardRouter', chainId)
|
||||
|
||||
if (!tokenInAddress || !rewardRouterAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const amountInWei = parseUnits(amount, tokenInDecimals)
|
||||
|
||||
const hash = await approveWrite({
|
||||
address: tokenInAddress as `0x${string}`,
|
||||
abi: abis.USDY,
|
||||
functionName: 'approve',
|
||||
args: [rewardRouterAddress, amountInWei],
|
||||
})
|
||||
|
||||
setApproveHash(hash)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Approval failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const executeSwap = async (
|
||||
tokenInAddress: string,
|
||||
tokenInDecimals: number,
|
||||
tokenOutAddress: string,
|
||||
tokenOutDecimals: number,
|
||||
amount: string,
|
||||
minOut: string = '0'
|
||||
) => {
|
||||
if (status === 'swapping' || status === 'success' || status === 'error') return false
|
||||
if (!chainId || !address) {
|
||||
setError('Please connect your wallet')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('swapping')
|
||||
setError(null)
|
||||
|
||||
const rewardRouterAddress = getContractAddress('YTRewardRouter', chainId)
|
||||
|
||||
if (!tokenInAddress || !tokenOutAddress || !rewardRouterAddress) {
|
||||
throw new Error('Contract not deployed on this chain')
|
||||
}
|
||||
|
||||
const amountInWei = parseUnits(amount, tokenInDecimals)
|
||||
const minOutWei = parseUnits(minOut, tokenOutDecimals)
|
||||
|
||||
const hash = await swapWrite({
|
||||
address: rewardRouterAddress,
|
||||
abi: abis.YTRewardRouter,
|
||||
functionName: 'swapYT',
|
||||
args: [tokenInAddress as `0x${string}`, tokenOutAddress as `0x${string}`, amountInWei, minOutWei, address],
|
||||
gas: SWAP_GAS_LIMIT,
|
||||
type: 'legacy',
|
||||
})
|
||||
|
||||
setSwapHash(hash)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Swap failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const executeApproveAndSwap = async (
|
||||
tokenInAddress: string,
|
||||
tokenInDecimals: number,
|
||||
tokenOutAddress: string,
|
||||
tokenOutDecimals: number,
|
||||
amount: string,
|
||||
minOut: string = '0'
|
||||
) => {
|
||||
if (status !== 'idle') return
|
||||
const approved = await executeApprove(tokenInAddress, tokenInDecimals, amount)
|
||||
if (!approved) return
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
await executeSwap(tokenInAddress, tokenInDecimals, tokenOutAddress, tokenOutDecimals, amount, minOut)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
setApproveHash(undefined)
|
||||
setSwapHash(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveSuccess && status === 'approving') {
|
||||
setStatus('approved')
|
||||
}
|
||||
}, [isApproveSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveError && status === 'approving') {
|
||||
setError(parseContractError(approveReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isApproveError, status, approveReceiptError])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSwapSuccess && status === 'swapping') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isSwapSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSwapError && status === 'swapping') {
|
||||
setError(parseContractError(swapReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isSwapError, status, swapReceiptError])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isApproving || isSwapping,
|
||||
approveHash,
|
||||
swapHash,
|
||||
executeApprove,
|
||||
executeSwap,
|
||||
executeApproveAndSwap,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
7
webapp/hooks/useTokenBySymbol.ts
Normal file
7
webapp/hooks/useTokenBySymbol.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useTokenList } from './useTokenList'
|
||||
import { Token } from '@/lib/api/tokens'
|
||||
|
||||
export function useTokenBySymbol(symbol: string): Token | undefined {
|
||||
const { bySymbol } = useTokenList()
|
||||
return bySymbol[symbol]
|
||||
}
|
||||
38
webapp/hooks/useTokenDecimals.ts
Normal file
38
webapp/hooks/useTokenDecimals.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useReadContract } from 'wagmi'
|
||||
|
||||
/** 最小 ERC20 decimals ABI,无需引入完整 ABI 文件 */
|
||||
const DECIMALS_ABI = [
|
||||
{
|
||||
inputs: [],
|
||||
name: 'decimals',
|
||||
outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }],
|
||||
stateMutability: 'view',
|
||||
type: 'function',
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 从合约地址直接读取精度(适用于产品 API 返回 contractAddress 的场景)
|
||||
* 优先从合约 decimals() 读取,失败时回退到 fallback(通常是 token.decimals)
|
||||
*
|
||||
* @param contractAddress 合约地址,直接来自产品/Token 对象
|
||||
* @param fallback 备用精度(来自 token.decimals 或链配置)
|
||||
*/
|
||||
export function useTokenDecimalsFromAddress(
|
||||
contractAddress: string | undefined,
|
||||
fallback: number = 18
|
||||
): number {
|
||||
const { data, isError } = useReadContract({
|
||||
address: contractAddress as `0x${string}` | undefined,
|
||||
abi: DECIMALS_ABI,
|
||||
functionName: 'decimals',
|
||||
query: {
|
||||
enabled: !!contractAddress,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: 2,
|
||||
},
|
||||
})
|
||||
|
||||
return (data !== undefined && !isError) ? Number(data) : fallback
|
||||
}
|
||||
96
webapp/hooks/useTokenList.ts
Normal file
96
webapp/hooks/useTokenList.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useReadContracts } from 'wagmi'
|
||||
import { fetchProducts, Product } from '@/lib/api/fundmarket'
|
||||
import { Token } from '@/lib/api/tokens'
|
||||
import { getDynamicOverride, getDynamicOverrideByName } from '@/lib/contracts/registry'
|
||||
|
||||
const ERC20_DECIMALS_ABI = [
|
||||
{ name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
|
||||
] as const
|
||||
|
||||
function productToToken(p: Product): Token {
|
||||
return {
|
||||
symbol: p.tokenSymbol,
|
||||
name: p.name,
|
||||
decimals: p.decimals ?? 18,
|
||||
iconUrl: p.iconUrl,
|
||||
contractAddress: p.contractAddress ?? '',
|
||||
chainId: p.chainId ?? 0,
|
||||
tokenType: p.token_role === 'stablecoin' ? 'stablecoin' : 'yield-token',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global token list hook — fetches once, cached 5 min.
|
||||
* All components share the same ['token-list'] React Query cache.
|
||||
* Each token has onChainDecimals (live from chain) with decimals as fallback.
|
||||
*/
|
||||
export function useTokenList() {
|
||||
const { data: products = [], isLoading: isProductsLoading } = useQuery({
|
||||
queryKey: ['token-list'],
|
||||
queryFn: () => fetchProducts(true),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const baseTokens: Token[] = useMemo(
|
||||
() => products
|
||||
.filter(p => p.token_role === 'stablecoin' || p.token_role === 'yt_token')
|
||||
.map(productToToken),
|
||||
[products]
|
||||
)
|
||||
|
||||
// 批量读取每个 token 的链上 decimals,按 token 配置的 chainId 发起请求
|
||||
// 仅包含有合约地址的 token,记录原始索引以便回写
|
||||
const tokensWithAddress = useMemo(
|
||||
() => baseTokens
|
||||
.map((t, i) => ({ t, i }))
|
||||
.filter(({ t }) => !!t.contractAddress),
|
||||
[baseTokens]
|
||||
)
|
||||
|
||||
const decimalsContracts = useMemo(
|
||||
() => tokensWithAddress.map(({ t }) => ({
|
||||
address: t.contractAddress as `0x${string}`,
|
||||
abi: ERC20_DECIMALS_ABI,
|
||||
functionName: 'decimals' as const,
|
||||
chainId: t.chainId,
|
||||
})),
|
||||
[tokensWithAddress]
|
||||
)
|
||||
|
||||
const { data: onChainData } = useReadContracts({
|
||||
contracts: decimalsContracts,
|
||||
query: { enabled: decimalsContracts.length > 0 },
|
||||
})
|
||||
|
||||
// 将链上精度回写到对应 token(按原始索引匹配,避免过滤后索引错位)
|
||||
const onChainDecimalsMap = new Map<number, number>()
|
||||
tokensWithAddress.forEach(({ i }, j) => {
|
||||
if (onChainData?.[j]?.status === 'success') {
|
||||
onChainDecimalsMap.set(i, Number(onChainData[j].result))
|
||||
}
|
||||
})
|
||||
|
||||
// 合并链上精度 + 合约地址(contractAddress 为空时回退到注册表)
|
||||
const tokens: Token[] = baseTokens.map((t, i) => {
|
||||
// DB 未配置地址/chainId 时,先按精确 chainId 查注册表,再按 name 跨链搜索兜底
|
||||
let contractAddress = t.contractAddress || getDynamicOverride(t.symbol, t.chainId) || ''
|
||||
let chainId = t.chainId
|
||||
if (!contractAddress) {
|
||||
const found = getDynamicOverrideByName(t.symbol)
|
||||
if (found) {
|
||||
contractAddress = found.address
|
||||
chainId = found.chainId // 同时修正 chainId
|
||||
}
|
||||
}
|
||||
const onChainDecimals = onChainDecimalsMap.has(i) ? onChainDecimalsMap.get(i) : undefined
|
||||
return { ...t, contractAddress, chainId, onChainDecimals }
|
||||
})
|
||||
|
||||
const stablecoins = tokens.filter(t => t.tokenType === 'stablecoin')
|
||||
const yieldTokens = tokens.filter(t => t.tokenType === 'yield-token')
|
||||
const bySymbol: Record<string, Token> = Object.fromEntries(tokens.map(t => [t.symbol, t]))
|
||||
|
||||
return { tokens, stablecoins, yieldTokens, bySymbol, isLoading: isProductsLoading }
|
||||
}
|
||||
35
webapp/hooks/useWalletStatus.ts
Normal file
35
webapp/hooks/useWalletStatus.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useAccount, useChainId } from 'wagmi'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 获取钱包连接状态的 Hook
|
||||
* 带有客户端渲染保护
|
||||
*/
|
||||
export function useWalletStatus() {
|
||||
const { address, isConnected, isConnecting } = useAccount()
|
||||
const chainId = useChainId()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 在 SSR 期间或未 mounted 时返回安全的默认值
|
||||
if (!mounted) {
|
||||
return {
|
||||
address: undefined,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
chainId: undefined,
|
||||
mounted: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
chainId,
|
||||
mounted: true,
|
||||
}
|
||||
}
|
||||
167
webapp/hooks/useWithdraw.ts
Normal file
167
webapp/hooks/useWithdraw.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits } from 'viem'
|
||||
import { abis } from '@/lib/contracts'
|
||||
import { Token } from '@/lib/api/tokens'
|
||||
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
|
||||
import { DEFAULT_GAS_LIMIT } from '@/lib/constants'
|
||||
|
||||
type WithdrawStatus = 'idle' | 'approving' | 'approved' | 'withdrawing' | 'success' | 'error'
|
||||
|
||||
export function useWithdraw(ytToken: Token | undefined) {
|
||||
const { address } = useAccount()
|
||||
const [status, setStatus] = useState<WithdrawStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pendingAmount, setPendingAmount] = useState<string>('')
|
||||
const [requestId, setRequestId] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
writeContractAsync: approveWrite,
|
||||
data: approveHash,
|
||||
isPending: isApprovePending,
|
||||
reset: resetApprove,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isApproveConfirming,
|
||||
isSuccess: isApproveSuccess,
|
||||
isError: isApproveError,
|
||||
error: approveReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: approveHash })
|
||||
|
||||
const {
|
||||
writeContractAsync: withdrawWrite,
|
||||
data: withdrawHash,
|
||||
isPending: isWithdrawPending,
|
||||
reset: resetWithdraw,
|
||||
} = useWriteContract()
|
||||
|
||||
const {
|
||||
isLoading: isWithdrawConfirming,
|
||||
isSuccess: isWithdrawSuccess,
|
||||
isError: isWithdrawError,
|
||||
error: withdrawReceiptError,
|
||||
} = useWaitForTransactionReceipt({ hash: withdrawHash })
|
||||
|
||||
const executeApprove = useCallback(async (amount: string) => {
|
||||
if (status !== 'idle') return false
|
||||
if (!address || !ytToken?.contractAddress || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('approving')
|
||||
|
||||
const decimals = ytToken.onChainDecimals ?? ytToken.decimals
|
||||
const amountInWei = parseUnits(amount, decimals)
|
||||
|
||||
await approveWrite({
|
||||
address: ytToken.contractAddress as `0x${string}`,
|
||||
abi: abis.YTToken,
|
||||
functionName: 'approve',
|
||||
args: [ytToken.contractAddress as `0x${string}`, amountInWei],
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Approve failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, approveWrite, ytToken, status])
|
||||
|
||||
const executeWithdraw = useCallback(async (amount: string) => {
|
||||
if (status === 'withdrawing' || status === 'success' || status === 'error') return false
|
||||
if (!address || !ytToken?.contractAddress || !amount) {
|
||||
setError('Missing required parameters')
|
||||
return false
|
||||
}
|
||||
if (!isValidAmount(amount)) {
|
||||
setError('Invalid amount')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setStatus('withdrawing')
|
||||
|
||||
const decimals = ytToken.onChainDecimals ?? ytToken.decimals
|
||||
const amountInWei = parseUnits(amount, decimals)
|
||||
|
||||
await withdrawWrite({
|
||||
address: ytToken.contractAddress as `0x${string}`,
|
||||
abi: abis.YTToken,
|
||||
functionName: 'withdrawYT',
|
||||
args: [amountInWei],
|
||||
gas: DEFAULT_GAS_LIMIT,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
handleContractCatchError(err, 'Withdraw failed', setError, setStatus as (s: string) => void)
|
||||
return false
|
||||
}
|
||||
}, [address, withdrawWrite, ytToken, status])
|
||||
|
||||
const executeApproveAndWithdraw = useCallback(async (amount: string) => {
|
||||
if (status !== 'idle') return
|
||||
setPendingAmount(amount)
|
||||
const approveSuccess = await executeApprove(amount)
|
||||
if (!approveSuccess) return
|
||||
}, [executeApprove, status])
|
||||
|
||||
// Auto-execute withdraw when approve is successful
|
||||
useEffect(() => {
|
||||
if (isApproveSuccess && status === 'approving' && pendingAmount) {
|
||||
setStatus('approved')
|
||||
executeWithdraw(pendingAmount)
|
||||
}
|
||||
}, [isApproveSuccess, status, pendingAmount, executeWithdraw])
|
||||
|
||||
useEffect(() => {
|
||||
if (isApproveError && status === 'approving') {
|
||||
setError(parseContractError(approveReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isApproveError, status, approveReceiptError])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawSuccess && status === 'withdrawing') {
|
||||
setStatus('success')
|
||||
}
|
||||
}, [isWithdrawSuccess, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (isWithdrawError && status === 'withdrawing') {
|
||||
setError(parseContractError(withdrawReceiptError))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [isWithdrawError, status, withdrawReceiptError])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
setPendingAmount('')
|
||||
setRequestId(null)
|
||||
resetApprove()
|
||||
resetWithdraw()
|
||||
}, [resetApprove, resetWithdraw])
|
||||
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
isLoading: isApprovePending || isApproveConfirming || isWithdrawPending || isWithdrawConfirming,
|
||||
approveHash,
|
||||
withdrawHash,
|
||||
requestId,
|
||||
executeApprove,
|
||||
executeWithdraw,
|
||||
executeApproveAndWithdraw,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user