diff --git a/src/pages/stakingPage/components/RewardsCard.tsx b/src/pages/stakingPage/components/RewardsCard.tsx index 3012995..9051f46 100644 --- a/src/pages/stakingPage/components/RewardsCard.tsx +++ b/src/pages/stakingPage/components/RewardsCard.tsx @@ -103,6 +103,7 @@ export const RewardsCard = ({ data, refetchData }: RewardsCardProps): JSX.Elemen bg="gray.950" borderRadius="md" p={4} + minW="360px" height="fit-content" border="1px solid" borderColor="gray.900" @@ -111,36 +112,24 @@ export const RewardsCard = ({ data, refetchData }: RewardsCardProps): JSX.Elemen - + WETH - + ≤{(0.0001).toFixed(4)} WETH (≤${(0.01).toFixed(2)}) - - - Staked Amount - - - - {data?.stakedAmount ? Number(data.stakedAmount).toFixed(4) : '0.0000'} GRIX - - - - - - + Claimable Rewards - + {data?.claimable ? Number(data.claimable).toFixed(4) : '0.0000'} esGRIX {grixPrice && data?.claimable && ( diff --git a/src/pages/stakingPage/components/VestingCard.tsx b/src/pages/stakingPage/components/VestingCard.tsx index 23d7922..309b328 100644 --- a/src/pages/stakingPage/components/VestingCard.tsx +++ b/src/pages/stakingPage/components/VestingCard.tsx @@ -1,4 +1,4 @@ -import { Box, Button, HStack, Input, InputGroup, InputRightElement, useToast, VStack } from '@chakra-ui/react'; +import { Box, useDisclosure, useToast, VStack } from '@chakra-ui/react'; import React, { useCallback, useEffect, useState } from 'react'; import { parseEther } from 'viem'; import { useAccount } from 'wagmi'; @@ -18,16 +18,26 @@ import { import { VestingHeader } from './VestingComponents/VestingHeader'; import { VestingInfo } from './VestingComponents/VestingInfo'; import { VestingStats } from './VestingComponents/VestingStats'; +import { VestingModal } from './VestingModal'; +import { WithdrawModal } from './WithdrawModal'; type VestingCardProps = { onActionComplete?: () => void; + userRewardData?: { + claimable: string; + stakedAmount: string; + cumulativeRewards: string; + averageStaked: string; + } | null; }; -export const VestingCard: React.FC = ({ onActionComplete }) => { +export const VestingCard: React.FC = ({ onActionComplete, userRewardData }) => { + const { isOpen: isVestingOpen, onOpen: onVestingOpen, onClose: onVestingClose } = useDisclosure(); + const { isOpen: isWithdrawOpen, onOpen: onWithdrawOpen, onClose: onWithdrawClose } = useDisclosure(); const { address } = useAccount(); const toast = useToast(); - const [amount, setAmount] = useState(''); const [esGrixBalance, setEsGrixBalance] = useState('0'); + const [grixBalance, setGrixBalance] = useState('0'); const [isApproving, setIsApproving] = useState(false); const [isVesting, setIsVesting] = useState(false); const [needsApproval, setNeedsApproval] = useState(true); @@ -60,16 +70,15 @@ export const VestingCard: React.FC = ({ onActionComplete }) => ); const checkAllowance = useCallback(async () => { - if (!address || !amount) return; + if (!address) return; try { const allowance = await checkVestingAllowance(address, stakingContracts.esGRIXToken.address); - const amountBigInt = parseEther(amount); - setNeedsApproval(allowance < amountBigInt); + setNeedsApproval(allowance === 0n); } catch (error) { setNeedsApproval(true); } - }, [address, amount]); + }, [address]); useEffect(() => { void fetchVestingData(); @@ -81,6 +90,12 @@ export const VestingCard: React.FC = ({ onActionComplete }) => void checkAllowance(); }, [checkAllowance]); + useEffect(() => { + if (!isApproving) { + void checkAllowance(); + } + }, [isApproving, checkAllowance]); + const fetchBalance = useCallback(async () => { if (!address) return; const balance = await getTokenBalance(stakingContracts.esGRIXToken.address, address); @@ -93,71 +108,60 @@ export const VestingCard: React.FC = ({ onActionComplete }) => return () => clearInterval(interval); }, [fetchBalance]); - const handleAction = useCallback( - async (action: 'approve' | 'vest' | 'withdraw') => { - if (!address || (!amount && action !== 'withdraw')) return; + const fetchGrixBalance = useCallback(async () => { + if (!address) return; + const balance = await getTokenBalance(stakingContracts.grixToken.address, address); + setGrixBalance(balance); + }, [address]); - const actions = { - approve: async () => { - setIsApproving(true); - await approveVesting(stakingContracts.esGRIXToken.address, parseEther(amount)); - setNeedsApproval(false); - }, - vest: async () => { - setIsVesting(true); - await vestEsGrix(parseEther(amount)); - setAmount(''); - }, - withdraw: async () => { - setIsWithdrawing(true); - await withdrawEsGrix(); - }, - }; + useEffect(() => { + void fetchGrixBalance(); + const interval = setInterval(() => void fetchGrixBalance(), 15000); + return () => clearInterval(interval); + }, [fetchGrixBalance]); - try { - await actions[action](); - await Promise.all([fetchBalance(), fetchVestingData(true)]); + const handleVest = useCallback( + async (vestAmount: string) => { + if (!address) return; - if (onActionComplete) { - onActionComplete(); + try { + if (needsApproval) { + setIsApproving(true); + await approveVesting(stakingContracts.esGRIXToken.address, parseEther(vestAmount)); + await checkAllowance(); + } else { + setIsVesting(true); + await vestEsGrix(parseEther(vestAmount)); } + await Promise.all([fetchBalance(), fetchVestingData(true), fetchGrixBalance()]); + toast({ - title: `${action.charAt(0).toUpperCase() + action.slice(1)} Successful`, + title: needsApproval ? 'Approval Successful' : 'Vesting Successful', status: 'success', duration: 5000, isClosable: true, }); + + if (!needsApproval) { + onVestingClose(); + } } catch (error) { toast({ - title: `${action.charAt(0).toUpperCase() + action.slice(1)} Failed`, - description: `There was an error during the ${action} process`, + title: needsApproval ? 'Approval Failed' : 'Vesting Failed', + description: `There was an error during the ${needsApproval ? 'approval' : 'vesting'} process`, status: 'error', duration: 5000, isClosable: true, }); } finally { - if (action === 'approve') setIsApproving(false); - if (action === 'vest') setIsVesting(false); - if (action === 'withdraw') setIsWithdrawing(false); + setIsApproving(false); + setIsVesting(false); } }, - [address, amount, fetchVestingData, toast, fetchBalance, onActionComplete] + [address, needsApproval, fetchBalance, fetchVestingData, fetchGrixBalance, toast, onVestingClose, checkAllowance] ); - const handleMaxClick = useCallback(async () => { - await fetchBalance(); - await fetchVestingData(); - setAmount(esGrixBalance); - }, [esGrixBalance, fetchVestingData, fetchBalance]); - - const handleAmountChange = (e: React.ChangeEvent) => { - const value = e.target.value; - if (!value || /^\d*\.?\d*$/.test(value)) { - setAmount(value); - } - }; - // Calculate remaining days and progress const calculateVestingProgress = useCallback(() => { if (!lastVestingTime || !vestingDuration || !vestingData || parseFloat(vestingData.totalVested) === 0) { @@ -181,6 +185,34 @@ export const VestingCard: React.FC = ({ onActionComplete }) => }; }, [lastVestingTime, vestingDuration, vestingData]); + const handleWithdraw = useCallback(async () => { + if (!address) return; + + try { + setIsWithdrawing(true); + await withdrawEsGrix(); + await Promise.all([fetchBalance(), fetchVestingData(true), fetchGrixBalance()]); + + toast({ + title: 'Withdrawal Successful', + status: 'success', + duration: 5000, + isClosable: true, + }); + onWithdrawClose(); + } catch (error) { + toast({ + title: 'Withdrawal Failed', + description: 'There was an error during the withdrawal process', + status: 'error', + duration: 5000, + isClosable: true, + }); + } finally { + setIsWithdrawing(false); + } + }, [address, fetchBalance, fetchVestingData, fetchGrixBalance, toast, onWithdrawClose]); + return ( = ({ onActionComplete }) => vestingProgress: calculateVestingProgress(), } } + onVestClick={onVestingOpen} + isVesting={isVesting || isApproving} + needsApproval={needsApproval} + onWithdraw={onWithdrawOpen} + isWithdrawing={isWithdrawing} /> - - - - - - - - + - - {needsApproval && amount ? ( - - ) : ( - - )} + void handleWithdraw()} + isLoading={isWithdrawing} + claimableGS={vestingData?.claimable || '0'} + vestingAmount={vestingData?.totalVested || '0'} + totalReserved={ + vestingData ? (parseFloat(vestingData.claimable) + parseFloat(vestingData.totalVested)).toString() : '0' + } + /> - - - + ); diff --git a/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx b/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx index dd4811b..ba9fe06 100644 --- a/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx +++ b/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx @@ -1,4 +1,4 @@ -import { Grid, GridItem, Progress, Text, VStack } from '@chakra-ui/react'; +import { Button, Grid, GridItem, HStack, Progress, Text, VStack } from '@chakra-ui/react'; import React from 'react'; import { formatBalance } from '../../utils/formatters'; @@ -19,78 +19,128 @@ type VestingStatsProps = { lastVestingTime?: bigint; vestingProgress?: VestingProgress; } | null; + onVestClick: () => void; + isVesting: boolean; + needsApproval: boolean; + onWithdraw: () => void; + isWithdrawing: boolean; }; -export const VestingStats: React.FC = ({ vestingData }) => ( - - - - - - - - {vestingData ? formatBalance(vestingData.claimable) : '0.0000'} - - - +export const VestingStats: React.FC = ({ + vestingData, + onVestClick, + isVesting, + needsApproval, + onWithdraw, + isWithdrawing, +}) => ( + + + + + + + + + {vestingData ? formatBalance(vestingData.claimable) : '0.0000'} + + + - - - - - - - {vestingData ? formatBalance(vestingData.totalVested) : '0.0000'} - - - + + + + + + + {vestingData ? formatBalance(vestingData.totalVested) : '0.0000'} + + + - - - - - - - {vestingData?.esGrixBalance ? formatBalance(vestingData.esGrixBalance) : '0.0000'} - - - + + + + + + + {vestingData?.esGrixBalance ? formatBalance(vestingData.esGrixBalance) : '0.0000'} + + + - - - - - - - {vestingData ? formatBalance(vestingData.maxVestableAmount) : '0.0000'} - - - + + + + + + + {vestingData ? formatBalance(vestingData.maxVestableAmount) : '0.0000'} + + + - - - - Vesting Progress - - {vestingData?.vestingProgress?.isVesting ? ( - <> - + + + + Vesting Progress + + {vestingData?.vestingProgress?.isVesting ? ( + <> + + + {vestingData.vestingProgress.remainingDays} days remaining + + + ) : ( - {vestingData.vestingProgress.remainingDays} days remaining + No active vesting - - ) : ( - - No active vesting - - )} - - - + )} + + + + + + + + + + ); diff --git a/src/pages/stakingPage/components/VestingModal.tsx b/src/pages/stakingPage/components/VestingModal.tsx new file mode 100644 index 0000000..e5344a7 --- /dev/null +++ b/src/pages/stakingPage/components/VestingModal.tsx @@ -0,0 +1,250 @@ +import { + Box, + Button, + Flex, + HStack, + Icon, + Input, + Modal, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Text, + useToast, + VStack, +} from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; +import { FaEquals, FaPlus } from 'react-icons/fa'; + +import { GrixLogo } from '@/components/commons/Logo'; + +type VestingModalProps = { + isOpen: boolean; + onClose: () => void; + esGrixBalance: string; + grixBalance: string; + isLoading: boolean; + onVest: (amount: string) => Promise; + claimableRewards: string; +}; + +type DexScreenerResponse = { + grix: { + usd: number; + }; +}; + +export const VestingModal = ({ + isOpen, + onClose, + esGrixBalance, + grixBalance, + isLoading, + onVest, + claimableRewards, +}: VestingModalProps): JSX.Element => { + const [amount, setAmount] = useState(''); + const [grixPrice, setGrixPrice] = useState(null); + const toast = useToast(); + + // Fetch GRIX price from CoinGecko + useEffect(() => { + const fetchPrice = async () => { + try { + const res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=grix&vs_currencies=usd'); + const json = (await res.json()) as DexScreenerResponse; + const price = json.grix.usd; + setGrixPrice(price); + } catch { + setGrixPrice(null); + } + }; + void fetchPrice(); + }, []); + + const formatUsdValue = (tokenAmount: string | number) => { + if (!grixPrice) return ''; + const amount = Number(tokenAmount); + return `($${(amount * grixPrice).toFixed(2)})`; + }; + + const handleVest = async () => { + if (!amount || Number(amount) <= 0) { + toast({ + title: 'Invalid amount', + description: 'Please enter a valid amount to vest', + status: 'error', + duration: 3000, + isClosable: true, + }); + return; + } + + if (Number(amount) > Number(esGrixBalance)) { + toast({ + title: 'Insufficient balance', + description: 'You do not have enough esGRIX to vest this amount', + status: 'error', + duration: 3000, + isClosable: true, + }); + return; + } + + try { + await onVest(amount); + setAmount(''); + onClose(); + toast({ + title: 'Success', + description: 'Successfully vested esGRIX', + status: 'success', + duration: 3000, + isClosable: true, + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to vest esGRIX. Please try again.', + status: 'error', + duration: 3000, + isClosable: true, + }); + } + }; + + const handleSetMaxAmount = () => { + setAmount(esGrixBalance); + }; + + return ( + + + + + + Vesting + + + + + + + + + + esGRIX + + setAmount(e.target.value)} + placeholder="0.0" + variant="unstyled" + color="white" + fontSize="md" + width="auto" + textAlign="right" + sx={{ + '::placeholder': { + color: 'gray.400', + opacity: 1, + }, + }} + /> + + + + + Vestable Balance + + + {Number(esGrixBalance).toFixed(4)} esGRIX + + + + {/* Available to Reserve Section */} + + + Available to Reserve for Vesting + + + + + GRIX tokens + + + + {Number(grixBalance).toFixed(4)} GRIX {formatUsdValue(grixBalance)} + + + + + + + esGRIX tokens + + + + {Number(claimableRewards).toFixed(4)} esGRIX {formatUsdValue(claimableRewards)} + + + + + + + + Total + + + + {(Number(grixBalance) + Number(claimableRewards)).toFixed(4)} Tokens{' '} + {formatUsdValue(Number(grixBalance) + Number(claimableRewards))} + + + + + + + + + Reserving + + + {amount ? `${Number(amount).toFixed(2)} Token ${formatUsdValue(amount)}` : '-'} + + + + + + + {/* Vest Button */} + + + + + + ); +}; diff --git a/src/pages/stakingPage/components/WithdrawModal.tsx b/src/pages/stakingPage/components/WithdrawModal.tsx new file mode 100644 index 0000000..72523c4 --- /dev/null +++ b/src/pages/stakingPage/components/WithdrawModal.tsx @@ -0,0 +1,98 @@ +import { + Box, + Button, + HStack, + Modal, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Text, + VStack, +} from '@chakra-ui/react'; +import React from 'react'; + +import { formatBalance } from '../utils/formatters'; + +type WithdrawModalProps = { + isOpen: boolean; + onClose: () => void; + onWithdraw: () => void; + isLoading: boolean; + claimableGS: string; + vestingAmount: string; + totalReserved: string; +}; + +export const WithdrawModal: React.FC = ({ + isOpen, + onClose, + onWithdraw, + isLoading, + claimableGS, + vestingAmount, + totalReserved, +}) => ( + + + + + + Withdraw + + + + + + + + + + Claimable GRIX + + + {formatBalance(claimableGS)} GRIX + + + + + + Vesting + + + {formatBalance(vestingAmount)} esGS + + + + + + + Total reserved + + + {formatBalance(totalReserved)} tokens + + + + + + + + + + +); diff --git a/src/pages/stakingPage/hooks/useVesting.ts b/src/pages/stakingPage/hooks/useVesting.ts new file mode 100644 index 0000000..751c8cb --- /dev/null +++ b/src/pages/stakingPage/hooks/useVesting.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react'; +import { parseEther } from 'viem'; +import { useAccount } from 'wagmi'; + +import { stakingContracts } from '@/web3Config/staking/config'; +import { + approveVesting, + checkVestingAllowance, + getTokenBalance, + getVestingData, + vestEsGs, +} from '@/web3Config/staking/hooks'; + +type VestingData = { + claimable: string; + totalVested: string; + maxVestableAmount: string; +}; + +export const useVesting = () => { + const { address } = useAccount(); + const [isVestingModalOpen, setIsVestingModalOpen] = useState(false); + const [vestingAllowance, setVestingAllowance] = useState('0'); + const [esGrixBalance, setEsGrixBalance] = useState('0'); + const [grixBalance, setGrixBalance] = useState('0'); + const [vestingData, setVestingData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const fetchVestingData = useCallback(async () => { + if (!address) return; + + try { + const [allowance, esGrixBal, grixBal, vesting] = await Promise.all([ + checkVestingAllowance(address, stakingContracts.esGRIXToken.address), + getTokenBalance(stakingContracts.esGRIXToken.address, address), + getTokenBalance(stakingContracts.grixToken.address, address), + getVestingData(address), + ]); + + setVestingAllowance(allowance.toString()); + setEsGrixBalance(esGrixBal); + setGrixBalance(grixBal); + setVestingData(vesting as VestingData); + } catch (error) { + setVestingData(null); + throw error; + } + }, [address]); + + useEffect(() => { + void fetchVestingData(); + }, [fetchVestingData]); + + const handleVest = async (amount: string) => { + if (!address || !amount) return; + + setIsLoading(true); + try { + const parsedAmount = parseEther(amount); + + // Check if approval is needed + if (BigInt(vestingAllowance) < parsedAmount) { + await approveVesting(stakingContracts.esGRIXToken.address, parsedAmount); + } + + // Perform vesting + await vestEsGs(parsedAmount); + + // Refresh data + await fetchVestingData(); + } finally { + setIsLoading(false); + } + }; + + return { + isVestingModalOpen, + setIsVestingModalOpen, + vestingAllowance, + esGrixBalance, + grixBalance, + vestingData, + isLoading, + handleVest, + fetchVestingData, + }; +}; diff --git a/src/pages/stakingPage/index.tsx b/src/pages/stakingPage/index.tsx index 58ca768..026cddd 100644 --- a/src/pages/stakingPage/index.tsx +++ b/src/pages/stakingPage/index.tsx @@ -59,16 +59,18 @@ export const StakingPage: React.FC = () => { return ( - - - Staking - - - - - + + + + + Staking + + + + + + - { - - + + Vesting - + diff --git a/src/web3Config/staking/hooks.ts b/src/web3Config/staking/hooks.ts index f66ebef..8a28190 100644 --- a/src/web3Config/staking/hooks.ts +++ b/src/web3Config/staking/hooks.ts @@ -106,7 +106,15 @@ export const getCoreTracker = async () => vester: string; loanVester: string; }; - +const getCumulativeRewards = async (address: string) => { + const cumulativeRewards = await readContract(wagmiConfig, { + abi: rewardTrackerAbi, + address: normalizeAddress(stakingContracts.rewardTracker.address), + functionName: 'cumulativeRewards', + args: [address], + }); + return cumulativeRewards; +}; // Get user reward tracker data export const getUserRewardTrackerData = async (address: string) => { if (!address) return null; @@ -126,12 +134,7 @@ export const getUserRewardTrackerData = async (address: string) => { args: [address], }); - const cumulativeRewards = await readContract(wagmiConfig, { - abi: rewardTrackerAbi, - address: normalizeAddress(stakingContracts.rewardTracker.address), - functionName: 'cumulativeRewards', - args: [address], - }); + const cumulativeRewards = await getCumulativeRewards(address); const averageStaked = await readContract(wagmiConfig, { abi: rewardTrackerAbi, @@ -295,16 +298,21 @@ export const approveVesting = async (token: string, amount: bigint) => args: [normalizeAddress(stakingContracts.vester.address), amount], }); +const getClaimable = async (address: `0x${string}`) => { + const claimable = await readContract(wagmiConfig, { + abi: vesterAbi, + address: normalizeAddress(stakingContracts.vester.address), + functionName: 'claimable', + args: [address], + }); + return claimable; +}; + // Get vesting data export const getVestingData = async (address: `0x${string}`) => { try { const [claimable, totalVested, maxVestableAmount] = await Promise.all([ - readContract(wagmiConfig, { - abi: vesterAbi, - address: normalizeAddress(stakingContracts.vester.address), - functionName: 'claimable', - args: [address], - }), + getClaimable(address), readContract(wagmiConfig, { abi: vesterAbi, address: normalizeAddress(stakingContracts.vester.address),