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),