From bc5b22f0e2df6c8a01c1a8c2fc9f8362708db3a6 Mon Sep 17 00:00:00 2001 From: "jonbray.eth" Date: Fri, 13 Sep 2024 02:40:05 -0400 Subject: [PATCH] feat: deploy staking page (#66) Signed-off-by: jonbray.eth --- src/app/stake/page.js | 258 +++++++++++++++++ src/components/BookmarkAdded.js | 19 +- src/components/Header.js | 6 +- src/components/staking/UserAssets.js | 380 ++++++++++++++++++++++++++ src/components/wallet/AssetsValues.js | 4 +- 5 files changed, 652 insertions(+), 15 deletions(-) create mode 100644 src/app/stake/page.js create mode 100644 src/components/staking/UserAssets.js diff --git a/src/app/stake/page.js b/src/app/stake/page.js new file mode 100644 index 0000000..ceca21d --- /dev/null +++ b/src/app/stake/page.js @@ -0,0 +1,258 @@ +'use client'; + +import { useEffect, useState, useMemo } from 'react'; +import { Image } from '@chakra-ui/react'; +import Lottie from 'react-lottie-player'; +import lottieJson from '@/assets/animations/PE2.json'; +import XeonStakingPoolABI from '@/abi/XeonStakingPool.abi.json'; +import { Constants } from '@/abi/constants'; +import Header from '@/components/Header'; +import UserAssets from '@/components/staking/UserAssets'; +import { ethers } from 'ethers'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Spinner, + useDisclosure, +} from '@chakra-ui/react'; +import BookmarkAdded from '@/components/BookmarkAdded'; + +function Page() { + const [voteValue, setVoteValue] = useState(5); // state for user buyback vote value + // todo: for mainnet, ensure currentPercentage is proper default + const [currentPercentage, setCurrentPercentage] = useState(5); // state for current buyback percentage + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + const [provider, setProvider] = useState(null); + const [signer, setSigner] = useState(null); + const { isOpen, onOpen, onClose } = useDisclosure(); + + // init provider and signer + useEffect(() => { + const initializeProvider = async () => { + if (typeof window !== 'undefined' && window.ethereum) { + const web3Provider = new ethers.providers.Web3Provider(window.ethereum); + const signer = web3Provider.getSigner(); + setProvider(web3Provider); + setSigner(signer); + } + }; + + initializeProvider(); + }, []); + + // memoize the XeonStakingPool contract instance to avoid re-creating it on every render + const XeonStakingPool = useMemo(() => { + if (!provider || !signer) return null; + return new ethers.Contract( + Constants.testnet.XeonStakingPool, + XeonStakingPoolABI, + signer + ); + }, [provider, signer]); + + useEffect(() => { + // fetch current buyback percentage from contract + const fetchBuybackPercentage = async () => { + if (XeonStakingPool) { + try { + // todo: for mainnet, ensure value is formatted correctly (N/10000) + const percentage = await XeonStakingPool.buyBackPercentage(); // assume integer value from contract + setCurrentPercentage(percentage.toNumber()); // update state with value + } catch (error) { + console.error('Error fetching buyback percentage:', error); + } + } + }; + + fetchBuybackPercentage(); + }, [XeonStakingPool]); + + // handle increment and decrement of vote value + // todo: for mainnet, ensure vote value is clamped to contract min/max + const handleIncrement = () => { + setVoteValue((prevValue) => Math.min(prevValue + 1, 100)); + }; + + const handleDecrement = () => { + setVoteValue((prevValue) => Math.max(prevValue - 1, 1)); + }; + + const handleVote = async () => { + if (!XeonStakingPool || voteValue < 1 || voteValue > 100) { + setMessage('Please enter a value between 1 and 100'); + return; + } + + setLoading(true); + onOpen(); + + try { + const tx = await XeonStakingPool.voteForBuybackPercentage(voteValue); + await tx.wait(); + setLoading(false); + setMessage(`Vote successful for ${voteValue}% buyback`); + } catch (error) { + console.error('Vote failed', error); + setLoading(false); + setMessage('Vote failed, please try again.'); + } + }; + + // handle vote value change + const handleVoteChange = (e) => { + const value = parseInt(e.target.value); + if (Number.isNaN(value)) { + setVoteValue(1); + } else { + setVoteValue(Math.min(Math.max(value, 1), 100)); + } + }; + + return ( +
+
+
+
+

Stake

+

+ Xeon +

+ + container + +
+
+

+ Stake your XEON tokens in just two simple steps. +

+
+

+ Stake XEON tokens to be eligible for revenue sharing. The staking + window opens for 3 days at the end of each epoch, at which time + XEON can be staked or unstaked. Protocol revenue is deposited is + deposited into the staking pool. +

+
+ container +
+
+
+ +
+
+
+
+

+ Settle +

+

+ Close expired positions and collect fess into the staking pool +

+
+ +
+
+
+
+
+

+ $XEON Buyback +

+

+ What percentage of protocol revenue should be used to buyback + $XEON token? +

+
+ + + + + + +
+

+ Current Buyback Percentage: {currentPercentage}% +

+
+
+
+ + + + + Vote Feedback + + + {loading ? ( + + ) : ( + + )} + + + + + + +
+ ); +} + +export default Page; diff --git a/src/components/BookmarkAdded.js b/src/components/BookmarkAdded.js index 11413a3..b7e21e6 100644 --- a/src/components/BookmarkAdded.js +++ b/src/components/BookmarkAdded.js @@ -1,14 +1,13 @@ -import {Image} from "@chakra-ui/react"; - +import { Image } from '@chakra-ui/react'; const explorerUrls = { - 0: "https://sepolia.basescan.org/tx/", - 1: "https://etherscan.io/tx/", - 56: "https://bscscan.com/tx/", - 137: "https://polygonscan.com/tx/", - 84532: "https://sepolia.basescan.org/tx/", + 0: 'https://sepolia.basescan.org/tx/', + 1: 'https://etherscan.io/tx/', + 56: 'https://bscscan.com/tx/', + 137: 'https://polygonscan.com/tx/', + 84532: 'https://sepolia.basescan.org/tx/', }; -function BookmarkAdded({message, status, chainId, txHash}) { +function BookmarkAdded({ message, status, chainId, txHash }) { const explorerUrl = explorerUrls[chainId] ? `${explorerUrls[chainId]}${txHash}` : null; @@ -17,14 +16,14 @@ function BookmarkAdded({message, status, chainId, txHash}) {

{status}

transaction status

{message}

- {status === "success" && explorerUrl && ( + {status === 'success' && explorerUrl && (
Analytics
- Guide + Stake Claim - -

Guide

+ +

Stake

Claim

diff --git a/src/components/staking/UserAssets.js b/src/components/staking/UserAssets.js new file mode 100644 index 0000000..aa6e061 --- /dev/null +++ b/src/components/staking/UserAssets.js @@ -0,0 +1,380 @@ +'use client'; +import { + FormControl, + FormLabel, + Switch, + Spinner, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + ModalFooter, + useDisclosure, +} from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { useState, useEffect, useMemo } from 'react'; +import { FaEthereum } from 'react-icons/fa'; +import AssetsValues from '../wallet/AssetsValues'; +import { useActiveAccount } from 'thirdweb/react'; +import { ethers } from 'ethers'; +import XeonStakingPoolABI from '@/abi/XeonStakingPool.abi.json'; +import { Constants } from '@/abi/constants'; +import BookmarkAdded from '../BookmarkAdded'; + +function UserAssets() { + const [isSwitched, setIsSwitched] = useState(false); + const [walletBalance, setWalletBalance] = useState(0); + const [stakedBalance, setStakedBalance] = useState(0); + const [stakeAmount, setStakeAmount] = useState(''); + const [isApproved, setIsApproved] = useState(false); + const [buttonText, setButtonText] = useState('APPROVE'); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + const [status, setStatus] = useState(''); + const [epoch, setEpoch] = useState('0.00'); // todo: app doesn't set epoch, only reads it (value is whole number integer) + const [ethInPool, setEthInPool] = useState('0.00'); + const [buyBackPercentage, setBuyBackPercentage] = useState('0.00'); + const [teamPercentage, setTeamPercentage] = useState('0.00'); + const [walletXeonBalance, setWalletXeonBalance] = useState('0.00'); // todo: display user's contract balance + const [stakedXeonBalance, setStakedXeonBalance] = useState('0.00'); // todo: display user's staked balance + const { isOpen, onOpen, onClose } = useDisclosure(); + const wallet = useActiveAccount(); + const connectedAddress = wallet?.address; + const [provider, setProvider] = useState(null); + const [signer, setSigner] = useState(null); + + useEffect(() => { + if (typeof window !== 'undefined' && window.ethereum) { + const web3Provider = new ethers.providers.Web3Provider(window.ethereum); + const signer = web3Provider.getSigner(); + setProvider(web3Provider); + setSigner(signer); + } + }, []); + + const XeonToken = useMemo(() => { + if (!provider || !signer) return null; + return new ethers.Contract( + Constants.testnet.XeonToken, + XeonStakingPoolABI, + signer + ); + }, [provider, signer]); + + const XeonStakingPool = useMemo(() => { + if (!provider || !signer) return null; + return new ethers.Contract( + Constants.testnet.XeonStakingPool, + XeonStakingPoolABI, + signer + ); + }, [provider, signer]); + + const WETH = useMemo(() => { + if (!provider) return null; + return new ethers.Contract( + Constants.testnet.WETH, + XeonStakingPoolABI, + provider + ); + }, [provider]); + + useEffect(() => { + const fetchData = async () => { + try { + if (!XeonStakingPool || !WETH || !XeonToken || !connectedAddress) + return; + + const epoch = await XeonStakingPool.epoch(); + setEpoch(ethers.utils.formatUnits(epoch, 0)); + + const ethBalance = await WETH.balanceOf( + Constants.testnet.XeonStakingPool + ); + setEthInPool(ethers.utils.formatEther(ethBalance)); + + const buyBackPercentage = await XeonStakingPool.buyBackPercentage(); + setBuyBackPercentage(ethers.utils.formatUnits(buyBackPercentage, 0)); + + const teamPercentage = await XeonStakingPool.teamPercentage(); + setTeamPercentage(ethers.utils.formatUnits(teamPercentage, 0)); + + const xeonBalance = await XeonToken.balanceOf(connectedAddress); + setWalletXeonBalance(ethers.utils.formatEther(xeonBalance)); + + const stakedXeonBalance = + await XeonStakingPool.balanceOf(connectedAddress); + setStakedXeonBalance(ethers.utils.formatEther(stakedXeonBalance)); + } catch (error) { + console.error('Error fetching asset values:', error); + } + }; + + if (connectedAddress) { + fetchData(); + } + }, [connectedAddress, XeonStakingPool, XeonToken, WETH]); + + useEffect(() => { + if (wallet && XeonToken && XeonStakingPool) { + XeonToken.balanceOf(wallet.address).then((balance) => { + setWalletBalance(ethers.utils.formatEther(balance)); + }); + + XeonStakingPool.stakedAmounts(wallet.address).then((balance) => { + setStakedBalance(ethers.utils.formatEther(balance)); + }); + + XeonToken.allowance(wallet.address, XeonStakingPool.address).then( + (allowance) => { + if (ethers.utils.formatEther(allowance) > 0) { + setIsApproved(true); + setButtonText('STAKE'); + } + } + ); + } + }, [wallet, XeonToken, XeonStakingPool]); + + const switchHandler = () => { + setIsSwitched(!isSwitched); + }; + + const handleApprove = async () => { + setLoading(true); + try { + if (!isApproved && XeonToken) { + const tx = await XeonToken.approve( + XeonStakingPool.address, + ethers.utils.parseEther(stakeAmount) + ); + await tx.wait(); + setIsApproved(true); + setButtonText('STAKE'); + setStatus('success'); + setMessage('Approval successful!'); + } + } catch (error) { + setStatus('error'); + setMessage('Approval failed.'); + console.error('Approval failed', error); + } finally { + setLoading(false); + onOpen(); + } + }; + + const handleStake = async () => { + setLoading(true); + try { + if (isApproved && XeonStakingPool) { + const tx = await XeonStakingPool.stake( + ethers.utils.parseEther(stakeAmount) + ); + await tx.wait(); + setStatus('success'); + setMessage('Stake successful!'); + } + } catch (error) { + setStatus('error'); + setMessage('Staking failed.'); + console.error('Staking failed', error); + } finally { + setLoading(false); + onOpen(); + } + }; + + const handleUnstake = async () => { + setLoading(true); + try { + if (parseFloat(stakeAmount) > parseFloat(stakedBalance)) { + throw new Error('Unstake amount exceeds staked balance'); + } + + const tx = await XeonStakingPool.unstake( + ethers.utils.parseEther(stakeAmount) + ); + await tx.wait(); + setMessage('Unstake successful'); + } catch (error) { + console.error('Unstaking failed', error); + setMessage(error.message || 'Unstaking failed'); + } finally { + setLoading(false); + onOpen(); + } + }; + + const handleButtonClick = () => { + if (isSwitched) { + handleUnstake(); + } else if (isApproved) { + handleStake(); + } else { + handleApprove(); + } + }; + + const handleStakeAmountChange = (e) => { + const value = e.target.value; + if (isSwitched && parseFloat(value) > parseFloat(stakedBalance)) { + alert('Unstake amount exceeds your staked balance'); + } else if (!isSwitched && parseFloat(value) > parseFloat(walletBalance)) { + alert('Amount exceeds wallet balance'); + } else { + setStakeAmount(value); + } + }; + + return ( +
+
+ + + + {isSwitched ? 'Unstaking mode' : 'Staking mode'} + + +
+ {isSwitched ? 'Unstake Tokens' : 'Stake Tokens'} +
+ {isSwitched ? ( + + +
+ +
+
+ ) : ( + +
+ + +
+ +
+
+
+ )} +
+ +
+
+
+
+

+

+ {wallet?.address.slice(0, 6) + + '...' + + wallet?.address.slice(-4)} +

+
+
+ +
+
+ + + + +
+
+
+

Staked

+
+

$XEON {stakedBalance}

+
+
+ +
+

Wallet

+
+

$XEON {walletBalance}

+
+
+
+
+
+
+ + + + + + {status === 'success' ? 'Success' : 'Error'} + + + + {loading ? ( + + ) : ( + + )} + + + + + + + +
+ ); +} + +export default UserAssets; diff --git a/src/components/wallet/AssetsValues.js b/src/components/wallet/AssetsValues.js index 1bd32dd..e97c5f3 100644 --- a/src/components/wallet/AssetsValues.js +++ b/src/components/wallet/AssetsValues.js @@ -1,4 +1,4 @@ -function AssetsValues({ label, value }) { +function AssetsValues({label, value}) { return (
@@ -8,7 +8,7 @@ function AssetsValues({ label, value }) {

- ${value} + {value} {`}`}