diff --git a/web-ui/README.md b/web-ui/README.md index 50ea63205..e128a98a9 100644 --- a/web-ui/README.md +++ b/web-ui/README.md @@ -37,14 +37,12 @@ Please ensure your IDE is configured to use Typescript v4.9.3 - [ ] Add support for Dymension - [ ] Add more weight options IE `equal`, `custom`, `most votes`, `lowest commission` etc -- [x] Make back button in staking modal larger -- [x] Fix skeleton spam when searching for non existent validator in staking modal **Governance** **UI/UX** -- [ ] Double check breakpoints +- [ ] refetch on tx success **Mobile Menu** @@ -52,7 +50,7 @@ Please ensure your IDE is configured to use Typescript v4.9.3 **DevOps** -- [ ] Add doc for adding networks +- [ ] Finish doc for adding networks **Has Blockers** diff --git a/web-ui/bun.lockb b/web-ui/bun.lockb index fc829ea07..8fd836002 100755 Binary files a/web-ui/bun.lockb and b/web-ui/bun.lockb differ diff --git a/web-ui/components/Assets/assetsGrid.tsx b/web-ui/components/Assets/assetsGrid.tsx index 041d45622..d0e5fde3d 100644 --- a/web-ui/components/Assets/assetsGrid.tsx +++ b/web-ui/components/Assets/assetsGrid.tsx @@ -1,15 +1,34 @@ -import { WarningIcon } from '@chakra-ui/icons'; -import { Box, VStack, Text, Divider, HStack, Flex, Grid, GridItem, Spinner, Tooltip } from '@chakra-ui/react'; -import React from 'react'; +import { + Box, + VStack, + Text, + Divider, + HStack, + Flex, + Spinner, + Button, + useDisclosure, + Stat, + StatHelpText, + StatLabel, + StatNumber, + SimpleGrid, + Skeleton, +} from '@chakra-ui/react'; +import React, { useEffect, useState } from 'react'; -import { truncateToTwoDecimals } from '@/utils'; -import { shiftDigits, formatQasset } from '@/utils'; + + +import { shiftDigits, formatQasset, formatNumber } from '@/utils'; import QDepositModal from './modals/qTokenDepositModal'; import QWithdrawModal from './modals/qTokenWithdrawlModal'; + + interface AssetCardProps { + address: string; assetName: string; balance: string; apy: number; @@ -17,9 +36,12 @@ interface AssetCardProps { redemptionRates: string; isWalletConnected: boolean; nonNative: LiquidRewardsData | undefined; + liquidRewards: LiquidRewardsData | undefined; + refetch: () => void; } interface AssetGridProps { + address: string; isWalletConnected: boolean; assets: Array<{ name: string; @@ -29,6 +51,8 @@ interface AssetGridProps { redemptionRates: string; }>; nonNative: LiquidRewardsData | undefined; + liquidRewards: LiquidRewardsData | undefined; + refetch: () => void; } type Amount = { @@ -53,36 +77,71 @@ type LiquidRewardsData = { errors: Errors; }; -const AssetCard: React.FC = ({ assetName, balance, apy, redemptionRates }) => { - const calculateTotalBalance = (nonNative: LiquidRewardsData | undefined, nativeAssetName: string) => { - if (!nonNative) { - return '0'; +const AssetCard: React.FC = ({ address, assetName, balance, apy, redemptionRates, liquidRewards, refetch }) => { + const chainIdToName: { [key: string]: string } = { + 'osmosis-1': 'osmosis', + 'secret-1': 'secretnetwork', + 'umee-1': 'umee', + 'cosmoshub-4': 'cosmoshub', + 'stargaze-1': 'stargaze', + 'sommelier-3': 'sommelier', + 'regen-1': 'regen', + 'juno-1': 'juno', + 'dydx-mainnet-1': 'dydx', + }; + + const getChainName = (chainId: string) => { + return chainIdToName[chainId] || chainId; + }; + + const convertAmount = (amount: string, denom: string) => { + if (denom.startsWith('a')) { + return shiftDigits(amount, -18); } - const chainIds = ['osmosis-1', 'secret-1', 'umee-1', 'cosmoshub-4', 'stargaze-1', 'sommelier-3', 'regen-1', 'juno-1', 'dydx-mainnet-1']; - let totalAmount = 0; - - chainIds.forEach((chainId) => { - const assetsInChain = nonNative?.assets[chainId]; - if (assetsInChain) { - assetsInChain.forEach((asset: any) => { - const assetAmount = asset.Amount.find((amount: { denom: string }) => amount.denom === `uq${nativeAssetName.toLowerCase()}`); - if (assetAmount) { - totalAmount += parseInt(assetAmount.amount, 10); - } - }); - } - }); - - return shiftDigits(totalAmount.toString(), -6); + + return shiftDigits(amount, -6); }; - // const nativeAssets = nonNative?.assets['quicksilver-2'] - // ? nonNative.assets['quicksilver-2'][0].Amount.find((amount) => amount.denom === `uq${nativeAssetName.toLowerCase()}`) - // : undefined; + const [interchainDetails, setInterchainDetails] = useState({}); + + useEffect(() => { + const calculateInterchainBalance = () => { + if (!liquidRewards || !liquidRewards.assets) return '0'; + + let totalAmount = 0; + const assetDenom = `uq${assetName.toLowerCase().replace('q', '')}`; + const aAssetDenom = `aq${assetName.toLowerCase().replace('q', '')}`; - // const formattedNonNativeBalance = calculateTotalBalance(nonNative, nativeAssetName); + const details: { [key: string]: number } = {}; - // const formattedNativebalance = nativeAssets ? shiftDigits(nativeAssets.amount, -6) : '0'; + Object.keys(liquidRewards.assets).forEach((chainId) => { + if (chainId !== 'quicksilver-2') { + liquidRewards.assets[chainId].forEach((asset) => { + asset.Amount.forEach((amount) => { + if (amount.denom === assetDenom || amount.denom === aAssetDenom) { + const convertedAmount = parseFloat(convertAmount(amount.amount, amount.denom)); + totalAmount += convertedAmount; + details[getChainName(chainId)] = (details[getChainName(chainId)] || 0) + convertedAmount; + } + }); + }); + } + }); + + setInterchainDetails(details); + return totalAmount.toString(); + }; + + calculateInterchainBalance(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [liquidRewards, assetName]); + + const interchainBalance = Object.values(interchainDetails as { [key: string]: number }) + .reduce((acc: number, val: number) => acc + val, 0) + .toString(); + + const withdrawDisclosure = useDisclosure(); + const depositDisclosure = useDisclosure(); if (balance === undefined || balance === null || apy === undefined || apy === null) { return ( @@ -103,79 +162,215 @@ const AssetCard: React.FC = ({ assetName, balance, apy, redempti } return ( - - - - - {assetName} + + + + {assetName} + + + + {Number(shiftDigits(apy, 2))}% + + + APY - - - APY: - - - {truncateToTwoDecimals(Number(shiftDigits(apy, 2)))}% - - - - - - - ON QUICKSILVER: - - - - - {balance.toString()} {assetName} - - - {balance > '0' ? ( - <> - - - REDEEMABLE FOR: - - - - - {truncateToTwoDecimals(Number(balance) * Number(redemptionRates)).toString()} {assetName.slice('q'.length)} - - - - ) : ( - <> - - - REDEEMABLE FOR: - - - - - Placeholder - - - - )} - - - - - + + + + + + On Quicksilver + + {!balance || !liquidRewards ? ( + + ) : ( + + {formatNumber(parseFloat(balance))} {assetName} + + )} + + {!balance || !liquidRewards ?( + <> + + + + ) : ( + Number(balance) > 0 && ( + <> + + Redeem For + + + {formatNumber(parseFloat(balance) / Number(redemptionRates))} {assetName.replace('q', '')} + + + ) + )} + + + + + + + + Interchain + + {!balance || !liquidRewards || !interchainBalance ? ( + + ) : ( + + {formatNumber(parseFloat(interchainBalance))} {assetName} + + )} + + {!balance || !liquidRewards || !interchainBalance ? ( + <> + + + + ) : ( + Number(interchainBalance) > 0 && ( + <> + + Redeem For + + + {formatNumber(parseFloat(interchainBalance) / Number(redemptionRates))} {assetName.replace('q', '')} + + + ) + )} + + + + ); }; -const AssetsGrid: React.FC = ({ assets, isWalletConnected, nonNative }) => { +const AssetsGrid: React.FC = ({ address, assets, isWalletConnected, nonNative, liquidRewards, refetch }) => { + const scrollRef = React.useRef(null); + const [focusedIndex, setFocusedIndex] = useState(0); + + const handleMouseEnter = (index: number) => { + setFocusedIndex(index); + }; + + // const scrollByOne = (direction: 'left' | 'right') => { + // if (!scrollRef.current) return; + + // const cardWidth = 380; + // let newIndex = focusedIndex; + + // if (direction === 'left' && focusedIndex > 0) { + // scrollRef.current.scrollBy({ left: -cardWidth, behavior: 'smooth' }); + // newIndex = focusedIndex - 1; + // } else if (direction === 'right' && focusedIndex < assets.length - 1) { + // scrollRef.current.scrollBy({ left: cardWidth, behavior: 'smooth' }); + // newIndex = focusedIndex + 1; + // } + + // setFocusedIndex(newIndex); + // }; + + // const getZoneName = (qAssetName: string) => { + // switch (qAssetName) { + // case 'QATOM': + // return 'Cosmos'; + // case 'QOSMO': + // return 'Osmosis'; + // case 'QSTARS': + // return 'Stargaze'; + // case 'QSOMM': + // return 'Sommelier'; + // case 'QREGEN': + // return 'Regen'; + // case 'QJUNO': + // return 'Juno'; + // case 'QDYDX': + // return 'DyDx'; + + // default: + // return qAssetName; + // } + // }; + return ( <> - + {/* Carousel controls and title */} + qAssets - - {!isWalletConnected && ( + {/* + } + onClick={() => scrollByOne('left')} + aria-label="Scroll left" + variant="ghost" + _hover={{ bgColor: 'transparent', color: 'complimentary.900' }} + _active={{ transform: 'scale(0.75)', color: 'complimentary.800' }} + color="white" + isDisabled={focusedIndex === 0} + _disabled={{ cursor: 'default' }} + /> + + + {getZoneName(assets[focusedIndex]?.name)} + + + } + onClick={() => scrollByOne('right')} + aria-label="Scroll right" + variant="ghost" + _hover={{ bgColor: 'transparent', color: 'complimentary.900' }} + _active={{ transform: 'scale(0.75)', color: 'complimentary.800' }} + color="white" + isDisabled={focusedIndex === assets.length - 1} + _disabled={{ cursor: 'default' }} + /> + */} + + + {/* Carousel content */} + {!isWalletConnected ? ( = ({ assets, isWalletConnected, nonNa Wallet is not connected! Please connect your wallet to interact with your qAssets. - )} - {isWalletConnected && ( - - {assets.map((asset, index) => ( - + ) : ( + + {assets?.map((asset, index) => ( + handleMouseEnter(index)} + > = ({ assets, isWalletConnected, nonNa apy={asset.apy} nonNative={nonNative} redemptionRates={asset.redemptionRates} + liquidRewards={liquidRewards} + refetch={refetch} /> ))} - + )} ); }; + export default AssetsGrid; diff --git a/web-ui/components/Assets/intents.tsx b/web-ui/components/Assets/intents.tsx index 46851cdf7..738bb30ef 100644 --- a/web-ui/components/Assets/intents.tsx +++ b/web-ui/components/Assets/intents.tsx @@ -13,8 +13,10 @@ import { SkeletonCircle, SkeletonText, Center, + Fade, } from '@chakra-ui/react'; -import { Key, useState } from 'react'; +import { Key, useCallback, useState } from 'react'; + import { useIntentQuery, useValidatorLogos, useValidatorsQuery } from '@/hooks/useQueries'; @@ -23,6 +25,7 @@ import { truncateString } from '@/utils'; import SignalIntentModal from './modals/signalIntentProcess'; + export interface StakingIntentProps { address: string; isWalletConnected: boolean; @@ -33,6 +36,13 @@ const StakingIntent: React.FC = ({ address, isWalletConnecte const chains = ['Cosmos', 'Osmosis', 'Dydx', 'Stargaze', 'Regen', 'Sommelier', 'Juno']; const [currentChainIndex, setCurrentChainIndex] = useState(0); + const [isBottomVisible, setIsBottomVisible] = useState(true); + + const handleScroll = useCallback((event: React.UIEvent) => { + const target = event.currentTarget; + const isBottom = target.scrollHeight - target.scrollTop <= target.clientHeight; + setIsBottomVisible(!isBottom); + }, []); const [isSignalIntentModalOpen, setIsSignalIntentModalOpen] = useState(false); const openSignalIntentModal = () => setIsSignalIntentModalOpen(true); @@ -45,6 +55,8 @@ const StakingIntent: React.FC = ({ address, isWalletConnecte const { intent, refetch } = useIntentQuery(currentNetwork.chainName, address ?? ''); + + interface ValidatorDetails { moniker: string; logoUrl: string | undefined; @@ -162,7 +174,16 @@ const StakingIntent: React.FC = ({ address, isWalletConnecte /> - + {(validatorsWithDetails.length > 0 && validatorsWithDetails.map( (validator: { logoUrl: string; moniker: string; percentage: string }, index: Key | null | undefined) => ( @@ -189,7 +210,7 @@ const StakingIntent: React.FC = ({ address, isWalletConnecte /> )} {validator.moniker ? ( - {truncateString(validator.moniker, 20)} + {truncateString(validator.moniker, 18)} ) : ( = ({ address, isWalletConnecte No intent set )} + {isBottomVisible && validatorsWithDetails.length > 5 && ( + + + + )} diff --git a/web-ui/components/Assets/modals/qTokenDepositModal.tsx b/web-ui/components/Assets/modals/qTokenDepositModal.tsx index 9fd67dca5..b26c95855 100644 --- a/web-ui/components/Assets/modals/qTokenDepositModal.tsx +++ b/web-ui/components/Assets/modals/qTokenDepositModal.tsx @@ -10,9 +10,13 @@ import { FormControl, FormLabel, Input, - useDisclosure, + HStack, + Text, + Divider, useToast, Spinner, + InputGroup, + InputRightElement, } from '@chakra-ui/react'; import { StdFee, coins } from '@cosmjs/stargate'; import { ChainName } from '@cosmos-kit/core'; @@ -24,45 +28,51 @@ import { useState, useMemo, useEffect } from 'react'; import { ChooseChain } from '@/components/react/choose-chain'; import { handleSelectChainDropdown, ChainOption, ChooseChainInfo } from '@/components/types'; import { useTx } from '@/hooks'; -import { useIbcBalanceQuery } from '@/hooks/useQueries'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { ibcDenomDepositMapping } from '@/state/chains/prod'; import { getCoin, getIbcInfo } from '@/utils'; export interface QDepositModalProps { token: string; + isOpen: boolean; + onClose: () => void; + interchainDetails: { [chainId: string]: number }; + refetch: () => void; } -const QDepositModal: React.FC = ({ token }) => { - const { isOpen, onOpen, onClose } = useDisclosure(); +const QDepositModal: React.FC = ({ token, isOpen, onClose, interchainDetails, refetch }) => { const toast = useToast(); - - const [chainName, setChainName] = useState('osmosis'); const { chainRecords, getChainLogo } = useManager(); + const [chainName, setChainName] = useState('osmosis'); const [amount, setAmount] = useState(''); + const [maxAmount, setMaxAmount] = useState(''); const [isLoading, setIsLoading] = useState(false); const chainOptions = useMemo(() => { - const desiredChains = ['osmosis', 'umee']; + const availableChains = Object.keys(interchainDetails); return chainRecords - .filter((chainRecord) => desiredChains.includes(chainRecord.name)) + .filter((chainRecord) => availableChains.includes(chainRecord.name)) .map((chainRecord) => ({ - chainName: chainRecord?.name, - label: chainRecord?.chain?.pretty_name, - value: chainRecord?.name, + chainName: chainRecord.name, + label: chainRecord?.chain?.pretty_name || chainRecord.name, + value: chainRecord.name, icon: getChainLogo(chainRecord.name), })); - }, [chainRecords, getChainLogo]); + }, [chainRecords, getChainLogo, interchainDetails]); useEffect(() => { - setChainName(window.localStorage.getItem('selected-chain') || 'osmosis'); - }, []); + const storedChainName = window.localStorage.getItem('selected-chain'); + const defaultChainName = chainOptions[0]?.chainName || 'osmosis'; + const initialChainName = storedChainName || defaultChainName; + setChainName(initialChainName); + setMaxAmount(interchainDetails[initialChainName]?.toString() || '0'); + }, [chainOptions, interchainDetails]); - const onChainChange: handleSelectChainDropdown = async (selectedValue: ChainOption | null) => { - setChainName(selectedValue?.chainName); + const onChainChange: handleSelectChainDropdown = (selectedValue: ChainOption | null) => { if (selectedValue?.chainName) { - window?.localStorage.setItem('selected-chain', selectedValue?.chainName); - } else { - window?.localStorage.removeItem('selected-chain'); + setChainName(selectedValue.chainName); + setMaxAmount(interchainDetails[selectedValue.chainName]?.toString() || '0'); + window.localStorage.setItem('selected-chain', selectedValue.chainName); } }; @@ -72,22 +82,17 @@ const QDepositModal: React.FC = ({ token }) => { const toChain = 'quicksilver'; const { transfer } = ibc.applications.transfer.v1.MessageComposer.withTypeUrl; - const { address, connect, status, message, wallet } = useChain(fromChain ?? ''); + const { address } = useChain(fromChain ?? ''); const { address: qAddress } = useChain('quicksilver'); - const { balance } = useIbcBalanceQuery(fromChain ?? '', address ?? ''); + const { tx } = useTx(fromChain ?? ''); + const { estimateFee } = useFeeEstimation(fromChain ?? ''); const onSubmitClick = async () => { setIsLoading(true); - const coin = getCoin(fromChain ?? ''); const transferAmount = new BigNumber(amount).shiftedBy(6).toString(); - const fee: StdFee = { - amount: coins('1000', coin.base), - gas: '300000', - }; - const { source_port, source_channel } = getIbcInfo(fromChain ?? '', toChain ?? ''); // Function to get the correct IBC denom trace based on chain and token @@ -138,10 +143,13 @@ const QDepositModal: React.FC = ({ token }) => { timeoutTimestamp: timeoutInNanos, }); + const fee = await estimateFee(address ?? '', [msg]); + await tx([msg], { fee, onSuccess: () => { setAmount(''); + refetch(); }, }); @@ -149,41 +157,26 @@ const QDepositModal: React.FC = ({ token }) => { }; return ( - <> - - - - - - Deposit {token} Tokens - - - {/* Chain Selection Dropdown */} - - From Chain - {chooseChain} - - - {/* Amount Input */} - - Amount + + + + Deposit {token} Tokens + + + {/* Chain Selection Dropdown */} + + From Chain + {chooseChain} + + + {/* Amount Input */} + + + Amount + = ({ token }) => { boxShadow: '0 0 0 3px #FF8000', }} value={amount} - onChange={(e) => setAmount(e.target.value)} + onChange={(e) => setAmount(e.target.value <= maxAmount ? e.target.value : BigNumber(maxAmount).toString())} + max={maxAmount} color={'white'} placeholder="Enter amount" /> - - - - - - - - - - + + + + + + + + + + + + + + + + ); }; diff --git a/web-ui/components/Assets/modals/qTokenWithdrawlModal.tsx b/web-ui/components/Assets/modals/qTokenWithdrawlModal.tsx index 29d8489a3..865540633 100644 --- a/web-ui/components/Assets/modals/qTokenWithdrawlModal.tsx +++ b/web-ui/components/Assets/modals/qTokenWithdrawlModal.tsx @@ -7,12 +7,16 @@ import { ModalBody, ModalCloseButton, Button, + Text, + Divider, FormControl, FormLabel, Input, - useDisclosure, useToast, Spinner, + HStack, + InputGroup, + InputRightElement, } from '@chakra-ui/react'; import { StdFee, coins } from '@cosmjs/stargate'; import { ChainName } from '@cosmos-kit/core'; @@ -24,16 +28,20 @@ import { useState, useMemo, useEffect } from 'react'; import { ChooseChain } from '@/components/react/choose-chain'; import { handleSelectChainDropdown, ChainOption, ChooseChainInfo } from '@/components/types'; import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { useIbcBalanceQuery } from '@/hooks/useQueries'; import { ibcDenomWithdrawMapping } from '@/state/chains/prod'; import { getCoin, getIbcInfo } from '@/utils'; interface QDepositModalProps { + max: string; token: string; + isOpen: boolean; + onClose: () => void; + refetch: () => void; } -const QWithdrawModal: React.FC = ({ token }) => { - const { isOpen, onOpen, onClose } = useDisclosure(); +const QWithdrawModal: React.FC = ({ max, token, isOpen, onClose, refetch }) => { const toast = useToast(); const [chainName, setChainName] = useState('osmosis'); @@ -74,20 +82,15 @@ const QWithdrawModal: React.FC = ({ token }) => { const { transfer } = ibc.applications.transfer.v1.MessageComposer.withTypeUrl; const { address } = useChain(toChain ?? ''); const { address: qAddress } = useChain('quicksilver'); - const { balance } = useIbcBalanceQuery(fromChain ?? '', address ?? ''); + const { tx } = useTx(fromChain ?? ''); + const { estimateFee } = useFeeEstimation(fromChain ?? ''); const onSubmitClick = async () => { setIsLoading(true); - const coin = getCoin(fromChain ?? ''); const transferAmount = new BigNumber(amount).shiftedBy(6).toString(); - const fee: StdFee = { - amount: coins('1000', coin.base), - gas: '300000', - }; - const { source_port, source_channel } = getIbcInfo(fromChain ?? '', toChain ?? ''); // Function to get the correct IBC denom trace based on chain and token @@ -119,8 +122,9 @@ const QWithdrawModal: React.FC = ({ token }) => { return; } + const qckDenom = token === 'qDYDX' ? 'a' + ibcDenom : 'u' + ibcDenom; const ibcToken = { - denom: 'u' + ibcDenom ?? '', + denom: qckDenom ?? '', amount: transferAmount, }; @@ -138,10 +142,13 @@ const QWithdrawModal: React.FC = ({ token }) => { timeoutTimestamp: timeoutInNanos, }); + const fee = await estimateFee(qAddress ?? '', [msg]); + await tx([msg], { fee, onSuccess: () => { setAmount(''); + refetch(); }, }); @@ -149,41 +156,26 @@ const QWithdrawModal: React.FC = ({ token }) => { }; return ( - <> - - - - - - Withdraw {token} Tokens - - - {/* Chain Selection Dropdown */} - - To Chain - {chooseChain} - - - {/* Amount Input */} - - Amount + + + + Withdraw {token} Tokens + + + {/* Chain Selection Dropdown */} + + To Chain + {chooseChain} + + + {/* Amount Input */} + + + Amount + = ({ token }) => { boxShadow: '0 0 0 3px #FF8000', }} value={amount} - onChange={(e) => setAmount(e.target.value)} + onChange={(e) => setAmount(e.target.value <= max ? e.target.value : BigNumber(max).toString())} + max={max} color={'white'} placeholder="Enter amount" /> - - - - - - - - - - + + + + + + + + + + + + + + + + ); }; diff --git a/web-ui/components/Assets/modals/qckDepositModal.tsx b/web-ui/components/Assets/modals/qckDepositModal.tsx index d0a576627..bcce19f9f 100644 --- a/web-ui/components/Assets/modals/qckDepositModal.tsx +++ b/web-ui/components/Assets/modals/qckDepositModal.tsx @@ -11,6 +11,8 @@ import { FormLabel, Input, useDisclosure, + Divider, + Text, Spinner, } from '@chakra-ui/react'; import { StdFee } from '@cosmjs/stargate'; @@ -24,7 +26,7 @@ import { useState, useMemo, useEffect } from 'react'; import { ChooseChain } from '@/components/react/choose-chain'; import { handleSelectChainDropdown, ChainOption, ChooseChainInfo } from '@/components/types'; import { useTx } from '@/hooks'; -import { useIbcBalanceQuery } from '@/hooks/useQueries'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { getIbcInfo, shiftDigits } from '@/utils'; export function DepositModal() { @@ -67,30 +69,14 @@ export function DepositModal() { const { transfer } = ibc.applications.transfer.v1.MessageComposer.withTypeUrl; const { address } = useChain(fromChain ?? ''); const { address: qAddress } = useChain('quicksilver'); - const { balance } = useIbcBalanceQuery(fromChain ?? '', address ?? ''); const { tx } = useTx(fromChain ?? ''); + const { estimateFee } = useFeeEstimation(fromChain ?? ''); const onSubmitClick = async () => { setIsLoading(true); const transferAmount = new BigNumber(amount).shiftedBy(6).toString(); - const mainTokens = assets.find(({ chain_name }) => chain_name === chainName); - const fees = chains.find(({ chain_name }) => chain_name === chainName)?.fees?.fee_tokens; - const mainDenom = mainTokens?.assets[0].base ?? ''; - const fixedMinGasPrice = fees?.find(({ denom }) => denom === mainDenom)?.average_gas_price ?? ''; - const feeAmount = shiftDigits(fixedMinGasPrice, 6); - - const fee: StdFee = { - amount: [ - { - denom: mainDenom, - amount: feeAmount.toString(), - }, - ], - gas: '500000', - }; - const { source_port, source_channel } = getIbcInfo(fromChain ?? '', toChain ?? ''); const token = { @@ -111,7 +97,7 @@ export function DepositModal() { //@ts-ignore timeoutTimestamp: timeoutInNanos, }); - + const fee = await estimateFee(address ?? '', [msg]); await tx([msg], { fee, onSuccess: () => { @@ -144,7 +130,7 @@ export function DepositModal() { - Deposit QCK Tokens + Deposit QCK Tokens {/* Chain Selection Dropdown */} @@ -192,6 +178,7 @@ export function DepositModal() { mr={3} onClick={onSubmitClick} disabled={!amount} + isDisabled={!amount} > {isLoading === true && } {isLoading === false && 'Deposit'} diff --git a/web-ui/components/Assets/modals/qckWithdrawModal.tsx b/web-ui/components/Assets/modals/qckWithdrawModal.tsx index 590af5c7f..875f3bddd 100644 --- a/web-ui/components/Assets/modals/qckWithdrawModal.tsx +++ b/web-ui/components/Assets/modals/qckWithdrawModal.tsx @@ -6,6 +6,8 @@ import { ModalFooter, ModalBody, ModalCloseButton, + Text, + Divider, Button, FormControl, FormLabel, @@ -23,6 +25,7 @@ import { useState, useMemo, useEffect } from 'react'; import { ChooseChain } from '@/components/react/choose-chain'; import { handleSelectChainDropdown, ChainOption, ChooseChainInfo } from '@/components/types'; import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { getCoin, getIbcInfo } from '@/utils'; export function WithdrawModal() { @@ -67,17 +70,14 @@ export function WithdrawModal() { const { address: qAddress } = useChain('quicksilver'); const { tx } = useTx(fromChain ?? ''); + const { estimateFee } = useFeeEstimation(fromChain ?? ''); const onSubmitClick = async () => { setIsLoading(true); - const coin = getCoin(fromChain ?? ''); + const transferAmount = new BigNumber(amount).shiftedBy(6).toString(); - const fee: StdFee = { - amount: coins('1000', coin.base), - gas: '300000', - }; const { source_port, source_channel } = getIbcInfo(fromChain ?? '', toChain ?? ''); @@ -99,7 +99,7 @@ export function WithdrawModal() { //@ts-ignore timeoutTimestamp: timeoutInNanos, }); - + const fee = await estimateFee(qAddress ?? '', [msg]); await tx([msg], { fee, onSuccess: () => { @@ -132,7 +132,7 @@ export function WithdrawModal() { - Withdraw QCK Tokens + Withdraw QCK Tokens {/* Chain Selection Dropdown */} @@ -179,6 +179,7 @@ export function WithdrawModal() { mr={3} minW="100px" onClick={onSubmitClick} + isDisabled={!amount} disabled={Number.isNaN(Number(amount))} > {isLoading === true && } diff --git a/web-ui/components/Assets/modals/rewardsModal.tsx b/web-ui/components/Assets/modals/rewardsModal.tsx new file mode 100644 index 000000000..ea89e7dc7 --- /dev/null +++ b/web-ui/components/Assets/modals/rewardsModal.tsx @@ -0,0 +1,392 @@ + +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Divider, + Tooltip, + Text, + Button, + Table, + Thead, + Spinner, + Image, + Tbody, + Tr, + Th, + Td, + Icon, + Flex, + HStack, + TableContainer, + Menu, + Fade, + MenuButton, + MenuItem, + MenuList, + Box, +} from '@chakra-ui/react'; +import { useChain, useChains } from '@cosmos-kit/react'; +import {SkipRouter, SKIP_API_URL} from "@skip-router/core" +import { ibc } from 'interchain-query'; +import { useCallback, useState } from 'react'; +import { FaInfoCircle } from 'react-icons/fa'; + + +import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; +import { useAllBalancesQuery, useSkipAssets, useSkipReccomendedRoutes, useSkipRoutesData } from '@/hooks/useQueries'; +import { useSkipExecute } from '@/hooks/useSkipExecute'; +import { shiftDigits } from '@/utils'; + +const RewardsModal = ({ + address, + isOpen, + onClose, +}: { + address: string; + isOpen: boolean; + onClose: () => void; +}) => { + const { wallet } = useChain('quicksilver'); + + const walletMapping: { [key: string]: any } = { + 'Keplr': window.keplr?.getOfflineSignerOnlyAmino, + 'Cosmostation': window.cosmostation?.providers.keplr.getOfflineSignerOnlyAmino, + 'Leap': window.leap.getOfflineSignerOnlyAmino, + }; + + const offlineSigner = wallet ? walletMapping[wallet.prettyName] : undefined; + const skipClient = new SkipRouter({ + apiURL: SKIP_API_URL, + getCosmosSigner: offlineSigner, + endpointOptions: { + endpoints: { + "quicksilver-2": { rpc: "https://rpc.quicksilver.zone/" }, + }, + }, + }); + const { balance, refetch } = useAllBalancesQuery('quicksilver', address); + const [isSigning, setIsSigning] = useState(false); + const [isBottomVisible, setIsBottomVisible] = useState(true); + + const handleScroll = useCallback((event: React.UIEvent) => { + const target = event.currentTarget; + const isBottom = target.scrollHeight - target.scrollTop <= target.clientHeight; + setIsBottomVisible(!isBottom); + }, []); + + const chains = useChains(['cosmoshub', 'osmosis', 'stargaze', 'juno', 'sommelier', 'regen', 'dydx']); + + const { assets: skipAssets } = useSkipAssets('quicksilver-2'); + const balanceData = balance?.balances || []; + + // maps through the balance query to get the token details and assigns them to the correct values for the skip router + const getMappedTokenDetails = () => { + const denomArray = skipAssets?.['quicksilver-2'] || []; + + return balanceData + .map((balanceItem) => { + if (balanceItem.denom.startsWith('q') || balanceItem.denom.startsWith('aq') || balanceItem.denom.startsWith('uq')) { + return null; + } + + const denomDetail = denomArray.find((denomItem) => denomItem.denom === balanceItem.denom); + + if (denomDetail) { + return { + amount: balanceItem.amount, + denom: denomDetail.denom, + originDenom: denomDetail.originDenom, + originChainId: denomDetail.originChainID, + trace: denomDetail.trace, + logoURI: denomDetail.logoURI, + decimals: denomDetail.decimals, + }; + } + + return null; + }) + .filter(Boolean); + }; + + const tokenDetails = getMappedTokenDetails(); + + // maps through the token details to get the route objects for the skip router + const osmosisRouteObjects = tokenDetails.map(token => ({ + sourceDenom: token?.denom ?? '', + sourceChainId: 'quicksilver-2', + destChainId: 'osmosis-1', + })); + + const { routes: osmosisRoutes } = useSkipReccomendedRoutes(osmosisRouteObjects) +// maps through the token details and the route objects to get the specific token details for the skip router +const osmosisRoutesDataObjects = tokenDetails.flatMap((token, index) => { + return osmosisRoutes[index]?.flatMap(route => { + return route.recommendations.map(recommendation => { + return { + amountIn: token?.amount ?? '0', + sourceDenom: token?.denom ?? '', + sourceChainId: 'quicksilver-2', + destDenom: recommendation.asset.denom ?? '', + destChainId: recommendation.asset.chainID ?? '', + }; + }); + }); +}).filter(Boolean) as { amountIn: string; sourceDenom: string; sourceChainId: string; destDenom: string; destChainId: string; }[]; +const { routesData } = useSkipRoutesData(osmosisRoutesDataObjects) + + +const executeRoute = useSkipExecute(skipClient); +// uses all the data gathered to create the ibc transactions for sending assets to osmosis. +const handleExecuteRoute = async () => { + setIsSigning(true); + + const addresses = { + 'quicksilver-2': address, + 'osmosis-1': chains.osmosis.address, + 'cosmoshub-4': chains.cosmoshub.address, + 'stargaze-1': chains.stargaze.address, + 'sommelier-3': chains.sommelier.address, + 'regen-1': chains.regen.address, + 'juno-1': chains.juno.address, + 'dydx-mainnet-1': chains.dydx.address, + }; + + // Execute each route in sequence + for (const route of routesData) { + try { + await executeRoute(route, addresses, refetch); + + } catch (error) { + console.error('Error executing route:', error); + setIsSigning(false); + return; + } + } + + setIsSigning(false); +}; + +const { tx } = useTx('quicksilver'); + const { transfer } = ibc.applications.transfer.v1.MessageComposer.withTypeUrl; + const { estimateFee } = useFeeEstimation('quicksilver'); + + const onSubmitClick = async () => { + setIsSigning(true); + + const messages = []; + + for (const tokenDetail of tokenDetails) { + if (!tokenDetails) { + setIsSigning(false); + return; + } + + const [_, channel] = tokenDetail?.trace.split('/') ?? ''; + const sourcePort = 'transfer'; + const sourceChannel = channel; + const senderAddress = address ?? ''; + + const ibcToken = { + denom: tokenDetail?.denom ?? '', + amount: tokenDetail?.amount ?? '0', + }; + + const stamp = Date.now(); + const timeoutInNanos = (stamp + 1.2e6) * 1e6; + + const chainIdToName: { [key: string]: string } = { + 'osmosis-1': 'osmosis', + 'cosmoshub-4': 'cosmoshub', + 'stargaze-1': 'stargaze', + 'sommelier-3': 'sommelier', + 'regen-1': 'regen', + 'juno-1': 'juno', + 'dydx-mainnet-1': 'dydx', + }; + + const getChainName = (chainId: string) => { + return chainIdToName[chainId] || chainId; + }; + + const chain = chains[getChainName(tokenDetail?.originChainId ?? '') ?? '']; + const receiverAddress = chain?.address ?? ''; + + const msg = transfer({ + sourcePort, + sourceChannel, + sender: senderAddress, + receiver: receiverAddress, + token: ibcToken, + timeoutHeight: undefined, + //@ts-ignore + timeoutTimestamp: timeoutInNanos, + }); + + messages.push(msg); + } + + try { + const fee = await estimateFee(address, messages); + + await tx(messages, { + fee, + onSuccess: () => { + setIsSigning(false); + }, + }); + setIsSigning(false); + } catch (error) { + + setIsSigning(false); + } + }; + + const [destination, setDestination] = useState(''); + + return ( + + + + + + Rewards + + + + + + + + + + + {tokenDetails.length === 0 && ( + + No rewards available to claim + + )} + {tokenDetails.length >= 1 && ( + <> + + + + + + + + + + + {tokenDetails.map((detail, index) => ( + + + + + ))} + +
Token + Amount +
+ + {detail?.originDenom} + + {detail?.originDenom + ? detail.originDenom.toLowerCase().startsWith('factory/') + ? (() => { + const lastSegment = detail.originDenom.split('/').pop() || ''; + return lastSegment.startsWith('u') ? lastSegment.slice(1).toUpperCase() : lastSegment.toUpperCase(); + })() + : detail.originDenom.slice(1).toUpperCase() + : ''} + + + + {Number(shiftDigits(detail?.amount ?? '', -Number(detail?.decimals))) + .toFixed(2) + .toString()} +
+
+ {isBottomVisible && tokenDetails.length > 6 && ( + + + + )} +
+ + + + + + + {destination === 'parentChains' ? 'Parent Chains' : destination === 'osmosis' ? 'Osmosis' : 'Direction'} + + + setDestination('parentChains')} + bgColor={'rgb(26,26,26)'} + _hover={{ bg: 'complimentary.400' }} + _focus={{ bg: '#2a2a2a' }} + > + Parent Chains + + setDestination('osmosis')} + bgColor={'rgb(26,26,26)'} + _hover={{ bg: 'complimentary.400' }} + _focus={{ bg: '#2a2a2a' }} + > + Osmosis + + + + + + )} +
+ + +
+
+ ); +}; + +export default RewardsModal; diff --git a/web-ui/components/Assets/modals/signalIntentProcess.tsx b/web-ui/components/Assets/modals/signalIntentProcess.tsx index 6f7aa8d72..e1a52a4f2 100644 --- a/web-ui/components/Assets/modals/signalIntentProcess.tsx +++ b/web-ui/components/Assets/modals/signalIntentProcess.tsx @@ -23,7 +23,9 @@ import { assets } from 'chain-registry'; import { quicksilver } from 'quicksilverjs'; import React, { useEffect, useState } from 'react'; + import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { IntentMultiModal } from './intentMultiModal'; @@ -177,25 +179,15 @@ export const SignalIntentModal: React.FC = ({ isOpen, onClose fromAddress: address ?? '', }); - const mainTokens = assets.find(({ chain_name }) => chain_name === 'quicksilver'); - const mainDenom = mainTokens?.assets[0].base ?? 'uqck'; - - const fee: StdFee = { - amount: [ - { - denom: mainDenom, - amount: '5000', - }, - ], - gas: '500000', - }; - const { tx } = useTx('quicksilver' ?? ''); + const { estimateFee } = useFeeEstimation('quicksilver' ?? ''); const handleSignalIntent = async (event: React.MouseEvent) => { event.preventDefault(); setIsSigning(true); + const fee = await estimateFee(address ?? "", [msgSignalIntent]) + try { await tx([msgSignalIntent], { fee, diff --git a/web-ui/components/Assets/portfolio.tsx b/web-ui/components/Assets/portfolio.tsx index 112e87a55..8dbd925f7 100644 --- a/web-ui/components/Assets/portfolio.tsx +++ b/web-ui/components/Assets/portfolio.tsx @@ -1,13 +1,13 @@ -import { Progress, Flex, Text, VStack, HStack, Heading, Spinner, Tooltip, Grid, Box } from '@chakra-ui/react'; +import { Flex, Text, VStack, HStack, Heading, Spinner, SimpleGrid, Center, Image, SkeletonText } from '@chakra-ui/react'; +import { Divider } from '@interchain-ui/react'; -import { abbreviateNumber, shiftDigits, formatQasset } from '@/utils'; +import { shiftDigits, formatNumber } from '@/utils'; interface PortfolioItemInterface { title: string; - percentage: string; - progressBarColor: string; amount: string; qTokenPrice: number; + chainId: string; } interface MyPortfolioProps { @@ -46,7 +46,7 @@ const MyPortfolio: React.FC = ({ ); } - if (isLoading) { + if (isLoading && !portfolioItems.length) { return ( = ({ gap={6} color="white" > - + ); } return ( - + My QUICKSILVER Portfolio @@ -80,64 +80,90 @@ const MyPortfolio: React.FC = ({ gap={5} > - - - - TOTAL - - - ${totalValue.toFixed(2)} - - - - - - - AVG APY: + +
+ + + TOTAL VALUE + + + ${formatNumber(totalValue)} + + +
+
+ + + AVERAGE APY + + + {isNaN(averageApy) ? '0%' : `${shiftDigits(averageApy.toFixed(2), 2)}%`} - {Number.isNaN(averageApy) && ( - - 0% - - )} - {Number.isFinite(averageApy) && ( - - {shiftDigits(averageApy.toFixed(2), 2)}% - - )} - - - - Yearly Yield:{' '} + +
+
+ + + Est. Yield - - ${totalYearlyYield.toFixed(2)} + + ${formatNumber(totalYearlyYield)} - - - + +
+
+ {isLoading && ( + + + + + + + )} {totalValue === 0 && ( - + You have no liquid staked assets. )} - - + + + {totalValue > 0 && ( + + + + ASSET + + + + + + AMOUNT + + + + + + VALUE + + + + + )} + {portfolioItems .filter((item) => Number(item.amount) > 0) .map((item) => ( ))} @@ -149,40 +175,37 @@ const MyPortfolio: React.FC = ({ interface PortfolioItemProps { title: string; - percentage: number; - progressBarColor: string; + amount: string; qTokenPrice: number; totalValue: number; + index: number; } -const PortfolioItem: React.FC = ({ title, percentage, progressBarColor, amount, qTokenPrice, totalValue }) => { - const amountLength = amount.toString().length; - const amountWidth = Math.min(Math.max(amountLength * 8, 90), 100); +const PortfolioItem: React.FC = ({ title, amount, qTokenPrice, index }) => { + const tokenValue = Number(amount) * qTokenPrice; + + const imgType = title === 'qATOM' ? 'svg' : 'png'; return ( - - - - - {abbreviateNumber(Number(amount))} - - - - {formatQasset(title)} - + + + {`${title}`} + q{title.toLocaleLowerCase().slice(1).toLocaleUpperCase()} - - - - - - - - {`${(percentage * 100).toFixed(0)}%`} - - - + {formatNumber(parseFloat(amount))} + {tokenValue < 0.01 ? '>$0.01' : '$' + formatNumber(tokenValue)} + ); }; diff --git a/web-ui/components/Assets/quickbox.tsx b/web-ui/components/Assets/quickbox.tsx index d3a453ff2..4fc6a2cab 100644 --- a/web-ui/components/Assets/quickbox.tsx +++ b/web-ui/components/Assets/quickbox.tsx @@ -1,7 +1,5 @@ -import { Box, Flex, Text, VStack, HStack, SkeletonCircle, Spinner } from '@chakra-ui/react'; +import { Flex, Text, VStack, HStack, Spinner, Button, Stat, StatLabel, StatNumber, useDisclosure } from '@chakra-ui/react'; import { useChain } from '@cosmos-kit/react'; -import { BsCoin } from 'react-icons/bs'; - import { defaultChainName } from '@/config'; import { useBalanceQuery } from '@/hooks/useQueries'; @@ -9,6 +7,9 @@ import { shiftDigits } from '@/utils'; import { DepositModal } from './modals/qckDepositModal'; import { WithdrawModal } from './modals/qckWithdrawModal'; +import RewardsModal from './modals/rewardsModal'; + + interface QuickBoxProps { stakingApy?: number; @@ -20,6 +21,7 @@ const QuickBox: React.FC = ({ stakingApy }) => { const tokenBalance = Number(shiftDigits(balance?.balance?.amount ?? '', -6)) .toFixed(2) .toString(); + const { isOpen, onOpen, onClose } = useDisclosure(); if (!address) { return ( @@ -50,46 +52,48 @@ const QuickBox: React.FC = ({ stakingApy }) => { } const decimalValue = parseFloat(stakingApy?.toString() ?? '0'); - const percentageValue = decimalValue * 100; + const percentageValue = (decimalValue * 100).toFixed(0); const percentageString = percentageValue.toString(); - const truncatedPercentage = percentageString.slice(0, percentageString.indexOf('.') + 3); return ( - - - - - + + + + QCK - - - - STAKING APY: - - {stakingApy ? ( - - {truncatedPercentage}% + + + {percentageString}% - ) : ( - - - - )} - - - TOKENS: - {isLoading ? ( - - ) : ( - - {tokenBalance} QCK + + APY - )} + + + + + Quicksilver Balance + + {tokenBalance} QCK + + + + + ); }; diff --git a/web-ui/components/Assets/rewardsClaim.tsx b/web-ui/components/Assets/rewardsClaim.tsx index ad536e53c..a8bd605b0 100644 --- a/web-ui/components/Assets/rewardsClaim.tsx +++ b/web-ui/components/Assets/rewardsClaim.tsx @@ -1,19 +1,23 @@ import { CloseIcon } from '@chakra-ui/icons'; import { Box, Flex, Text, VStack, Button, HStack, Spinner, Checkbox } from '@chakra-ui/react'; +import { StdFee } from '@cosmjs/amino'; import { assets } from 'chain-registry'; import { GenericAuthorization } from 'interchain-query/cosmos/authz/v1beta1/authz'; import { quicksilver, cosmos } from 'quicksilverjs'; import React, { useState } from 'react'; import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { useIncorrectAuthChecker, useLiquidEpochQuery } from '@/hooks/useQueries'; interface RewardsClaimInterface { address: string; onClose: () => void; + refetch: () => void; } -export const RewardsClaim: React.FC = ({ address, onClose }) => { +export const RewardsClaim: React.FC = ({ address, onClose, refetch }) => { const { tx } = useTx('quicksilver' ?? ''); + const { estimateFee } = useFeeEstimation('quicksilver'); const { authData } = useIncorrectAuthChecker(address); const [isSigning, setIsSigning] = useState(false); @@ -48,35 +52,39 @@ export const RewardsClaim: React.FC = ({ address, onClose msgTypeUrl: quicksilver.participationrewards.v1.MsgSubmitClaim.typeUrl, }); - const mainTokens = assets.find(({ chain_name }) => chain_name === 'quicksilver'); - const mainDenom = mainTokens?.assets[0].base ?? 'uqck'; - - const fee = { - amount: [ - { - denom: mainDenom, - amount: '50', - }, - ], - gas: '500000', - }; - const handleAutoClaimRewards = async (event: React.MouseEvent) => { event.preventDefault(); setIsSigning(true); + + const feeBoth: StdFee = { + amount: [ + { + denom: 'uqck', + amount: '1000000', + }, + ], + gas: '2000000', + } + + const feeSingle = await estimateFee(address, [msgGrant]); try { if (authData) { - // Call msgRevokeBad - await tx([msgRevokeBad], { - fee, - onSuccess: () => {}, + // Call msgRevokeBad and msgGrant + await tx([msgRevokeBad, msgGrant], { + fee: feeBoth, + onSuccess: () => { + refetch(); + }, + }); + } else { + // Call msgGrant + await tx([msgGrant], { + fee: feeSingle, + onSuccess: () => { + refetch(); + }, }); } - // Continue with msgGrant - await tx([msgGrant], { - fee, - onSuccess: () => {}, - }); } catch (error) { console.error('Transaction failed', error); setIsError(true); @@ -85,6 +93,25 @@ export const RewardsClaim: React.FC = ({ address, onClose } }; + function transformProofs(proofs: any[]) { + return proofs.map((proof) => ({ + key: proof.key, + data: proof.data, + proofOps: proof.proof_ops + ? { + //@ts-ignore + ops: proof.proof_ops.ops.map((op) => ({ + type: op.type, + key: op.key, + data: op.data, + })), + } + : undefined, + height: proof.height, + proofType: proof.proof_type, + })); + } + const handleClaimRewards = async (event: React.MouseEvent) => { event.preventDefault(); setIsSigning(true); @@ -97,19 +124,17 @@ export const RewardsClaim: React.FC = ({ address, onClose try { const msgSubmitClaims = liquidEpoch.messages.map((message) => { + const transformedProofs = transformProofs(message.proofs); return submitClaim({ userAddress: message.user_address, zone: message.zone, srcZone: message.src_zone, claimType: message.claim_type, - proofs: message.proofs.map((proof) => ({ - ...proof, - proofOps: proof.proof_ops, - proofType: proof.proof_type, - })), + //@ts-ignore + proofs: transformedProofs, }); }); - + const fee = await estimateFee(address, msgSubmitClaims); await tx(msgSubmitClaims, { fee, onSuccess: () => {}, diff --git a/web-ui/components/Assets/unbondingTable.tsx b/web-ui/components/Assets/unbondingTable.tsx index 3b7660925..b17b23ea9 100644 --- a/web-ui/components/Assets/unbondingTable.tsx +++ b/web-ui/components/Assets/unbondingTable.tsx @@ -27,7 +27,7 @@ const formatDateAndTime = (dateString: string | number | Date) => { }; const formatDenom = (denom: string) => { - return denom.startsWith('u') ? formatQasset(denom.substring(1).toUpperCase()) : denom.toUpperCase(); + return formatQasset(denom.substring(1).toUpperCase()); }; interface UnbondingAssetsTableProps { @@ -257,47 +257,50 @@ const UnbondingAssetsTable: React.FC = ({ address, is - - - - - - {unbondingData?.withdrawals.map((withdrawal, index) => ( - - - - - - - - ))} + {unbondingData?.withdrawals.map((withdrawal, index) => { + const shiftAmount = formatDenom(withdrawal.burn_amount.denom) === 'qDYDX' ? -18 : -6; + return ( + + + + + + + + ); + })}
+ Burn Amount + Status + Redemption Amount + Epoch Number + Completion Time
- {Number(shiftDigits(withdrawal.burn_amount.amount, -6))} {formatDenom(withdrawal.burn_amount.denom)} - - {statusCodes.get(withdrawal.status)} - - {withdrawal.amount.map((amt) => `${shiftDigits(amt.amount, -6)} ${formatDenom(amt.denom)}`).join(', ')} - - {withdrawal.epoch_number} - - {withdrawal.status === 2 - ? 'Pending' - : withdrawal.status === 4 - ? 'A few moments' - : formatDateAndTime(withdrawal.completion_time)} -
+ {Number(shiftDigits(withdrawal.burn_amount.amount, shiftAmount))} {formatDenom(withdrawal.burn_amount.denom)} + + {statusCodes.get(withdrawal.status)} + + {withdrawal.amount.map((amt) => `${shiftDigits(amt.amount, shiftAmount)} ${formatDenom(amt.denom)}`).join(', ')} + + {withdrawal.epoch_number} + + {withdrawal.status === 2 + ? 'Pending' + : withdrawal.status === 4 + ? 'A few moments' + : formatDateAndTime(withdrawal.completion_time)} +
diff --git a/web-ui/components/Governance/VoteModal.tsx b/web-ui/components/Governance/VoteModal.tsx index 317d625b5..c457ce8ff 100644 --- a/web-ui/components/Governance/VoteModal.tsx +++ b/web-ui/components/Governance/VoteModal.tsx @@ -19,6 +19,7 @@ import { cosmos } from 'interchain-query'; import { useState } from 'react'; import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { getCoin } from '@/utils'; const VoteType = cosmos.gov.v1beta1.VoteOption; @@ -38,6 +39,7 @@ export const VoteModal: React.FC = ({ modalControl, chainName, u const [isLoading, setIsLoading] = useState(false); const { tx } = useTx(chainName); + const { estimateFee } = useFeeEstimation(chainName); const { address } = useChain(chainName); const coin = getCoin(chainName); @@ -60,10 +62,7 @@ export const VoteModal: React.FC = ({ modalControl, chainName, u voter: address, }); - const fee: StdFee = { - amount: coins('5000', coin.base), - gas: '100000', - }; + const fee = await estimateFee(address, [msg]); await tx([msg], { fee, diff --git a/web-ui/components/Staking/assetsAccordion.tsx b/web-ui/components/Staking/assetsAccordion.tsx index 89a7ae160..6f2300f76 100644 --- a/web-ui/components/Staking/assetsAccordion.tsx +++ b/web-ui/components/Staking/assetsAccordion.tsx @@ -1,8 +1,11 @@ import { Box, Image, Text, Accordion, AccordionItem, Flex, AccordionButton, SkeletonCircle } from '@chakra-ui/react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { useLiquidRewardsQuery } from '@/hooks/useQueries'; import { shiftDigits } from '@/utils'; +const BigNumber = require('bignumber.js'); + type AssetsAccordianProps = { selectedOption: { name: string; @@ -13,17 +16,44 @@ type AssetsAccordianProps = { }; balance: string; qBalance: string; + address: string; }; -export const AssetsAccordian: React.FC = ({ selectedOption, balance, qBalance }) => { - const exponent = selectedOption.value === 'DYDX' ? -18 : -6; - const qAssets = shiftDigits(qBalance, exponent); +export const AssetsAccordian: React.FC = ({ selectedOption, balance, qBalance, address }) => { + + const { liquidRewards } = useLiquidRewardsQuery(address); + const [updatedQBalance, setUpdatedQBalance] = useState(qBalance); + + useEffect(() => { + const calculateLiquidRewards = () => { + let totalAmount = new BigNumber(0); + const denomToFind = selectedOption.value === 'DYDX' ? `aq${selectedOption.value.toLowerCase()}` : `uq${selectedOption.value.toLowerCase()}`; + + for (const chain in liquidRewards?.assets) { + const chainAssets = liquidRewards?.assets[chain]; + chainAssets.forEach((assetGroup) => { + if (assetGroup.Type === "liquid") { + assetGroup.Amount.forEach((asset) => { + if (asset.denom === denomToFind) { + totalAmount = totalAmount.plus(asset.amount); + } + }); + } + }); + } + + const exponent = selectedOption.value === 'DYDX' ? 18 : 6; + return totalAmount.shiftedBy(-exponent).toString(); + }; + + setUpdatedQBalance(calculateLiquidRewards()); + }, [selectedOption, liquidRewards, qBalance]); - const qAssetsDisplay = qAssets.includes('.') ? qAssets.substring(0, qAssets.indexOf('.') + 3) : qAssets; + const qAssetsDisplay = updatedQBalance.includes('.') ? updatedQBalance.substring(0, updatedQBalance.indexOf('.') + 3) : updatedQBalance; const balanceDisplay = balance.includes('.') ? balance.substring(0, balance.indexOf('.') + 4) : balance; const renderQAssets = () => { - if (qBalance) { + if (qBalance && liquidRewards) { return ( {qAssetsDisplay} diff --git a/web-ui/components/Staking/modals/revertSharesProcessModal.tsx b/web-ui/components/Staking/modals/revertSharesProcessModal.tsx index 6764e3349..c410b9dda 100644 --- a/web-ui/components/Staking/modals/revertSharesProcessModal.tsx +++ b/web-ui/components/Staking/modals/revertSharesProcessModal.tsx @@ -23,6 +23,7 @@ import { cosmos } from 'quicksilverjs'; import React, { useEffect, useState } from 'react'; import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { shiftDigits } from '@/utils'; const ChakraModalContent = styled(ModalContent)` @@ -76,6 +77,7 @@ interface StakingModalProps { address: string; isTokenized: boolean; denom: string; + refetch: () => void; } export const RevertSharesProcessModal: React.FC = ({ @@ -84,8 +86,8 @@ export const RevertSharesProcessModal: React.FC = ({ selectedOption, selectedValidator, address, - isTokenized, denom, + refetch, }) => { const [step, setStep] = useState(1); const getProgressColor = (circleStep: number) => { @@ -111,23 +113,8 @@ export const RevertSharesProcessModal: React.FC = ({ const labels = ['Revert Shares', `Receive Tokens`]; - const mainTokens = assets.find(({ chain_name }) => chain_name === newChainName); - const fees = chains.chains.find(({ chain_name }) => chain_name === newChainName)?.fees?.fee_tokens; - const mainDenom = mainTokens?.assets[0].base ?? ''; - const fixedMinGasPrice = fees?.find(({ denom }) => denom === mainDenom)?.high_gas_price ?? ''; - const feeAmount = Number(fixedMinGasPrice) * 750000; - - const fee: StdFee = { - amount: [ - { - denom: mainDenom, - amount: feeAmount.toString(), - }, - ], - gas: '750000', - }; - const { tx, responseEvents } = useTx(newChainName ?? ''); + const { estimateFee } = useFeeEstimation(newChainName ?? ''); const [combinedDenom, setCombinedDenom] = useState(); // prettier-ignore @@ -156,15 +143,19 @@ export const RevertSharesProcessModal: React.FC = ({ amount: selectedValidator.tokenAmount.toString(), }, }); + const handleRevertShares = async (event: React.MouseEvent) => { event.preventDefault(); setIsSigning(true); + const fee = await estimateFee(address, [msg]) + try { await tx([msg], { fee, onSuccess: () => { + refetch(); setStep(2); }, }); diff --git a/web-ui/components/Staking/modals/stakingProcessModal.tsx b/web-ui/components/Staking/modals/stakingProcessModal.tsx index f32178416..45cc8e919 100644 --- a/web-ui/components/Staking/modals/stakingProcessModal.tsx +++ b/web-ui/components/Staking/modals/stakingProcessModal.tsx @@ -27,12 +27,19 @@ import { cosmos } from 'quicksilverjs'; import React, { useEffect, useState } from 'react'; + + + import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { useZoneQuery } from '@/hooks/useQueries'; import { shiftDigits } from '@/utils'; import { MultiModal } from './validatorSelectionModal'; + + + const ChakraModalContent = styled(ModalContent)` position: relative; background: none; @@ -75,9 +82,10 @@ interface StakingModalProps { chainId: string; }; address: string; + refetch: () => void; } -export const StakingProcessModal: React.FC = ({ isOpen, onClose, selectedOption, tokenAmount, address }) => { +export const StakingProcessModal: React.FC = ({ isOpen, onClose, selectedOption, tokenAmount, address, refetch }) => { const [step, setStep] = useState(1); const getProgressColor = (circleStep: number) => { if (step >= circleStep) return 'complimentary.900'; @@ -263,27 +271,24 @@ export const StakingProcessModal: React.FC = ({ isOpen, onClo feeAmount = shiftDigits(fixedMinGasPrice, 6).toString(); } - const fee: StdFee = { - amount: [ - { - denom: mainDenom, - amount: feeAmount, - }, - ], - gas: '500000', - }; - const { tx } = useTx(newChainName ?? ''); + const { estimateFee } = useFeeEstimation(newChainName ?? ''); + + const handleLiquidStake = async (event: React.MouseEvent) => { event.preventDefault(); setIsSigning(true); setTransactionStatus('Pending'); + + const feeAmountQuery = await estimateFee(address, [msgSend]); + try { await tx([msgSend], { memo, - fee, + fee: feeAmountQuery, onSuccess: () => { + refetch(); setStep(4); setTransactionStatus('Success'); }, diff --git a/web-ui/components/Staking/modals/transferProcessModal.tsx b/web-ui/components/Staking/modals/transferProcessModal.tsx index 4c116a13f..26ce5e61d 100644 --- a/web-ui/components/Staking/modals/transferProcessModal.tsx +++ b/web-ui/components/Staking/modals/transferProcessModal.tsx @@ -15,14 +15,13 @@ import { StatNumber, Spinner, } from '@chakra-ui/react'; -import { coins, StdFee } from '@cosmjs/amino'; +import { coins } from '@cosmjs/amino'; import styled from '@emotion/styled'; -import chains from 'chain-registry'; -import { assets } from 'chain-registry'; import { cosmos } from 'quicksilverjs'; import React, { useEffect, useState } from 'react'; import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { useZoneQuery } from '@/hooks/useQueries'; import { shiftDigits } from '@/utils'; @@ -77,6 +76,7 @@ interface StakingModalProps { address: string; isTokenized: boolean; denom: string; + refetch: () => void; } export const TransferProcessModal: React.FC = ({ @@ -87,6 +87,7 @@ export const TransferProcessModal: React.FC = ({ address, isTokenized, denom, + refetch, }) => { useEffect(() => { if (isTokenized === true) { @@ -141,33 +142,6 @@ export const TransferProcessModal: React.FC = ({ tokenizedShareOwner: address, }); - const mainTokens = assets.find(({ chain_name }) => chain_name === newChainName); - const fees = chains.chains.find(({ chain_name }) => chain_name === newChainName)?.fees?.fee_tokens; - const mainDenom = mainTokens?.assets[0].base ?? ''; - const fixedMinGasPrice = fees?.find(({ denom }) => denom === mainDenom)?.high_gas_price ?? ''; - const feeAmount = Number(fixedMinGasPrice) * 750000; - const sendFeeAmount = Number(fixedMinGasPrice) * 100000; - - const fee: StdFee = { - amount: [ - { - denom: mainDenom, - amount: feeAmount.toString(), - }, - ], - gas: '1000000', // increased to 1,000,000 from 750,000 - }; - - // don't use the same fee for both txs, as a send is piddly! - const sendFee: StdFee = { - amount: [ - { - denom: mainDenom, - amount: sendFeeAmount.toString(), - }, - ], - gas: '100000', - }; const { tx, responseEvents } = useTx(newChainName ?? ''); const [combinedDenom, setCombinedDenom] = useState(); @@ -189,10 +163,13 @@ export const TransferProcessModal: React.FC = ({ } }, [responseEvents]); + const { estimateFee } = useFeeEstimation(newChainName ?? ''); + const handleTokenizeShares = async (event: React.MouseEvent) => { event.preventDefault(); setIsSigning(true); setTransactionStatus('Pending'); + const fee = await estimateFee(address, [msg]) try { await tx([msg], { fee, @@ -227,9 +204,10 @@ export const TransferProcessModal: React.FC = ({ event.preventDefault(); setIsSigning(true); setTransactionStatus('Pending'); + const fee = await estimateFee(address, [msgSend]) try { await tx([msgSend], { - fee: sendFee, + fee: fee, onSuccess: () => { setStep(3); setTransactionStatus('Success'); diff --git a/web-ui/components/Staking/networkSelectButton.tsx b/web-ui/components/Staking/networkSelectButton.tsx index 428a79dd4..cb6ff9bf2 100644 --- a/web-ui/components/Staking/networkSelectButton.tsx +++ b/web-ui/components/Staking/networkSelectButton.tsx @@ -77,6 +77,13 @@ export const NetworkSelect: React.FC = ({ buttonTextColor = 'wh _hover={{ bgColor: 'rgba(255,128,0, 0.25)', }} + _active={{ + bgColor: 'rgba(255,128,0, 0.25)', + }} + + _focus={{ + bgColor: 'rgba(255,128,0, 0.25)', + }} px={2} color="white" as={Button} diff --git a/web-ui/components/Staking/stakingBox.tsx b/web-ui/components/Staking/stakingBox.tsx index 8fc3420ba..f479fcd26 100644 --- a/web-ui/components/Staking/stakingBox.tsx +++ b/web-ui/components/Staking/stakingBox.tsx @@ -33,7 +33,12 @@ import { quicksilver } from 'quicksilverjs'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { FaStar } from 'react-icons/fa'; + + + + import { useTx } from '@/hooks'; +import { useFeeEstimation } from '@/hooks/useFeeEstimation'; import { useAllBalancesQuery, useBalanceQuery, @@ -50,6 +55,8 @@ import StakingProcessModal from './modals/stakingProcessModal'; import TransferProcessModal from './modals/transferProcessModal'; + + type StakingBoxProps = { selectedOption: { name: string; @@ -96,11 +103,17 @@ export const StakingBox = ({ const { address: qAddress } = useChain('quicksilver'); const exp = getExponent(selectedOption.chainName); - const { balance, isLoading } = useBalanceQuery(selectedOption.chainName, address ?? ''); + const { balance, isLoading, refetchBalance } = useBalanceQuery(selectedOption.chainName, address ?? ''); + + const { balance: allBalances, refetch: allRefetch } = useAllBalancesQuery(selectedOption.chainName, address ?? ''); - const { balance: allBalances } = useAllBalancesQuery(selectedOption.chainName, address ?? ''); + const { balance: qBalance, refetch: qRefetch } = useQBalanceQuery('quicksilver', qAddress ?? '', selectedOption.value.toLowerCase()); - const { balance: qBalance } = useQBalanceQuery('quicksilver', qAddress ?? '', selectedOption.value.toLowerCase()); + const allRefetchBalances = () => { + allRefetch(); + refetchBalance(); + qRefetch(); + }; const qAssets = qBalance?.balance.amount || ''; @@ -155,23 +168,18 @@ export const StakingBox = ({ destinationAddress: address ?? '', }); - const fee: StdFee = { - amount: [ - { - denom: 'uqck', - amount: '50', - }, - ], - gas: '500000', - }; - const { tx } = useTx(quicksilverChainName); + const { estimateFee } = useFeeEstimation(quicksilverChainName); const handleLiquidUnstake = async (event: React.MouseEvent) => { event.preventDefault(); setIsSigning(true); + const fee = await estimateFee(qAddress ?? '', [msgRequestRedemption]); try { await tx([msgRequestRedemption], { + onSuccess() { + allRefetchBalances(); + }, fee, }); } catch (error) { @@ -200,7 +208,7 @@ export const StakingBox = ({ // } }; - const { delegations, delegationsIsError, delegationsIsLoading } = useNativeStakeQuery(selectedOption.chainName, address ?? ''); + const { delegations, delegationsIsError, delegationsIsLoading, refetchDelegations } = useNativeStakeQuery(selectedOption.chainName, address ?? ''); const delegationsResponse = delegations?.delegation_responses; @@ -530,7 +538,7 @@ export const StakingBox = ({ {!isZoneLoading ? ( - (Number(tokenAmount) / Number(zone?.redemptionRate || 1)).toFixed(2) + (Number(tokenAmount) * Number(zone?.redemptionRate || 1)).toFixed(2) ) : ( )} @@ -560,6 +568,7 @@ export const StakingBox = ({ onClose={closeStakingModal} selectedOption={selectedOption} address={address ?? ''} + refetch={allRefetchBalances} /> )} @@ -663,7 +672,7 @@ export const StakingBox = ({ }, )} - {isBottomVisible && ( + {isBottomVisible && combinedDelegations.length > 3 && (
)} @@ -710,17 +721,6 @@ export const StakingBox = ({ {/* Unstake TabPanel */} - { selectedOption.value.toUpperCase() == "DYDX" && ( - - - - - DyDx unstaking will be live very soon! - - - - - ) || ( @@ -883,7 +883,6 @@ export const StakingBox = ({ - )} diff --git a/web-ui/components/react/accountControlModal.tsx b/web-ui/components/react/accountControlModal.tsx index 5c9d8cc7a..838805663 100644 --- a/web-ui/components/react/accountControlModal.tsx +++ b/web-ui/components/react/accountControlModal.tsx @@ -12,6 +12,8 @@ import { Spinner, Flex, Box, + Stat, + StatHelpText, } from '@chakra-ui/react'; import { StdFee } from '@cosmjs/amino'; import { useChain } from '@cosmos-kit/react'; @@ -168,14 +170,12 @@ export const AccountControlModal: React.FC = ({ isOpen try { if (incorrectAccount) { // Call msgRevokeBad - await authTx([msgRevokeBad], { + await authTx([msgRevokeBad, revokeGrant], { fee, onSuccess: () => {}, }); - } - // Continue with msgGrant - if (correctAccount) { - // Call msgRevokeBad + } else { + // Call revokeGrant await authTx([revokeGrant], { fee, onSuccess: () => {}, @@ -259,7 +259,7 @@ export const AccountControlModal: React.FC = ({ isOpen XCC Authz Controls - + Disable or reenable the ability to auto claim your cross chain rewards. @@ -281,7 +281,7 @@ export const AccountControlModal: React.FC = ({ isOpen backdropFilter: 'blur(10px)', }} color="white" - variant="ghost" + variant="outline" minW={'100px'} > Back @@ -334,7 +334,7 @@ export const AccountControlModal: React.FC = ({ isOpen Liquid Staking Module Controls - + If your wallet is compromised, hackers can easily tokenize your staked assets and steal them. Disabling LSM prevents this from happening. @@ -343,6 +343,11 @@ export const AccountControlModal: React.FC = ({ isOpen Remember that you will not be able to directly stake your LSM-supported assets, such as Atom, unless you re-enable LSM. + + + Once you re enable this feature, you must wait the unbonding period of the chain in order to use the LSM functions again. + + @@ -357,7 +362,7 @@ export const AccountControlModal: React.FC = ({ isOpen backdropFilter: 'blur(10px)', }} color="white" - variant="ghost" + variant="outline" minW={'100px'} > Back diff --git a/web-ui/components/react/astronaut.tsx b/web-ui/components/react/astronaut.tsx deleted file mode 100644 index 946261f00..000000000 --- a/web-ui/components/react/astronaut.tsx +++ /dev/null @@ -1,139 +0,0 @@ -export const Astronaut = (props: any) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); diff --git a/web-ui/components/react/chain-dropdown.tsx b/web-ui/components/react/chain-dropdown.tsx index d442b229f..b8d893919 100644 --- a/web-ui/components/react/chain-dropdown.tsx +++ b/web-ui/components/react/chain-dropdown.tsx @@ -52,8 +52,8 @@ const SelectOptions = ({ data, value, onChange }: ChangeChainMenuType) => { h: menuHeight, mt: 4, mb: 0, - bg: 'rgb(32,32,32)', - boxShadow: '0 1px 5px #FF8000', + bg: 'rgb(26, 25, 25)', + borderRadius: '0.3rem', color: 'white', _active: { @@ -74,18 +74,12 @@ const SelectOptions = ({ data, value, onChange }: ChangeChainMenuType) => { borderRadius: 'none', color: 'white', p: 2, - _active: { - borderColor: 'complimentary.900', - }, - _selected: { - borderColor: 'complimentary.900', - }, + _hover: { borderColor: 'complimentary.900', }, _focus: { borderColor: 'complimentary.900', - boxShadow: '0 0 0 3px #FF8000', }, // For Firefox scrollbarWidth: 'auto', @@ -103,11 +97,6 @@ const SelectOptions = ({ data, value, onChange }: ChangeChainMenuType) => { backgroundClip: 'content-box', }, }), - clearIndicator: (provided: SystemStyleObject) => ({ - ...provided, - borderRadius: 'full', - color: '#FF8000', - }), dropdownIndicator: (provided: SystemStyleObject) => ({ ...provided, bg: 'transparent', @@ -125,13 +114,13 @@ const SelectOptions = ({ data, value, onChange }: ChangeChainMenuType) => { mt: 2, }, _active: { - bg: 'complimentary.900', + bg: 'rgba(255, 119, 0, 0.15)', }, _hover: { - bg: 'complimentary.700', + bg: 'rgba(255, 119, 0, 0.25)', }, _selected: { - bg: 'complimentary.900', + bg: 'rgba(255, 119, 0, 0.4)', }, _disabled: { bg: 'transparent', _hover: { bg: 'transparent' } }, @@ -234,7 +223,7 @@ const SelectOptions = ({ data, value, onChange }: ChangeChainMenuType) => { instanceId="select-chain" placeholder="Choose a chain" chakraStyles={customStyles} - isClearable={true} + isClearable={false} isMulti={false} isOptionDisabled={(option) => option.isDisabled || false} blurInputOnSelect={true} @@ -261,7 +250,7 @@ const SelectOptions = ({ data, value, onChange }: ChangeChainMenuType) => { export const ChangeChainDropdown = ({ data, selectedItem, onChange }: ChangeChainDropdownType) => { return ( - + ); diff --git a/web-ui/components/react/index.ts b/web-ui/components/react/index.ts index c90449f4a..a84015fb1 100644 --- a/web-ui/components/react/index.ts +++ b/web-ui/components/react/index.ts @@ -1,4 +1,3 @@ -export * from './astronaut'; export * from './wallet-connect'; export * from './warn-block'; export * from './user-card'; diff --git a/web-ui/components/wallet.tsx b/web-ui/components/wallet.tsx index 9561a209e..9866bd7b6 100644 --- a/web-ui/components/wallet.tsx +++ b/web-ui/components/wallet.tsx @@ -6,14 +6,10 @@ import { FiAlertTriangle } from 'react-icons/fi'; import { defaultChainName as chainName } from '@/config'; import { - Astronaut, Error, Connected, - ConnectedShowAddress, - ConnectedUserInfo, Connecting, ConnectStatusWarn, - CopyAddressBtn, Disconnected, NotExist, Rejected, @@ -26,13 +22,6 @@ export const WalletSection = () => { const { connect, openView, status, username, address, message, wallet, chain: chainInfo } = useChain(chainName); const { getChainLogo } = useManager(); - const chain = { - chainName, - label: chainInfo.pretty_name, - value: chainName, - icon: getChainLogo(chainName), - }; - // Events const onClickConnect: MouseEventHandler = async (e) => { e.preventDefault(); @@ -65,9 +54,6 @@ export const WalletSection = () => { /> ); - const userInfo = username && } />; - const addressBtn = } />; - return (
diff --git a/web-ui/hooks/useGrpcQueryClient.ts b/web-ui/hooks/useGrpcQueryClient.ts index bb442a8a0..4850c0c91 100644 --- a/web-ui/hooks/useGrpcQueryClient.ts +++ b/web-ui/hooks/useGrpcQueryClient.ts @@ -1,16 +1,13 @@ import { useQuery } from '@tanstack/react-query'; import { quicksilver } from 'quicksilverjs'; - const createGrpcGateWayClient = quicksilver.ClientFactory.createGrpcGateWayClient; export const useGrpcQueryClient = (chainName: string) => { - let grpcEndpoint: string | undefined; const env = process.env.NEXT_PUBLIC_CHAIN_ENV; - // Build the query client with the correct endpoint const endpoints: { [key: string]: string | undefined } = { quicksilver: env === 'testnet' ? process.env.NEXT_PUBLIC_TESTNET_LCD_ENDPOINT_QUICKSILVER : process.env.NEXT_PUBLIC_MAINNET_LCD_ENDPOINT_QUICKSILVER, @@ -23,11 +20,8 @@ export const useGrpcQueryClient = (chainName: string) => { dydx: env === 'testnet' ? process.env.NEXT_PUBLIC_TESTNET_LCD_ENDPOINT_DYDX : process.env.NEXT_PUBLIC_MAINNET_LCD_ENDPOINT_DYDX, }; - grpcEndpoint = endpoints[chainName]; - - const grpcQueryClientQuery = useQuery({ queryKey: ['grpcQueryClient', grpcEndpoint], queryFn: () => diff --git a/web-ui/hooks/useQueries.ts b/web-ui/hooks/useQueries.ts index 9c1b105b3..80fb63638 100644 --- a/web-ui/hooks/useQueries.ts +++ b/web-ui/hooks/useQueries.ts @@ -1,7 +1,9 @@ import { useChain } from '@cosmos-kit/react'; +import {SkipRouter, SKIP_API_URL} from '@skip-router/core'; import { useQueries, useQuery } from '@tanstack/react-query'; import axios from 'axios'; import { cosmos } from 'interchain-query'; +import { QueryAllBalancesResponse } from 'quicksilverjs/dist/codegen/cosmos/bank/v1beta1/query'; import { Zone } from 'quicksilverjs/dist/codegen/quicksilver/interchainstaking/v1/interchainstaking'; import { useGrpcQueryClient } from './useGrpcQueryClient'; @@ -30,11 +32,6 @@ type WithdrawalsResponse = { pagination: any; }; -type UseWithdrawalsQueryReturnType = { - data: WithdrawalsResponse | undefined; - isLoading: boolean; - isError: boolean; -}; type Amount = { denom: string; @@ -71,6 +68,7 @@ type UseLiquidRewardsQueryReturnType = { liquidRewards: LiquidRewardsData | undefined; isLoading: boolean; isError: boolean; + refetch: () => void; }; interface ProofOp { @@ -81,12 +79,13 @@ interface ProofOp { interface Proof { key: Uint8Array; - data: Uint8Array; - proof_ops: { + data: Uint8Array; + proofType: string; + proofOps: { ops: ProofOp[]; }; height: Long; - proof_type: string; + proofTypes: string; } interface Message { @@ -116,6 +115,9 @@ interface UseLiquidEpochQueryReturnType { isError: boolean; } +const skipClient = new SkipRouter({ + apiURL: SKIP_API_URL, +}); const BigNumber = require('bignumber.js'); const Long = require('long'); @@ -147,6 +149,7 @@ export const useBalanceQuery = (chainName: string, address: string) => { balance: balanceQuery.data, isLoading: balanceQuery.isLoading, isError: balanceQuery.isError, + refetchBalance: balanceQuery.refetch, }; }; @@ -209,6 +212,7 @@ export const useAuthChecker = (address: string) => { authError: authQuery.data?.error, isLoading: authQuery.isLoading, isError: authQuery.isError, + authRefetch: authQuery.refetch, }; }; @@ -276,6 +280,7 @@ export const useAllBalancesQuery = (chainName: string, address: string) => { balance: balancesQuery.data, isLoading: balancesQuery.isLoading, isError: balancesQuery.isError, + refetch: balancesQuery.refetch, }; }; @@ -361,6 +366,57 @@ export const useQBalanceQuery = (chainName: string, address: string, qAsset: str balance: balanceQuery.data, isLoading: balanceQuery.isLoading, isError: balanceQuery.isError, + refetch: balanceQuery.refetch, + }; +}; + +export const useQBalancesQuery = (chainName: string, address: string, grpcQueryClient: { cosmos: { bank: { v1beta1: { allBalances: (arg0: { address: string; pagination: { key: Uint8Array; offset: any; limit: any; countTotal: boolean; reverse: boolean; }; }) => any; }; }; }; } | undefined) => { + + + const allQBalanceQuery = useQuery( + ['balances', address], + async () => { + if (!grpcQueryClient) { + throw new Error('RPC Client not ready'); + } + + + + const next_key = new Uint8Array(); + const balance = await grpcQueryClient.cosmos.bank.v1beta1.allBalances({ + address: address || '', + pagination: { + key: next_key, + offset: Long.fromNumber(0), + limit: Long.fromNumber(100), + countTotal: true, + reverse: false, + }, + }); + + return balance; + }, + { + enabled: !!grpcQueryClient && !!address, + staleTime: 0, + }, + ); + + const sortAndFindQAssets = (balances: QueryAllBalancesResponse) => { + return balances.balances?.filter(b => + (b.denom.startsWith('uq') || b.denom.startsWith('aq')) && + !b.denom.startsWith('uqck') && + !b.denom.includes('ibc/') + ) + .sort((a, b) => a.denom.localeCompare(b.denom)); + }; + + + return { + qbalance: sortAndFindQAssets(allQBalanceQuery.data ?? {} as QueryAllBalancesResponse), + qIsLoading: allQBalanceQuery.isLoading, + qIsError: allQBalanceQuery.isError, + qRefetch: allQBalanceQuery.refetch, }; }; @@ -369,6 +425,8 @@ export const useIntentQuery = (chainName: string, address: string) => { const { chain } = useChain(chainName); const env = process.env.NEXT_PUBLIC_CHAIN_ENV; const baseApiUrl = env === 'testnet' ? 'https://lcd.test.quicksilver.zone' : 'https://lcd.quicksilver.zone'; + + // Determine the chain ID based on the chain name let chainId = chain.chain_id; if (chainName === 'osmosistestnet') { chainId = 'osmo-test-5'; @@ -378,25 +436,24 @@ export const useIntentQuery = (chainName: string, address: string) => { chainId = 'elgafar-1'; } else if (chainName === 'osmo-test-5') { chainId = 'osmosistestnet'; - } else { - chainId = chain.chain_id; } + const intentQuery = useQuery( - ['intent', chainName], + ['intent', chainName, address], async () => { if (!grpcQueryClient) { throw new Error('RPC Client not ready'); } const intent = await axios.get(`${baseApiUrl}/quicksilver/interchainstaking/v1/zones/${chainId}/delegator_intent/${address}`) - return intent; }, { - enabled: !!grpcQueryClient && !!address, + enabled: !!grpcQueryClient && !!address, staleTime: Infinity, + cacheTime: 0, }, ); @@ -421,7 +478,7 @@ export const useLiquidRewardsQuery = (address: string): UseLiquidRewardsQueryRet }, { enabled:!!address, - staleTime: Infinity, + staleTime: 0, }, ); @@ -429,6 +486,7 @@ export const useLiquidRewardsQuery = (address: string): UseLiquidRewardsQueryRet liquidRewards: liquidRewardsQuery.data, isLoading: liquidRewardsQuery.isLoading, isError: liquidRewardsQuery.isError, + refetch: liquidRewardsQuery.refetch, }; } @@ -583,6 +641,19 @@ const fetchAPY = async (chainId: any) => { return chainInfo ? chainInfo.apr : 0; }; +const fetchAPYs = async () => { + const res = await axios.get(`${process.env.NEXT_PUBLIC_QUICKSILVER_DATA_API}/apr`); + const { chains } = res.data; + if (!chains) { + return {}; + } + const apys = chains.reduce((acc: { [x: string]: any; }, chain: { chain_id: string | number; apr: any; }) => { + acc[chain.chain_id] = chain.apr; + return acc; + }, {}); + return apys; +}; + export const useAPYQuery = (chainId: any, liveNetworks?: string[] ) => { @@ -603,6 +674,24 @@ export const useAPYQuery = (chainId: any, liveNetworks?: string[] ) => { }; }; +export const useAPYsQuery = () => { + const query = useQuery( + ['APY'], + () => fetchAPYs(), + { + staleTime: Infinity, + enabled: true, + } + ); + + return { + APYs: query.data, + APYsLoading: query.isLoading, + APYsError: query.isError, + APYsRefetch : query.refetch, + }; +}; + function parseZone(apiZone: any): Zone { return { @@ -715,6 +804,42 @@ export const useZoneQuery = (chainId: string, liveNetworks?: string[]) => { ); }; +export const useRedemptionRatesQuery = () => { + const query = useQuery( + ['zones'], + async () => { + const res = await axios.get(`${process.env.NEXT_PUBLIC_QUICKSILVER_API}/quicksilver/interchainstaking/v1/zones`); + const { zones } = res.data; + + if (!zones || zones.length === 0) { + throw new Error('Failed to query zones'); + } + + + const rates = zones.reduce((acc: { [x: string]: { current: number; last: number; }; }, zone: { chain_id: string | number; redemption_rate: string; last_redemption_rate: string; }) => { + acc[zone.chain_id] = { + current: parseFloat(zone.redemption_rate), + last: parseFloat(zone.last_redemption_rate), + }; + return acc; + }, {}); + + return rates; + }, + { + staleTime: Infinity, + enabled: true, + } + ); + + return { + redemptionRates: query.data, + redemptionLoading: query.isLoading, + redemptionError: query.isError, + redemptionRefetch: query.refetch, + }; +}; + export const useValidatorLogos = ( chainName: string, validators: ExtendedValidator[] @@ -884,5 +1009,84 @@ export const useNativeStakeQuery = (chainName: string, address: string) => { delegations: delegationQuery.data, delegationsIsLoading: delegationQuery.isLoading, delegationsIsError: delegationQuery.isError, + refetchDelegations: delegationQuery.refetch }; -} \ No newline at end of file +} + +export const useSkipAssets = (chainId: string) => { + + const assetsQuery = useQuery( + ['skipAssets', chainId], + async () => { + const assets = await skipClient.assets({ + chainID: chainId, + includeEvmAssets: true, + includeCW20Assets: true, + includeSvmAssets: true, + }); + + return assets; + }, + { + staleTime: Infinity, + }, + ); + + return { + assets: assetsQuery.data, + assetsIsLoading: assetsQuery.isLoading, + assetsIsError: assetsQuery.isError, + }; +}; + +export const useSkipReccomendedRoutes = (reccomendedRoutes: { sourceDenom: string; sourceChainId: string; destChainId: string; }[]) => { + const routesQueries = useQueries({ + queries: reccomendedRoutes.map((reccomendedRoutes) => ({ + queryKey: ['skipReccomendedRoutes', reccomendedRoutes.sourceChainId, reccomendedRoutes.sourceDenom, reccomendedRoutes.destChainId], + queryFn: async () => { + const routes = await skipClient.recommendAssets({ + sourceAssetDenom: reccomendedRoutes.sourceDenom, + sourceAssetChainID: reccomendedRoutes.sourceChainId, + destChainID: reccomendedRoutes.destChainId, + }); + return routes; + }, + enabled: !!reccomendedRoutes.sourceDenom && !!reccomendedRoutes.sourceChainId && !!reccomendedRoutes.destChainId, + staleTime: Infinity, + })) + }); + + return { + routes: routesQueries.map(query => query.data), + routesIsLoading: routesQueries.some(query => query.isLoading), + routesIsError: routesQueries.some(query => query.isError), + }; +}; + +export const useSkipRoutesData = (routes: { amountIn: string, sourceDenom: string; sourceChainId: string; destDenom: string, destChainId: string; }[]) => { + const routesDataQuery = useQueries({ + queries: routes.map((route) => ({ + queryKey: ['skipRoutesData', route.amountIn, route.sourceDenom, route.sourceChainId, route.destDenom, route.destChainId], + queryFn: async () => { + const routes = await skipClient.route({ + amountIn: route.amountIn, + sourceAssetDenom: route.sourceDenom, + sourceAssetChainID: route.sourceChainId, + destAssetDenom: route.destDenom, + destAssetChainID: route.destChainId, + cumulativeAffiliateFeeBPS: '0', + allowMultiTx: true, + }); + return routes; + }, + enabled: !!route.sourceDenom && !!route.sourceChainId && !!route.destChainId, + staleTime: Infinity, + })) + }); + + return { + routesData: routesDataQuery.map(query => query.data), + routesDataIsLoading: routesDataQuery.some(query => query.isLoading), + routesDataIsError: routesDataQuery.some(query => query.isError), + }; +}; diff --git a/web-ui/hooks/useSkipExecute.ts b/web-ui/hooks/useSkipExecute.ts new file mode 100644 index 000000000..dc773bf74 --- /dev/null +++ b/web-ui/hooks/useSkipExecute.ts @@ -0,0 +1,91 @@ +import { ToastId } from '@chakra-ui/react'; +import {SkipRouter, TxStatusResponse} from '@skip-router/core'; +import { useCallback } from 'react'; + +import { useToaster, ToastType } from './useToaster'; + +export function useSkipExecute(skipClient: SkipRouter) { + if (!skipClient) { + throw new Error('SkipRouter is not initialized'); + } + + const toaster = useToaster(); + + const executeRoute = useCallback(async (route: any, userAddresses: any, refetch: () => void) => { + // Initialize with null and allow for the type to be null or ToastId + let broadcastToastId: ToastId | null = null; + + try { + + return await skipClient.executeRoute({ + route, + userAddresses, + onTransactionCompleted: async (chainID: string, txHash: string, status: TxStatusResponse) => { + + if (broadcastToastId) { + toaster.close(broadcastToastId); + } + + + toaster.toast({ + type: ToastType.Success, + title: 'Transaction Successful', + message: `Transaction ${txHash} completed on chain ${chainID}`, + }); + + refetch(); + }, + onTransactionBroadcast: async (txInfo) => { + + broadcastToastId = toaster.toast({ + type: ToastType.Loading, + title: 'Transaction Broadcasting', + message: 'Waiting for transaction to be included in a block', + duration: 9999, + }); + }, + onTransactionTracked: async (txInfo) => { + if (broadcastToastId) { + toaster.close(broadcastToastId); + } + + }, + }); + } catch (error) { + + if (broadcastToastId) { + toaster.close(broadcastToastId); + } + + // Show error toast + console.error('Error executing route:', error); + toaster.toast({ + type: ToastType.Error, + title: 'Transaction Failed', + message: (error as Error).message || 'An unexpected error occurred', + }); + } + }, [skipClient, toaster]); + + return executeRoute; +} + +export function useSkipMessages(skipClient: SkipRouter) { + if (!skipClient) { + throw new Error('SkipRouter is not initialized'); + } + const skipMessages = useCallback(async (route: any) => { + return await skipClient.messages({ + sourceAssetDenom: route.sourceAssetDenom, + sourceAssetChainID: route.sourceAssetChainID, + destAssetDenom: route.destAssetDenom, + destAssetChainID: route.destAssetChainID, + amountIn: route.amountIn, + amountOut: route.amountOut, + addressList: route.addressList, + operations: route.operations, + }); + }, []); + + return skipMessages; +} \ No newline at end of file diff --git a/web-ui/package.json b/web-ui/package.json index ad5e444c9..0a78ab208 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "bun-qs", - "version": "0.15.4", + "version": "0.15.3", "private": true, "homepage": "https://quicksilver-zone.github.io/quicksilver/", "scripts": { @@ -29,13 +29,15 @@ "@interchain-ui/react": "1.10.0", "@osmonauts/lcd": "^1.0.3", "@radix-ui/react-icons": "^1.3.0", + "@skip-router/core": "2.0.5", "@tanstack/react-query": "4.36.1", "@tanstack/react-query-devtools": "4.36.1", "@types/crypto-js": "^4.2.1", + "@vercel/speed-insights": "^1.0.10", "bech32": "^2.0.0", - "chain-registry": "1.28.0", + "chain-registry": "1.33.17", "chakra-react-select": "^4.7.6", - "cosmjs-types": "0.9.0", + "cosmjs-types": "0.5.0", "crypto-js": "^4.2.0", "dayjs": "^1.11.9", "express": "^4.18.2", @@ -51,7 +53,8 @@ "react-markdown": "^9.0.1", "react-minimal-pie-chart": "^8.4.0", "remixicon": "^4.0.1", - "simplex-noise": "^4.0.1" + "simplex-noise": "^4.0.1", + "stridejs": "^0.8.0-alpha.5" }, "devDependencies": { "@testing-library/react": "^14.0.0", @@ -60,8 +63,8 @@ "@types/react-dom": "18.2.18", "eslint": "8.56.0", "eslint-config-next": "13.0.5", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unused-imports": "^3.0.0", - "prettier": "^3.0.3", "react-refresh": "0.10.0", "typescript": "5.2.2" } diff --git a/web-ui/pages/_app.tsx b/web-ui/pages/_app.tsx index 5e4e94394..d99029beb 100644 --- a/web-ui/pages/_app.tsx +++ b/web-ui/pages/_app.tsx @@ -13,6 +13,7 @@ import { ChainProvider, ThemeCustomizationProps } from '@cosmos-kit/react'; import { ThemeProvider, useTheme } from '@interchain-ui/react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { SpeedInsights } from '@vercel/speed-insights/react'; import { chains, assets } from 'chain-registry'; import { ibcAminoConverters, ibcProtoRegistry } from 'interchain-query'; import type { AppProps } from 'next/app'; @@ -216,6 +217,7 @@ function QuickApp({ Component, pageProps }: AppProps) { return ( + - +
@@ -294,6 +296,7 @@ function QuickApp({ Component, pageProps }: AppProps) { +
diff --git a/web-ui/pages/assets.tsx b/web-ui/pages/assets.tsx index 8abd886d5..95667088d 100644 --- a/web-ui/pages/assets.tsx +++ b/web-ui/pages/assets.tsx @@ -10,48 +10,49 @@ import MyPortfolio from '@/components/Assets/portfolio'; import QuickBox from '@/components/Assets/quickbox'; import RewardsClaim from '@/components/Assets/rewardsClaim'; import UnbondingAssetsTable from '@/components/Assets/unbondingTable'; -import { useAPYQuery, useAuthChecker, useLiquidRewardsQuery, useQBalanceQuery, useTokenPrices, useZoneQuery } from '@/hooks/useQueries'; -import { useLiveZones } from '@/state/LiveZonesContext'; -import { shiftDigits, truncateToTwoDecimals } from '@/utils'; +import { useGrpcQueryClient } from '@/hooks/useGrpcQueryClient'; +import { + useAPYQuery, + useAPYsQuery, + useAuthChecker, + useLiquidRewardsQuery, + useQBalancesQuery, + useTokenPrices, + useRedemptionRatesQuery, +} from '@/hooks/useQueries'; +import { shiftDigits } from '@/utils'; export interface PortfolioItemInterface { title: string; - percentage: string; - progressBarColor: string; amount: string; qTokenPrice: number; + chainId: string; } -interface RedemptionRate { - current: number; - last: number; -} - -interface RedemptionRates { - atom: RedemptionRate; - osmo: RedemptionRate; - stars: RedemptionRate; - regen: RedemptionRate; - somm: RedemptionRate; - juno: RedemptionRate; - [key: string]: RedemptionRate; -} - -type BalanceRates = { - [key: string]: string; -}; - -type APYRates = { - [key: string]: Number; -}; - function Home() { const { address } = useChain('quicksilver'); const tokens = ['atom', 'osmo', 'stars', 'regen', 'somm', 'juno', 'dydx']; + const getExponent = (denom: string) => ['qdydx', 'aqdydx'].includes(denom) ? 18 : 6; + + const { grpcQueryClient } = useGrpcQueryClient('quicksilver'); const { data: tokenPrices, isLoading: isLoadingPrices } = useTokenPrices(tokens); + const { qbalance, qIsLoading, qIsError, qRefetch } = useQBalancesQuery('quicksilver-2', address ?? '', grpcQueryClient); + const { APYs, APYsLoading } = useAPYsQuery(); + const { redemptionRates, redemptionLoading } = useRedemptionRatesQuery(); + const { APY: quickAPY } = useAPYQuery('quicksilver-2'); + const { liquidRewards, refetch: liquidRefetch } = useLiquidRewardsQuery(address ?? ''); + const { authData, authError, authRefetch } = useAuthChecker(address ?? ''); + + + + const refetchAll = () => { + qRefetch(); + liquidRefetch(); + }; + + const isLoadingAll = qIsLoading || APYsLoading || redemptionLoading || isLoadingPrices; - // TODO: Use live chain ids from .env const COSMOSHUB_CHAIN_ID = process.env.NEXT_PUBLIC_COSMOSHUB_CHAIN_ID; const OSMOSIS_CHAIN_ID = process.env.NEXT_PUBLIC_OSMOSIS_CHAIN_ID; const STARGAZE_CHAIN_ID = process.env.NEXT_PUBLIC_STARGAZE_CHAIN_ID; @@ -60,198 +61,71 @@ function Home() { const JUNO_CHAIN_ID = process.env.NEXT_PUBLIC_JUNO_CHAIN_ID; const DYDX_CHAIN_ID = process.env.NEXT_PUBLIC_DYDX_CHAIN_ID; - // Retrieve list of zones that are enabled for liquid staking || Will use the above instead - const { liveNetworks } = useLiveZones(); - - // TODO: Figure out how to cycle through live networks and retrieve data for each with less lines of code - // Retrieve balance for each token - // Depending on whether the chain exists in liveNetworks or not, the query will be enabled/disabled - const { balance: qAtom, isLoading: isLoadingQABalance } = useQBalanceQuery('quicksilver', address ?? '', 'atom'); - const { balance: qOsmo, isLoading: isLoadingQOBalance } = useQBalanceQuery('quicksilver', address ?? '', 'osmo'); - const { balance: qStars, isLoading: isLoadingQSBalance } = useQBalanceQuery('quicksilver', address ?? '', 'stars'); - const { balance: qRegen, isLoading: isLoadingQRBalance } = useQBalanceQuery('quicksilver', address ?? '', 'regen'); - const { balance: qSomm, isLoading: isLoadingQSOBalance } = useQBalanceQuery('quicksilver', address ?? '', 'somm'); - const { balance: qJuno, isLoading: isLoadingQJBalance } = useQBalanceQuery('quicksilver', address ?? '', 'juno'); - const { balance: qDydx, isLoading: isLoadingQDBalance } = useQBalanceQuery('quicksilver', address ?? '', 'dydx'); - - // Retrieve zone data for each token - const { data: CosmosZone, isLoading: isLoadingCosmosZone } = useZoneQuery(COSMOSHUB_CHAIN_ID ?? ''); - const { data: OsmoZone, isLoading: isLoadingOsmoZone } = useZoneQuery(OSMOSIS_CHAIN_ID ?? ''); - const { data: StarZone, isLoading: isLoadingStarZone } = useZoneQuery(STARGAZE_CHAIN_ID ?? ''); - const { data: RegenZone, isLoading: isLoadingRegenZone } = useZoneQuery(REGEN_CHAIN_ID ?? ''); - const { data: SommZone, isLoading: isLoadingSommZone } = useZoneQuery(SOMMELIER_CHAIN_ID ?? ''); - const { data: JunoZone, isLoading: isLoadingJunoZone } = useZoneQuery(JUNO_CHAIN_ID ?? ''); - const { data: DydxZone, isLoading: isLoadingDydxZone } = useZoneQuery(DYDX_CHAIN_ID ?? ''); - // Retrieve APY data for each token - const { APY: cosmosAPY, isLoading: isLoadingCosmosApy } = useAPYQuery('cosmoshub-4'); - const { APY: osmoAPY, isLoading: isLoadingOsmoApy } = useAPYQuery('osmosis-1'); - const { APY: starsAPY, isLoading: isLoadingStarsApy } = useAPYQuery('stargaze-1'); - const { APY: regenAPY, isLoading: isLoadingRegenApy } = useAPYQuery('regen-1'); - const { APY: sommAPY, isLoading: isLoadingSommApy } = useAPYQuery('sommelier-3'); - const { APY: quickAPY } = useAPYQuery('quicksilver-2'); - const { APY: junoAPY, isLoading: isLoadingJunoApy } = useAPYQuery('juno-1'); - const { APY: dydxAPY, isLoading: isLoadingDydxApy } = useAPYQuery('dydx-mainnet-1'); - - const isLoadingAll = - isLoadingPrices || - isLoadingQABalance || - isLoadingQOBalance || - isLoadingQSBalance || - isLoadingQRBalance || - isLoadingQSOBalance || - isLoadingQJBalance || - isLoadingQDBalance || - isLoadingCosmosZone || - isLoadingOsmoZone || - isLoadingStarZone || - isLoadingRegenZone || - isLoadingSommZone || - isLoadingJunoZone || - isLoadingDydxZone || - isLoadingCosmosApy || - isLoadingOsmoApy || - isLoadingStarsApy || - isLoadingRegenApy || - isLoadingSommApy || - isLoadingJunoApy || - isLoadingDydxApy; - - // useMemo hook to cache APY data - const qAPYRates: APYRates = useMemo( - () => ({ - qAtom: cosmosAPY ?? 0, - qOsmo: osmoAPY ?? 0, - qStars: starsAPY ?? 0, - qRegen: regenAPY ?? 0, - qSomm: sommAPY ?? 0, - qJuno: junoAPY ?? 0, - qDydx: dydxAPY ?? 0, - }), - [cosmosAPY, osmoAPY, starsAPY, regenAPY, sommAPY, junoAPY, dydxAPY], - ); - // useMemo hook to cache qBalance data - const qBalances: BalanceRates = useMemo( - () => ({ - qAtom: shiftDigits(qAtom?.balance?.amount ?? '000000', -6), - qOsmo: shiftDigits(qOsmo?.balance?.amount ?? '000000', -6), - qStars: shiftDigits(qStars?.balance?.amount ?? '000000', -6), - qRegen: shiftDigits(qRegen?.balance?.amount ?? '000000', -6), - qSomm: shiftDigits(qSomm?.balance?.amount ?? '000000', -6), - qJuno: shiftDigits(qJuno?.balance?.amount ?? '000000', -6), - qDydx: shiftDigits(qDydx?.balance?.amount ?? '000000', -18), - }), - [qAtom, qOsmo, qStars, qRegen, qSomm, qJuno, qDydx], + const tokenToChainIdMap: { [key: string]: string | undefined } = useMemo(() => { + return { + atom: COSMOSHUB_CHAIN_ID, + osmo: OSMOSIS_CHAIN_ID, + stars: STARGAZE_CHAIN_ID, + regen: REGEN_CHAIN_ID, + somm: SOMMELIER_CHAIN_ID, + juno: JUNO_CHAIN_ID, + dydx: DYDX_CHAIN_ID, + }; + }, [COSMOSHUB_CHAIN_ID, OSMOSIS_CHAIN_ID, STARGAZE_CHAIN_ID, REGEN_CHAIN_ID, SOMMELIER_CHAIN_ID, JUNO_CHAIN_ID, DYDX_CHAIN_ID]); + + function getChainIdForToken(tokenToChainIdMap: { [x: string]: any }, baseToken: string) { + return tokenToChainIdMap[baseToken.toLowerCase()] || null; + } +const nonNative = liquidRewards?.assets; +const portfolioItems: PortfolioItemInterface[] = useMemo(() => { + if (!qbalance || !APYs || !redemptionRates || isLoadingAll || !liquidRewards) return []; + + // Flatten nonNative assets into a single array and accumulate amounts for each denom + const amountsMap = new Map(); + Object.values(nonNative || {}).flat().flatMap(reward => reward.Amount).forEach(({ denom, amount }) => { + const currentAmount = amountsMap.get(denom) || 0; + amountsMap.set(denom, currentAmount + Number(amount)); + }); + + // Map over the accumulated results to create portfolio items + return Array.from(amountsMap.entries()).map(([denom, amount]) => { + const normalizedDenom = denom.slice(2); + const chainId = getChainIdForToken(tokenToChainIdMap, normalizedDenom); + const tokenPriceInfo = tokenPrices?.find((info) => info.token === normalizedDenom); + const redemptionRate = chainId && redemptionRates[chainId] ? redemptionRates[chainId].current : 1; + const qTokenPrice = tokenPriceInfo ? tokenPriceInfo.price * redemptionRate : 0; + const exp = getExponent(denom); + const normalizedAmount = shiftDigits(amount, -exp); + + return { + title: 'q' + normalizedDenom.toUpperCase(), + amount: normalizedAmount.toString(), + qTokenPrice: qTokenPrice, + chainId: chainId ?? '', + }; + }); +// eslint-disable-next-line react-hooks/exhaustive-deps +}, [qbalance, APYs, redemptionRates, isLoadingAll, liquidRewards, nonNative, tokenToChainIdMap, tokenPrices, refetchAll]); + + + const totalPortfolioValue = useMemo( + () => portfolioItems.reduce((acc, item) => acc + Number(item.amount) * item.qTokenPrice, 0), + [portfolioItems], ); + const averageApy = useMemo(() => { + const totalWeightedApy = portfolioItems.reduce( + (acc, item) => acc + Number(item.amount) * item.qTokenPrice * (APYs[item.chainId] || 0), + 0, + ); + return totalWeightedApy / totalPortfolioValue || 0; + }, [portfolioItems, APYs, totalPortfolioValue]); - // useMemo hook to cache redemption rate data - const redemptionRates: RedemptionRates = useMemo( - () => ({ - atom: { - current: CosmosZone?.redemptionRate ? parseFloat(CosmosZone.redemptionRate) : 1, - last: CosmosZone?.lastRedemptionRate ? parseFloat(CosmosZone.lastRedemptionRate) : 1, - }, - osmo: { - current: OsmoZone?.redemptionRate ? parseFloat(OsmoZone.redemptionRate) : 1, - last: OsmoZone?.lastRedemptionRate ? parseFloat(OsmoZone.lastRedemptionRate) : 1, - }, - stars: { - current: StarZone?.redemptionRate ? parseFloat(StarZone.redemptionRate) : 1, - last: StarZone?.lastRedemptionRate ? parseFloat(StarZone.lastRedemptionRate) : 1, - }, - regen: { - current: RegenZone?.redemptionRate ? parseFloat(RegenZone.redemptionRate) : 1, - last: RegenZone?.lastRedemptionRate ? parseFloat(RegenZone.lastRedemptionRate) : 1, - }, - somm: { - current: SommZone?.redemptionRate ? parseFloat(SommZone.redemptionRate) : 1, - last: SommZone?.lastRedemptionRate ? parseFloat(SommZone.lastRedemptionRate) : 1, - }, - juno: { - current: JunoZone?.redemptionRate ? parseFloat(JunoZone.redemptionRate) : 1, - last: JunoZone?.lastRedemptionRate ? parseFloat(JunoZone.lastRedemptionRate) : 1, - }, - dydx: { - current: DydxZone?.redemptionRate ? parseFloat(DydxZone.redemptionRate) : 1, - last: DydxZone?.lastRedemptionRate ? parseFloat(DydxZone.lastRedemptionRate) : 1, - }, - }), - [CosmosZone, OsmoZone, StarZone, RegenZone, SommZone, JunoZone, DydxZone], + const totalYearlyYield = useMemo( + () => portfolioItems.reduce((acc, item) => acc + Number(item.amount) * item.qTokenPrice * (APYs[item.chainId] || 0), 0), + [portfolioItems, APYs], ); - // State hooks for portfolio items, total portfolio value, and other metrics - const [portfolioItems, setPortfolioItems] = useState([]); - const [totalPortfolioValue, setTotalPortfolioValue] = useState(0); - const [averageApy, setAverageAPY] = useState(0); - const [totalYearlyYield, setTotalYearlyYield] = useState(0); - - // useEffect hook to compute portfolio metrics when dependencies change - // TODO: cache the computation and make it faster - const computedValues = useMemo(() => { - if (isLoadingAll) { - return { updatedItems: [], totalValue: 0, weightedAPY: 0, totalYearlyYield: 0 }; - } - let totalValue = 0; - let totalYearlyYield = 0; - let weightedAPY = 0; - let updatedItems = []; - - for (const token of Object.keys(qBalances)) { - const baseToken = token.replace('q', '').toLowerCase(); - const tokenPriceInfo = tokenPrices?.find((priceInfo) => priceInfo.token === baseToken); - const qTokenPrice = tokenPriceInfo ? tokenPriceInfo.price * Number(redemptionRates[baseToken].current) : 0; - const qTokenBalance = qBalances[token]; - const itemValue = Number(qTokenBalance) * qTokenPrice; - - const qTokenAPY = qAPYRates[token] || 0; - const yearlyYield = itemValue * Number(qTokenAPY); - totalValue += itemValue; - totalYearlyYield += yearlyYield; - weightedAPY += (itemValue / totalValue) * Number(qTokenAPY); - - updatedItems.push({ - title: token.toUpperCase(), - percentage: 0, - progressBarColor: 'complimentary.700', - amount: qTokenBalance, - qTokenPrice: qTokenPrice || 0, - }); - } - - updatedItems = updatedItems.map((item) => { - const itemValue = Number(item.amount) * item.qTokenPrice; - return { - ...item, - percentage: (((itemValue / totalValue) * 100) / 100).toFixed(2), - }; - }); - - return { updatedItems, totalValue, weightedAPY, totalYearlyYield }; - }, [isLoadingAll, qBalances, tokenPrices, redemptionRates, qAPYRates]); - - useEffect(() => { - if (!isLoadingAll) { - setPortfolioItems(computedValues.updatedItems); - setTotalPortfolioValue(computedValues.totalValue); - setAverageAPY(computedValues.weightedAPY); - setTotalYearlyYield(computedValues.totalYearlyYield); - } - }, [computedValues, isLoadingAll]); - - const assetsData = useMemo(() => { - return Object.keys(qBalances).map((token) => { - return { - name: token.toUpperCase(), - balance: truncateToTwoDecimals(Number(qBalances[token])).toString(), - apy: parseFloat(qAPYRates[token]?.toFixed(2)) || 0, - native: token.replace('q', '').toUpperCase(), - redemptionRates: redemptionRates[token.replace('q', '').toLowerCase()].last.toString(), - }; - }); - }, [qBalances, qAPYRates, redemptionRates]); - - const { liquidRewards } = useLiquidRewardsQuery(address ?? ''); - const { authData, authError } = useAuthChecker(address ?? ''); const [showRewardsClaim, setShowRewardsClaim] = useState(false); const [userClosedRewardsClaim, setUserClosedRewardsClaim] = useState(false); @@ -264,12 +138,43 @@ function Home() { } }, [authData, authError, userClosedRewardsClaim]); - // Function to close the RewardsClaim component const closeRewardsClaim = () => { setShowRewardsClaim(false); setUserClosedRewardsClaim(true); }; + // Data for the assets grid + // the query return `qbalance` is an array of quicksilver staked assets held by the user + // assetsData maps over the assets in qbalance and returns the name, balance, apy, native asset denom, and redemption rate. + const qtokens = useMemo(() => ['qatom', 'qosmo', 'qstars', 'qregen', 'qsomm', 'qjuno', 'qdydx'], []); + +const assetsData = useMemo(() => { + return qtokens.map((token) => { + + const baseToken = token.substring(1).toLowerCase(); + + + const asset = qbalance?.find(a => a.denom.substring(2).toLowerCase() === baseToken); + const apyAsset = qtokens.find(a => a.substring(1).toLowerCase() === baseToken); + const chainId = apyAsset ? getChainIdForToken(tokenToChainIdMap, baseToken) : undefined; + + const apy = (chainId && chainId !== 'dydx-mainnet-1' && APYs && APYs.hasOwnProperty(chainId)) ? APYs[chainId] : 0; + const redemptionRate = chainId && redemptionRates && redemptionRates[chainId] ? redemptionRates[chainId].current || 1 : 1; + const exp = apyAsset ? getExponent(apyAsset) : 0; + + return { + name: token.toUpperCase(), + balance: asset ? shiftDigits(Number(asset.amount), -exp).toString() : "0", + apy: parseFloat(((apy * 100) / 100).toFixed(4)), + native: baseToken.toUpperCase(), + redemptionRates: redemptionRate.toString(), + }; + }); +// eslint-disable-next-line react-hooks/exhaustive-deps +}, [qtokens, qbalance, tokenToChainIdMap, APYs, redemptionRates, refetchAll]); + + const showAssetsGrid = qbalance && qbalance.length > 0 && !qIsLoading && !qIsError; + if (!address) { return ( @@ -342,8 +247,7 @@ function Home() { backdropFilter="blur(50px)" bgColor="rgba(255,255,255,0.1)" borderRadius="10px" - p={5} - w={{ base: 'full', md: 'sm' }} + w={{ base: 'full', md: 'md' }} h="sm" flexDir="column" justifyContent="space-around" @@ -358,7 +262,7 @@ function Home() { backdropFilter="blur(50px)" bgColor="rgba(255,255,255,0.1)" borderRadius="10px" - p={5} + px={5} w={{ base: 'full', md: '2xl' }} h="sm" > @@ -387,7 +291,17 @@ function Home() { {/* Assets Grid */} - + {showAssetsGrid && ( + + )} + {/* Unbonding Table */} @@ -409,7 +323,7 @@ function Home() { {showRewardsClaim && ( - + )} diff --git a/web-ui/pages/staking/[chainId]/[valoperAddress].tsx b/web-ui/pages/staking/[chainId]/[valoperAddress].tsx index 7ddc0dfd3..fdbc31dbe 100644 --- a/web-ui/pages/staking/[chainId]/[valoperAddress].tsx +++ b/web-ui/pages/staking/[chainId]/[valoperAddress].tsx @@ -694,7 +694,7 @@ export const StakingBox = ({ selectedOption, valoperAddress }: StakingBoxProps) - + What you'll get {selectedOption.value.toUpperCase()}: diff --git a/web-ui/pages/staking/index.tsx b/web-ui/pages/staking/index.tsx index 5570df767..dcb2f4a1b 100644 --- a/web-ui/pages/staking/index.tsx +++ b/web-ui/pages/staking/index.tsx @@ -1,4 +1,5 @@ import { Box, Container, Flex, VStack, HStack, Stat, StatLabel, StatNumber, SlideFade, SkeletonCircle, Image } from '@chakra-ui/react'; +import { useChain } from '@cosmos-kit/react-lite'; import dynamic from 'next/dynamic'; import Head from 'next/head'; import { useState } from 'react'; @@ -27,6 +28,8 @@ const networks = process.env.NEXT_PUBLIC_CHAIN_ENV === 'mainnet' ? prodNetworks export default function Staking() { const [selectedNetwork, setSelectedNetwork] = useState(networks[0]); + const {address} = useChain('quicksilver'); + let newChainId; if (selectedNetwork.chainId === 'provider') { newChainId = 'cosmoshub-4'; @@ -124,7 +127,7 @@ export default function Staking() { {/* Bottom Half (1/3) */} - + diff --git a/web-ui/utils/maths.ts b/web-ui/utils/maths.ts index 0d3459194..7063dfebd 100644 --- a/web-ui/utils/maths.ts +++ b/web-ui/utils/maths.ts @@ -26,6 +26,38 @@ export const toNumber = ( .toNumber(); }; +export const formatNumber = (num: number) => { + if (num === 0) return '0'; + if (num < 0.001) return '<0.001'; + + const truncate = (number: number, decimalPlaces: number) => { + const numStr = number.toString(); + const dotIndex = numStr.indexOf('.'); + if (dotIndex === -1) return numStr; + const endIndex = decimalPlaces > 0 ? dotIndex + decimalPlaces + 1 : dotIndex; + return numStr.substring(0, endIndex); + }; + + if (num < 1) { + return truncate(num, 3); + } + if (num < 100) { + return truncate(num, 1); + } + if (num < 1000) { + return truncate(num, 0); + } + if (num >= 1000 && num < 1000000) { + return truncate(num / 1000, 0) + 'K'; + } + if (num >= 1000000 && num < 1000000000) { + return truncate(num / 1000000, 0) + 'M'; + } + if (num >= 1000000000) { + return truncate(num / 1000000000, 0) + 'B'; + } +}; + export function truncateToTwoDecimals(num: number) { const multiplier = Math.pow(10, 2); return Math.floor(num * multiplier) / multiplier; diff --git a/web-ui/utils/string.ts b/web-ui/utils/string.ts index f69b28d8d..9bf597cbe 100644 --- a/web-ui/utils/string.ts +++ b/web-ui/utils/string.ts @@ -1,5 +1,5 @@ export function formatQasset(denom: string): string { - if (denom.substring(0, 1) == "Q") { + if (denom.substring(0, 1) == "Q" || denom.substring(0, 2) == "AQ"){ return "q"+denom.substring(1) } return denom