diff --git a/.gitignore b/.gitignore index 6707ed92..307b99d9 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ apps/extension/.env.development.compass .zed .zed apps/extension/staging-builds -apps/extension/src/components/header/header.tsx \ No newline at end of file diff --git a/apps/.gitignore b/apps/.gitignore index 4af97aea..67fbf154 100644 --- a/apps/.gitignore +++ b/apps/.gitignore @@ -1,2 +1,3 @@ leap-cosmos-mobile/ demo +.DS_Store diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore index e7324129..156341fe 100644 --- a/apps/extension/.gitignore +++ b/apps/extension/.gitignore @@ -13,3 +13,4 @@ canary-builds canary-builds.zip builds.zip coverage +.DS_Store diff --git a/apps/extension/public/base_manifest.json b/apps/extension/public/base_manifest.json index 0640ee3c..a899cf0a 100644 --- a/apps/extension/public/base_manifest.json +++ b/apps/extension/public/base_manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__NAME__", "description": "__DESCRIPTION__", - "version": "0.13.0", + "version": "0.13.3", "options_page": "index.html", "web_accessible_resources": [ { diff --git a/apps/extension/public/compass/manifest.json b/apps/extension/public/compass/manifest.json index 7fa4dbf9..0e88c4c9 100644 --- a/apps/extension/public/compass/manifest.json +++ b/apps/extension/public/compass/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Compass Wallet for Sei", "description": "A crypto wallet for Sei Blockchain, brought to you by the Leap Wallet team.", - "version": "0.13.0", + "version": "0.13.3", "options_page": "index.html", "web_accessible_resources": [ { diff --git a/apps/extension/public/leap-cosmos/manifest.json b/apps/extension/public/leap-cosmos/manifest.json index e14000ff..9a31c477 100644 --- a/apps/extension/public/leap-cosmos/manifest.json +++ b/apps/extension/public/leap-cosmos/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Leap Cosmos Wallet", "description": "A crypto wallet for Cosmos blockchains.", - "version": "0.13.0", + "version": "0.13.3", "options_page": "index.html", "web_accessible_resources": [ { diff --git a/apps/extension/src/App.tsx b/apps/extension/src/App.tsx index 900ec4c5..518b7b19 100644 --- a/apps/extension/src/App.tsx +++ b/apps/extension/src/App.tsx @@ -11,6 +11,7 @@ import { useInitBetaNFTsCollections, useInitChainCosmosSDK, useInitChainInfosConfig, + useInitChainsApr, useInitCoingeckoPrices, useInitCompassSeiEvmConfig, useInitCustomChannelsStore, @@ -75,6 +76,7 @@ export default function App() { useInitTheme() useInitiateCurrencyPreference() useInitCoingeckoPrices() + useInitChainsApr() useInitHideAssets() useInitHideSmallBalances() diff --git a/apps/extension/src/Routes.tsx b/apps/extension/src/Routes.tsx index 8dbdca65..024388d6 100644 --- a/apps/extension/src/Routes.tsx +++ b/apps/extension/src/Routes.tsx @@ -49,7 +49,9 @@ const Send = React.lazy(() => import('pages/send-v2')) const Buy = React.lazy(() => import('pages/buy')) const Sign = React.lazy(() => import('pages/sign/sign-transaction')) const SignSeiEvm = React.lazy(() => import('pages/sign-sei-evm/SignSeiEvmTransaction')) -const Stake = React.lazy(() => import('pages/stake')) +const Stake = React.lazy(() => import('pages/stake-v2')) +const StakeInputPage = React.lazy(() => import('pages/stake-v2/StakeInputPage')) +const StakeTxnPage = React.lazy(() => import('pages/stake-v2/StakeTxnPage')) const CancelUndelegation = React.lazy(() => import('pages/stake/CancelUndelegation')) const ChooseValidator = React.lazy(() => import('pages/stake/ChooseValidator')) const ValidatorDetails = React.lazy(() => import('pages/stake/ValidatorDetails')) @@ -146,6 +148,14 @@ export default function AppRoutes(): JSX.Element { } /> + + + + } + /> } /> + + + + } + /> void - handleCTAClick: () => void - toolTipData: NewChainTooltipData -} - -const NewChainSupportTooltip = ({ - toolTipData, - handleCTAClick, - handleToolTipClose, -}: NewChainSupportTooltipProps) => { - const { header, description, imgUrl, ctaText } = toolTipData - - const { theme } = useTheme() - - return ( - <> -
{ - e.stopPropagation() - }} - className='cursor-default z-[2] p-3 rounded-xl absolute bg-white-100 w-[272px] border border-gray-200 dark:border-gray-850 dark:bg-gray-950 top-[56px] right-0 flex flex-col justify-start items-start gap-3' - > -
- - - - -
- - {imgUrl && tooltip-img} - -
-
- {header} -
-
- {description} -
-
- -
- - -
-
- - ) -} - -export default NewChainSupportTooltip diff --git a/apps/extension/src/components/Header/PageHeader.tsx b/apps/extension/src/components/Header/PageHeader.tsx deleted file mode 100644 index 8f7a8b11..00000000 --- a/apps/extension/src/components/Header/PageHeader.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { LineDivider } from '@leapwallet/leap-ui' -import classNames from 'classnames' -import { ActionButton } from 'components/button' -import useNewChainTooltip from 'hooks/useNewChainTooltip' -import { Images } from 'images' -import React from 'react' -import { PageHeaderProps } from 'types/components' - -import NewChainSupportTooltip from './NewChainSupportTooltip' - -const PageHeader = React.memo( - ({ title, action, imgSrc, onImgClick, dontShowFilledArrowIcon = false }: PageHeaderProps) => { - const { showToolTip: _showToolTip, toolTipData, handleToolTipClose } = useNewChainTooltip() - - const showToolTip = _showToolTip && !!toolTipData && !!onImgClick - - return ( - <> - {showToolTip && ( -
- )} -
-
-
{title}
-
- - {action ? ( -
- -
- ) : null} - - {imgSrc ? ( -
- {typeof imgSrc === 'string' ? ( - - ) : ( - imgSrc - )} - - {onImgClick !== undefined && !dontShowFilledArrowIcon && ( - - )} - - {showToolTip && ( - { - onImgClick() - handleToolTipClose() - }} - handleToolTipClose={handleToolTipClose} - /> - )} -
- ) : null} - -
- -
-
- - ) - }, -) - -PageHeader.displayName = 'PageHeader' -export { PageHeader } diff --git a/apps/extension/src/components/Header/index.ts b/apps/extension/src/components/Header/index.ts deleted file mode 100644 index f76b3366..00000000 --- a/apps/extension/src/components/Header/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './Header' -export * from './PageHeader' diff --git a/apps/extension/src/components/Skeletons/StakeSkeleton.tsx b/apps/extension/src/components/Skeletons/StakeSkeleton.tsx index 0ac7af7e..82db1138 100644 --- a/apps/extension/src/components/Skeletons/StakeSkeleton.tsx +++ b/apps/extension/src/components/Skeletons/StakeSkeleton.tsx @@ -1,6 +1,19 @@ import React from 'react' import Skeleton from 'react-loading-skeleton' +export function AmountCardSkeleton() { + return ( +
+ +
+ + +
+ +
+ ) +} + export default function StakeCardSkeleton() { return (
@@ -10,3 +23,22 @@ export default function StakeCardSkeleton() {
) } + +export function YouStakeSkeleton() { + return ( +
+ + + +
+ ) +} + +export function ValidatorItemSkeleton() { + return ( +
+ + +
+ ) +} diff --git a/apps/extension/src/components/Skeletons/ValidatorListSkeleton.tsx b/apps/extension/src/components/Skeletons/ValidatorListSkeleton.tsx new file mode 100644 index 00000000..7b90de11 --- /dev/null +++ b/apps/extension/src/components/Skeletons/ValidatorListSkeleton.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import Skeleton from 'react-loading-skeleton' + +export function ValidatorListItemSkeleton() { + return ( +
+ +
+ + +
+
+ ) +} + +export default function ValidatorListSkeleton() { + return ( + <> + + + + + + + ) +} diff --git a/apps/extension/src/components/bottom-nav/BottomNav.tsx b/apps/extension/src/components/bottom-nav/BottomNav.tsx index 89f40c02..a89cfea0 100644 --- a/apps/extension/src/components/bottom-nav/BottomNav.tsx +++ b/apps/extension/src/components/bottom-nav/BottomNav.tsx @@ -65,7 +65,7 @@ export default function BottomNav({ label, disabled: disabledAll }: BottomNavPro { label: BottomNavLabel.Stake, icon: 'monetization_on', - path: '/stake', + path: '/stake?pageSource=bottomNav', show: true, disabled: activeChainInfo?.disableStaking, shouldRedirect: activeChain === 'initia', diff --git a/apps/extension/src/components/gas-price-options/index.tsx b/apps/extension/src/components/gas-price-options/index.tsx index 385f38ab..bf772ec5 100644 --- a/apps/extension/src/components/gas-price-options/index.tsx +++ b/apps/extension/src/components/gas-price-options/index.tsx @@ -226,16 +226,12 @@ const GasPriceOptions = ({ } if (hasToCalculateDynamicFee && feeTokenData) { - let isIbcDenom = false + let feeDenom = feeTokenData.denom?.coinMinimalDenom ?? '' if (feeTokenData.ibcDenom?.toLowerCase().startsWith('ibc/')) { - isIbcDenom = true + feeDenom = feeTokenData.ibcDenom ?? feeDenom } - const gasPriceStep = await getFeeMarketGasPricesSteps( - feeTokenData.denom?.coinMinimalDenom ?? '', - feeTokenData.gasPriceStep, - isIbcDenom, - ) + const gasPriceStep = await getFeeMarketGasPricesSteps(feeDenom, feeTokenData.gasPriceStep) setFeeTokenData(() => ({ ...feeTokenData, gasPriceStep: gasPriceStep })) } } diff --git a/apps/extension/src/components/gas-price-options/utils.ts b/apps/extension/src/components/gas-price-options/utils.ts index fd0693a1..e9c695b2 100644 --- a/apps/extension/src/components/gas-price-options/utils.ts +++ b/apps/extension/src/components/gas-price-options/utils.ts @@ -76,15 +76,14 @@ export async function updateFeeTokenData({ captureException(error) } } else if (hasToCalculateDynamicFee && foundFeeTokenData) { - let isIbcDenom = false + let feeDenom = foundFeeTokenData.denom?.coinMinimalDenom ?? '' if (foundFeeTokenData.ibcDenom?.toLowerCase().startsWith('ibc/')) { - isIbcDenom = true + feeDenom = foundFeeTokenData.ibcDenom ?? feeDenom } const gasPriceStep = await getFeeMarketGasPricesSteps( - foundFeeTokenData.denom?.coinMinimalDenom ?? '', + feeDenom, foundFeeTokenData.gasPriceStep, - isIbcDenom, ) feeTokenDataToSet = { ...foundFeeTokenData, gasPriceStep } diff --git a/apps/extension/src/components/header/Header.tsx b/apps/extension/src/components/header/Header.tsx new file mode 100644 index 00000000..acc79825 --- /dev/null +++ b/apps/extension/src/components/header/Header.tsx @@ -0,0 +1,41 @@ +import Text from 'components/text' +import { Images } from 'images' +import React from 'react' +import { isCompassWallet } from 'utils/isCompassWallet' + +type Props = { + heading?: string + subtitle?: string + SubTitleComponent?: React.FC + HeadingComponent?: React.FC + headingSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'jumbo' +} + +export const Header: React.FC = ({ + heading, + subtitle, + SubTitleComponent, + HeadingComponent, +}) => { + return ( +
+ + {HeadingComponent ? ( + + ) : ( + + {heading} + + )} + {subtitle && ( + + {subtitle} + + )} + {SubTitleComponent && } +
+ ) +} diff --git a/apps/extension/src/components/header/index.ts b/apps/extension/src/components/header/index.ts index f76b3366..487cb471 100644 --- a/apps/extension/src/components/header/index.ts +++ b/apps/extension/src/components/header/index.ts @@ -1,2 +1,3 @@ export * from './Header' +export * from './NewChainSupportTooltip' export * from './PageHeader' diff --git a/apps/extension/src/components/search-modal/SearchModal.tsx b/apps/extension/src/components/search-modal/SearchModal.tsx index 5c005634..66f87299 100644 --- a/apps/extension/src/components/search-modal/SearchModal.tsx +++ b/apps/extension/src/components/search-modal/SearchModal.tsx @@ -11,6 +11,7 @@ import { WALLETTYPE } from '@leapwallet/leap-keychain' import classNames from 'classnames' import { AlertStrip } from 'components/alert-strip' import { LoaderAnimation } from 'components/loader/Loader' +import { PageName } from 'config/analytics' import { useActiveChain } from 'hooks/settings/useActiveChain' import { Images } from 'images' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -199,7 +200,7 @@ export function SearchModal() { if (actionName === 'View NFTs') { handleNftsClick() } else { - navigate(config.redirect_url ?? '') + navigate(`${config.redirect_url}?pageSource=${PageName.QuickSearch}` ?? '') } break diff --git a/apps/extension/src/config/analytics.ts b/apps/extension/src/config/analytics.ts index e1c88d00..43eb78a8 100644 --- a/apps/extension/src/config/analytics.ts +++ b/apps/extension/src/config/analytics.ts @@ -13,6 +13,7 @@ export enum EventName { OnboardingCompleted = 'Onboarding Completed', OnboardingClicked = 'Onboarding CTA Clicked', SwapTransactionStatus = 'Transaction Status', + TransactionSigned = 'Transaction Signed', ChainFavorited = 'Chain Favorited', ChainUnfavorited = 'Chain Unfavorited', } @@ -59,6 +60,8 @@ export enum PageName { NFT = 'NFT Collections', SyncWithMobileApp = 'Sync with Mobile App', Airdrops = 'Airdrops', + StakeTxnPage = 'Transaction Completion CTA', + QuickSearch = 'QuickSearch', Buy = 'Buy', OnRampQuotePreview = 'On-ramp quote preview', AssetDetails = 'Asset Details', diff --git a/apps/extension/src/extension-scripts/background.ts b/apps/extension/src/extension-scripts/background.ts index 5c3a88a8..8c682bd3 100644 --- a/apps/extension/src/extension-scripts/background.ts +++ b/apps/extension/src/extension-scripts/background.ts @@ -1585,7 +1585,10 @@ function formatNewChainInfo(chainInfo: any) { ibcChannelIds: {}, feeCurrencies: chainInfo.feeCurrencies, nativeDenoms: { - [rest.coinMinimalDenom]: rest, + [rest.coinMinimalDenom]: { + ...rest, + chain: chainInfo.chainName, + }, }, theme: chainInfo.theme || { primaryColor: '#E18881', diff --git a/apps/extension/src/hooks/settings/useActiveWallet.ts b/apps/extension/src/hooks/settings/useActiveWallet.ts index 108f7b9b..801a01c3 100644 --- a/apps/extension/src/hooks/settings/useActiveWallet.ts +++ b/apps/extension/src/hooks/settings/useActiveWallet.ts @@ -104,7 +104,7 @@ export default function useActiveWallet() { if (isCompassWallet()) { const store = await browser.storage.local.get([ACTIVE_CHAIN]) - const activeChain = store[ACTIVE_CHAIN] as SupportedChain + const activeChain: SupportedChain = store[ACTIVE_CHAIN] ?? 'seiTestnet2' const evmAddress = getSeiEvmAddressToShow(wallet.pubKeys?.[activeChain]) await sendMessageToTab({ event: 'accountsChanged', data: [evmAddress] }) diff --git a/apps/extension/src/hooks/wallet/useWallet.ts b/apps/extension/src/hooks/wallet/useWallet.ts index 28e7d609..ec032a53 100644 --- a/apps/extension/src/hooks/wallet/useWallet.ts +++ b/apps/extension/src/hooks/wallet/useWallet.ts @@ -505,12 +505,13 @@ export namespace Wallet { }) as unknown as OfflineSigner } } else if (activeWallet?.walletType !== WALLETTYPE.LEDGER) { + const coinType = chainInfos[_chain]?.bip44?.coinType const walletId = activeWallet?.id const signer = await KeyChain.getSigner(walletId as string, password as string, { addressPrefix: chainInfos[_chain].addressPrefix, coinType: chainInfos[_chain]?.bip44?.coinType, ethWallet, - pubKeyBech32Address: ethWallet, + pubKeyBech32Address: ethWallet && coinType !== '60', }) return signer as unknown as OfflineSigner } else { diff --git a/apps/extension/src/images/stake/index.ts b/apps/extension/src/images/stake/index.ts index a41760bf..2d9f0f27 100644 --- a/apps/extension/src/images/stake/index.ts +++ b/apps/extension/src/images/stake/index.ts @@ -1,3 +1,4 @@ +import MilkywayLS from './milkyway.png' import NoStakeSVG from './no-stake.svg' -export { NoStakeSVG } +export { MilkywayLS, NoStakeSVG } diff --git a/apps/extension/src/images/stake/milkyway.png b/apps/extension/src/images/stake/milkyway.png new file mode 100644 index 00000000..f23df7b0 Binary files /dev/null and b/apps/extension/src/images/stake/milkyway.png differ diff --git a/apps/extension/src/pages/airdrops/components/EligibleWallets.tsx b/apps/extension/src/pages/airdrops/components/EligibleWallets.tsx index 0ed43c34..c7d31137 100644 --- a/apps/extension/src/pages/airdrops/components/EligibleWallets.tsx +++ b/apps/extension/src/pages/airdrops/components/EligibleWallets.tsx @@ -70,9 +70,6 @@ export default function EligibleWallets({ selectedAirdrop }: EligibleWalletsProp
Eligible wallets - - The airdrop is eligible for the following wallet: -
{walletAvatar ? ( diff --git a/apps/extension/src/pages/asset-details/components/chart-details/index.tsx b/apps/extension/src/pages/asset-details/components/chart-details/index.tsx index 3ec68a3a..26fe9a29 100644 --- a/apps/extension/src/pages/asset-details/components/chart-details/index.tsx +++ b/apps/extension/src/pages/asset-details/components/chart-details/index.tsx @@ -5,7 +5,9 @@ import { LeapWalletApi, sliceWord, Token, + useActiveStakingDenom, useAssetDetails, + useChainInfo, useFeatureFlags, useformatCurrency, useUserPreferredCurrency, @@ -33,6 +35,7 @@ import { useKadoAssets } from 'hooks/useGetKadoDetails' import useQuery from 'hooks/useQuery' import { useDefaultTokenLogo } from 'hooks/utility/useDefaultTokenLogo' import SelectChain from 'pages/home/SelectChain' +import StakeSelectSheet from 'pages/stake-v2/components/StakeSelectSheet' import React, { useEffect, useMemo, useState } from 'react' import Skeleton from 'react-loading-skeleton' import { useLocation, useNavigate } from 'react-router' @@ -46,10 +49,12 @@ import { TokensChart } from './token-chart' type TokenCTAsProps = { isSwapDisabled: boolean + isStakeDisabled: boolean onReceiveClick: () => void onSendClick: () => void onSwapClick: () => void onBuyClick: () => void + onStakeClick: () => void isBuyDisabled: boolean isSendDisabled?: boolean } @@ -58,9 +63,11 @@ function TokenCTAs({ onReceiveClick, onSendClick, onSwapClick, + onStakeClick, isSwapDisabled, isBuyDisabled, isSendDisabled, + isStakeDisabled, onBuyClick, }: TokenCTAsProps) { return ( @@ -90,6 +97,14 @@ function TokenCTAs({ image={{ src: 'swap_horiz', alt: 'Swap' }} onClick={onSwapClick} /> + + { + + }
) } @@ -137,6 +152,7 @@ function TokensDetails() { const [showChainSelector, setShowChainSelector] = useState(false) const [showReceiveSheet, setShowReceiveSheet] = useState(false) + const [showStakeSelectSheet, setShowStakeSelectSheet] = useState(false) const [formatCurrency] = useformatCurrency() const { handleSwapClick } = useHardCodedActions() @@ -236,6 +252,7 @@ function TokensDetails() { const dontShowSelectChain = useDontShowSelectChain() const defaultIconLogo = useDefaultTokenLogo() + const [activeStakingDenom] = useActiveStakingDenom() useEffect(() => { if (kadoSupportedAssets.length > 0 && portfolio) { @@ -437,6 +454,10 @@ function TokensDetails() { onSendClick={() => { navigate('/send', { state }) }} + onStakeClick={() => { + setShowStakeSelectSheet(true) + }} + isStakeDisabled={activeStakingDenom.coinDenom !== denomInfo?.coinDenom} onReceiveClick={() => { setShowReceiveSheet(true) }} @@ -487,6 +508,11 @@ function TokensDetails() { setShowReceiveSheet(false) }} /> + setShowStakeSelectSheet(false)} + /> setShowChainSelector(false)} />
) diff --git a/apps/extension/src/pages/nfts-v2/components/send-nft/index.tsx b/apps/extension/src/pages/nfts-v2/components/send-nft/index.tsx index c092dcec..621e44f5 100644 --- a/apps/extension/src/pages/nfts-v2/components/send-nft/index.tsx +++ b/apps/extension/src/pages/nfts-v2/components/send-nft/index.tsx @@ -22,7 +22,6 @@ import { FeesView } from '../fees-view' import { RecipientCard } from '../recipient-card' import { ReviewNFTTransferSheet } from './review-transfer-sheet' -const SHOW_LEDGER_POPUP = false const SendNftCardContext = createContext(null) export function useSendNftCardContext() { @@ -67,6 +66,7 @@ export function SendNftCard({ nftDetails }: { nftDetails: NftDetailsType }) { isSending, fee, transferNFTContract, + showLedgerPopup, simulateTransferNFTContract, fetchAccountDetailsStatus, fetchAccountDetailsData, @@ -241,7 +241,7 @@ export function SendNftCard({ nftDetails }: { nftDetails: NftDetailsType }) { loading={isProcessing || isSending} fee={fee} onConfirm={handleSendNft} - showLedgerPopup={SHOW_LEDGER_POPUP} + showLedgerPopup={showLedgerPopup} onClose={() => { setShowReviewSheet(false) }} diff --git a/apps/extension/src/pages/nfts-v2/components/send-nft/review-transfer-sheet.tsx b/apps/extension/src/pages/nfts-v2/components/send-nft/review-transfer-sheet.tsx index 3ae83ae3..659e4c6c 100644 --- a/apps/extension/src/pages/nfts-v2/components/send-nft/review-transfer-sheet.tsx +++ b/apps/extension/src/pages/nfts-v2/components/send-nft/review-transfer-sheet.tsx @@ -49,8 +49,8 @@ export const ReviewNFTTransferSheet: React.FC = }) => { const defaultTokenLogo = useDefaultTokenLogo() - if (showLedgerPopup && txError) { - return + if (showLedgerPopup && !txError) { + return } return ( diff --git a/apps/extension/src/pages/sign-sei-evm/SignSeiEvmTransaction.tsx b/apps/extension/src/pages/sign-sei-evm/SignSeiEvmTransaction.tsx index fb9bfcb3..1deef567 100644 --- a/apps/extension/src/pages/sign-sei-evm/SignSeiEvmTransaction.tsx +++ b/apps/extension/src/pages/sign-sei-evm/SignSeiEvmTransaction.tsx @@ -15,11 +15,13 @@ import BigNumber from 'bignumber.js' import PopupLayout from 'components/layout/popup-layout' import { LoaderAnimation } from 'components/loader/Loader' import { MessageTypes } from 'config/message-types' +import { BG_RESPONSE } from 'config/storage-keys' import { Wallet } from 'hooks/wallet/useWallet' import React, { useCallback, useEffect, useMemo, useState } from 'react' import Browser from 'webextension-polyfill' import { MessageSignature, SignTransaction, SignTransactionProps } from './components' +import { handleRejectClick } from './utils' function Loading() { return ( @@ -35,6 +37,15 @@ function Loading() { } function SeiEvmTransaction({ txnData, isEvmTokenExist }: SignTransactionProps) { + useEffect(() => { + window.addEventListener('beforeunload', handleRejectClick) + Browser.storage.local.remove(BG_RESPONSE) + + return () => { + window.removeEventListener('beforeunload', handleRejectClick) + } + }, []) + switch (txnData.signTxnData.methodType) { case ETHEREUM_METHOD_TYPE.PERSONAL_SIGN: case ETHEREUM_METHOD_TYPE.ETH__SIGN: diff --git a/apps/extension/src/pages/sign-sei-evm/components/MessageSignature.tsx b/apps/extension/src/pages/sign-sei-evm/components/MessageSignature.tsx index 959f9df5..c5cdc958 100644 --- a/apps/extension/src/pages/sign-sei-evm/components/MessageSignature.tsx +++ b/apps/extension/src/pages/sign-sei-evm/components/MessageSignature.tsx @@ -92,7 +92,7 @@ export function MessageSignature({ txnData }: MessageSignatureProps) { setTimeout(async () => { window.close() - }, 1000) + }, 100) } catch (error) { setTxStatus('error') setSigningError((error as Error).message) diff --git a/apps/extension/src/pages/sign-sei-evm/components/SignTransaction.tsx b/apps/extension/src/pages/sign-sei-evm/components/SignTransaction.tsx index 396a861b..de4b97c8 100644 --- a/apps/extension/src/pages/sign-sei-evm/components/SignTransaction.tsx +++ b/apps/extension/src/pages/sign-sei-evm/components/SignTransaction.tsx @@ -200,7 +200,7 @@ export function SignTransaction({ txnData, isEvmTokenExist }: SignTransactionPro setTimeout(async () => { window.close() - }, 1000) + }, 100) } catch (error) { setTxStatus('error') setSigningError((error as Error).message) diff --git a/apps/extension/src/pages/sign-sei-evm/utils/shared-functions.ts b/apps/extension/src/pages/sign-sei-evm/utils/shared-functions.ts index 859cd8fa..7ec2b829 100644 --- a/apps/extension/src/pages/sign-sei-evm/utils/shared-functions.ts +++ b/apps/extension/src/pages/sign-sei-evm/utils/shared-functions.ts @@ -9,5 +9,5 @@ export function handleRejectClick() { setTimeout(() => { window.close() - }, 1000) + }, 100) } diff --git a/apps/extension/src/pages/sign/sign-transaction.tsx b/apps/extension/src/pages/sign/sign-transaction.tsx index 573a962e..4973b691 100644 --- a/apps/extension/src/pages/sign/sign-transaction.tsx +++ b/apps/extension/src/pages/sign/sign-transaction.tsx @@ -88,6 +88,7 @@ import { getTxHashFromDirectSignResponse, logDirectTx, logSignAmino, + logSignAminoInj, } from './utils/tx-logger' const useGetWallet = Wallet.useGetWallet @@ -557,7 +558,10 @@ const SignTransaction = ({ } } - const wallet = (await getWallet(activeChain)) as OfflineAminoSigner & { + const wallet = (await getWallet( + activeChain, + !!(ethSignType || eip712Types), + )) as OfflineAminoSigner & { signAmino: ( // eslint-disable-next-line no-unused-vars address: string, @@ -584,12 +588,14 @@ const SignTransaction = ({ ) } if (eip712Types) { - return ethSignEip712( + const signature = await ethSignEip712( activeAddress, wallet as unknown as EthWallet, signDoc as StdSignDoc, eip712Types, ) + + return signature } return wallet.signAmino(activeAddress, signDoc as StdSignDoc, { extraEntropy: !signOptions?.enableExtraEntropy @@ -608,14 +614,30 @@ const SignTransaction = ({ if (!isSignArbitrary) { try { - await logSignAmino( - data as AminoSignResponse, - publicKey, - txPostToDb, - activeChain, - activeAddress, - siteOrigin ?? origin, - ) + if (chainInfo.bip44.coinType === '60' && activeChain === 'injective') { + const evmChainId = + chainInfo.chainId === (signDoc as StdSignDoc).chain_id + ? chainInfo.evmChainId + : chainInfo.evmChainIdTestnet + await logSignAminoInj( + data as AminoSignResponse, + publicKey, + txPostToDb, + evmChainId ?? '1', + activeChain, + activeAddress, + siteOrigin ?? origin, + ) + } else { + await logSignAmino( + data as AminoSignResponse, + publicKey, + txPostToDb, + activeChain, + activeAddress, + siteOrigin ?? origin, + ) + } } catch (e) { captureException(e) } @@ -742,19 +764,19 @@ const SignTransaction = ({ const hasToShowCheckbox = useMemo(() => { if (isSignArbitrary) { - return false + return '' } return Array.isArray(messages) ? isGenericOrSendAuthzGrant(messages?.map((msg) => msg.parsed) ?? null) - : false + : '' }, [isSignArbitrary, messages]) const isApproveBtnDisabled = !dappFeeDenom || !!signingError || !!gasPriceError || - (hasToShowCheckbox === true && checkedGrantAuthBox === false) || + (!!hasToShowCheckbox && checkedGrantAuthBox === false) || (isFeesValid === false && !highFeeAccepted) return ( @@ -783,7 +805,7 @@ const SignTransaction = ({

@@ -960,7 +982,7 @@ const SignTransaction = ({ className={classNames( 'text-xs text-gray-900 dark:text-white-100 dark:bg-gray-900 bg-white-100 p-4 w-full overflow-x-auto mt-3 rounded-2xl', { - 'whitespace-normal break-words': isSignArbitrary, + 'whitespace-pre-line break-words': isSignArbitrary, }, )} > @@ -1026,16 +1048,24 @@ const SignTransaction = ({
{hasToShowCheckbox && ( -
- setCheckedGrantAuthBox(e.target.checked)} - /> - - I've verified the wallet I'm giving permissions to - +
+
setCheckedGrantAuthBox(!checkedGrantAuthBox)}> + {!checkedGrantAuthBox ? ( + + indeterminate_check_box + + + ) : ( + + check_box + + )} +
+ + {hasToShowCheckbox}
)} diff --git a/apps/extension/src/pages/sign/utils/is-generic-or-send-authz-grant.ts b/apps/extension/src/pages/sign/utils/is-generic-or-send-authz-grant.ts index 298ceff1..53d9b493 100644 --- a/apps/extension/src/pages/sign/utils/is-generic-or-send-authz-grant.ts +++ b/apps/extension/src/pages/sign/utils/is-generic-or-send-authz-grant.ts @@ -2,24 +2,43 @@ import { ParsedMessage, ParsedMessageType } from '@leapwallet/parser-parfait' export function isGenericOrSendAuthzGrant(parsedMessages: ParsedMessage[] | null) { if (parsedMessages === null || parsedMessages.length === 0) { - return false + return '' } - let isTrue = false + let message = '' + + for (const parsedMessage of parsedMessages) { + if (parsedMessage.__type === ParsedMessageType.AuthzGrant) { + const messageType = + parsedMessage.grant.authorization['$type_url'] ?? + parsedMessage.grant.authorization['@type'] ?? + parsedMessage.grant.authorization['type'] + + let isTrue = false + const isSendAuthorization = '/cosmos.bank.v1beta1.SendAuthorization' === messageType - for (const message of parsedMessages) { - if (message.__type === ParsedMessageType.AuthzGrant) { if ( - [ - '/cosmos.bank.v1beta1.SendAuthorization', - '/cosmos.authz.v1beta1.GenericAuthorization', - ].includes(message.grant.authorization['$type_url'] ?? message.grant.authorization['@type']) + isSendAuthorization || + ['/cosmos.authz.v1beta1.GenericAuthorization', 'cosmos-sdk/GenericAuthorization'].includes( + messageType, + ) ) { isTrue = true - break + } + + if (isTrue) { + if ( + isSendAuthorization || + parsedMessage.grant.authorization?.value?.msg === '/cosmos.bank.v1beta1.MsgSend' + ) { + message = + 'You are allowing another account to transfer assets from your wallet for the specific time period. Be aware of scammers and approve with caution.' + } else { + message = "I've verified the wallet I'm giving permissions to" + } } } } - return isTrue + return message } diff --git a/apps/extension/src/pages/sign/utils/tx-logger.ts b/apps/extension/src/pages/sign/utils/tx-logger.ts index 21d77043..318435d2 100644 --- a/apps/extension/src/pages/sign/utils/tx-logger.ts +++ b/apps/extension/src/pages/sign/utils/tx-logger.ts @@ -1,7 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import { AminoSignResponse, encodeSecp256k1Pubkey } from '@cosmjs/amino' +import { AminoSignResponse, encodeSecp256k1Pubkey, StdSignDoc } from '@cosmjs/amino' +import { Secp256k1 } from '@cosmjs/crypto' import { fromBase64 } from '@cosmjs/encoding' +import { toBase64 } from '@cosmjs/encoding' import { Int53 } from '@cosmjs/math' import { DirectSignResponse, @@ -12,9 +12,18 @@ import { } from '@cosmjs/proto-signing' import { AminoTypes } from '@cosmjs/stargate' import { AminoMsgTransfer } from '@cosmjs/stargate' +import { createTransaction, SIGN_AMINO } from '@injectivelabs/sdk-ts' import { LeapWalletApi } from '@leapwallet/cosmos-wallet-hooks' import { CosmosTxType } from '@leapwallet/cosmos-wallet-hooks' -import { getTxHashFromSignedTx, SupportedChain } from '@leapwallet/cosmos-wallet-sdk' +import { + createSignerInfo, + fromEthSignature, + getEip712TxHash, + getMsgFromAmino, + getTxHashFromSignedTx, + SupportedChain, +} from '@leapwallet/cosmos-wallet-sdk' +import { ExtensionOptionsWeb3Tx } from '@leapwallet/cosmos-wallet-sdk/dist/browser/proto/ethermint/web3' import { cosmosAminoConverters, cosmosProtoRegistry, @@ -26,6 +35,7 @@ import { osmosisProtoRegistry, } from '@osmosis-labs/proto-codecs' import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing' +import { AuthInfo, TxBody } from 'cosmjs-types/cosmos/tx/v1beta1/tx' import { MsgTransfer } from 'cosmjs-types/ibc/applications/transfer/v1/tx' import Long from 'long' import { strideAminoConverters } from 'stridejs' @@ -144,6 +154,12 @@ const registry = new Registry([ import LogCosmosDappTx = LeapWalletApi.LogCosmosDappTx +const DAPPS_TO_SKIP_TXN_LOGGING = ['cosmos.leapwallet.io', 'swapfast.app'] + +function shouldSkipTxnLogging(origin: string): boolean { + return DAPPS_TO_SKIP_TXN_LOGGING.some((dapp) => origin.trim().toLowerCase().includes(dapp)) +} + export function getTxHashFromDirectSignResponse(data: DirectSignResponse): string { const txHash = getTxHashFromSignedTx({ authInfoBytes: data.signed.authInfoBytes, @@ -165,10 +181,7 @@ export async function logDirectTx( txPostToDb: LogCosmosDappTx, chainId: string, ) { - if ( - origin.trim().toLowerCase().includes('cosmos.leapwallet.io') || - origin.trim().toLowerCase().includes('swapfast.app') - ) { + if (shouldSkipTxnLogging(origin)) { return } @@ -242,6 +255,10 @@ export async function logSignAmino( address: string, origin: string, ) { + if (shouldSkipTxnLogging(origin)) { + return + } + if (data.signed.msgs.find((msg) => msg.type === 'query_permit')) return const txHash = getTxHashFromAminoSignResponse(data, pubkey) @@ -259,3 +276,47 @@ export async function logSignAmino( address, }) } + +export async function logSignAminoInj( + data: AminoSignResponse, + pubkey: Uint8Array, + txPostToDb: LogCosmosDappTx, + evmChainId: string, + chain: SupportedChain, + address: string, + origin: string, +) { + const _signDoc = data.signed as StdSignDoc & { timeout_height: string } + const pubKey = toBase64(Secp256k1.compressPubkey(pubkey)) + const arg = { + message: getMsgFromAmino(_signDoc.msgs as any), + memo: _signDoc.memo, + signMode: SIGN_AMINO, + pubKey, + timeoutHeight: parseInt(_signDoc.timeout_height, 10), + sequence: parseInt(_signDoc.sequence, 10), + accountNumber: parseInt(_signDoc.account_number, 10), + chainId: _signDoc.chain_id, + fee: _signDoc.fee, + } + const { txRaw } = createTransaction(arg) + const txHash = getEip712TxHash({ + signature: data.signature.signature, + ethereumChainId: parseInt(evmChainId ?? '1'), + txRaw, + }) + + await txPostToDb({ + txHash, + txType: CosmosTxType.Dapp, + metadata: { + dapp_url: origin, + tx_message: data.signed.msgs, + }, + feeQuantity: data.signed.fee?.amount[0]?.amount, + feeDenomination: data.signed.fee?.amount[0]?.denom, + chain, + chainId: data.signed.chain_id, + address, + }) +} diff --git a/apps/extension/src/pages/stake-v2/StakeInputPage.tsx b/apps/extension/src/pages/stake-v2/StakeInputPage.tsx new file mode 100644 index 00000000..ec24a84e --- /dev/null +++ b/apps/extension/src/pages/stake-v2/StakeInputPage.tsx @@ -0,0 +1,403 @@ +import { + FeeTokenData, + STAKE_MODE, + useActiveChain, + useActiveStakingDenom, + useGetTokenSpendableBalances, + useSelectedNetwork, + useStakeTx, + useStaking, +} from '@leapwallet/cosmos-wallet-hooks' +import { Delegation } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/staking' +import { Validator } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/validators' +import { Buttons, Header, HeaderActionType } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import GasPriceOptions, { useDefaultGasPrice } from 'components/gas-price-options' +import { DisplayFeeValue, GasPriceOptionValue } from 'components/gas-price-options/context' +import { DisplayFee } from 'components/gas-price-options/display-fee' +import { FeesSettingsSheet } from 'components/gas-price-options/fees-settings-sheet' +import PopupLayout from 'components/layout/popup-layout' +import Text from 'components/text' +import { Wallet } from 'hooks/wallet/useWallet' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router' + +import InactiveValidatorCard from './components/InactiveValidatorCard' +import InsufficientBalanceCard from './components/InsufficientBalanceCard' +import ReviewStakeTx, { buttonTitle } from './components/ReviewStakeTx' +import SelectValidatorCard from './components/SelectValidatorCard' +import SelectValidatorSheet from './components/SelectValidatorSheet' +import YouStake from './components/YouStake' +import useGetWallet = Wallet.useGetWallet +import { YouStakeSkeleton } from 'components/Skeletons/StakeSkeleton' +import { EventName } from 'config/analytics' +import { addSeconds } from 'date-fns' +import mixpanel from 'mixpanel-browser' +import { Colors } from 'theme/colors' +import { timeLeft } from 'utils/timeLeft' + +import AutoAdjustAmountSheet from './components/AutoAdjustModal' +import { StakeTxnPageState } from './StakeTxnPage' + +export type StakeInputPageState = { + mode: STAKE_MODE + toValidator?: Validator + fromValidator?: Validator + delegation?: Delegation +} + +const getTransactionType = (mode: STAKE_MODE) => { + switch (mode) { + case 'DELEGATE': + return 'stake_delegate' + case 'REDELEGATE': + return 'stake_redelegate' + case 'UNDELEGATE': + return 'stake_undelegate' + case 'CANCEL_UNDELEGATION': + return 'stake_cancel_undelegate' + case 'CLAIM_REWARDS': + return 'stake_claim' + default: + return 'stake_delegate' + } +} + +export default function StakeInputPage() { + const [selectedValidator, setSelectedValidator] = useState() + const [showSelectValidatorSheet, setShowSelectValidatorSheet] = useState(false) + const [showFeesSettingSheet, setShowFeesSettingSheet] = useState(false) + const [showReviewStakeTx, setShowReviewStakeTx] = useState(false) + const [hasError, setHasError] = useState(false) + const [loadingSelectedValidator, setLoadingSelectedValidator] = useState(false) + const [showAdjustAmountSheet, setShowAdjustAmountSheet] = useState(false) + const [adjustAmount, setAdjustAmount] = useState(false) + const navigate = useNavigate() + const [activeStakingDenom] = useActiveStakingDenom() + const { allAssets } = useGetTokenSpendableBalances() + const { + toValidator, + fromValidator, + mode = 'DELEGATE', + delegation, + } = useLocation().state as StakeInputPageState + const { network } = useStaking() + const unstakingPeriod = useMemo( + () => + timeLeft( + addSeconds( + new Date(), + network?.chain?.params?.unbonding_time ?? 24 * 60 * 60 + 10, + ).toISOString(), + '', + ), + [network], + ) + const validators = network?.getValidators({}) as Record + const apy = network?.validatorApys + const { + amount, + setAmount, + recommendedGasLimit, + userPreferredGasLimit, + setUserPreferredGasLimit, + userPreferredGasPrice, + gasOption, + setFeeDenom, + isLoading, + onReviewTransaction, + customFee, + feeDenom, + setGasOption, + error, + ledgerError, + setLedgerError, + showLedgerPopup, + } = useStakeTx(mode, selectedValidator as Validator, fromValidator, [delegation as Delegation]) + const activeChain = useActiveChain() + const activeNetwork = useSelectedNetwork() + const defaultGasPrice = useDefaultGasPrice({ + activeChain, + selectedNetwork: activeNetwork, + }) + const getWallet = useGetWallet() + + const [gasError, setGasError] = useState(null) + const [gasPriceOption, setGasPriceOption] = useState({ + option: gasOption, + gasPrice: userPreferredGasPrice ?? defaultGasPrice.gasPrice, + }) + const [displayFeeValue, setDisplayFeeValue] = useState() + + const token = useMemo(() => { + return allAssets?.find((e) => e.symbol === activeStakingDenom.coinDenom) + }, [activeStakingDenom.coinDenom, allAssets]) + + const activeValidators = useMemo( + () => + Object.values(validators ?? {}) + .filter((v) => !v.jailed) + .filter((v) => v.address !== fromValidator?.address), + [fromValidator?.address, validators], + ) + + useEffect(() => { + setGasPriceOption({ + option: gasOption, + gasPrice: defaultGasPrice.gasPrice, + }) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultGasPrice.gasPrice.amount.toString(), defaultGasPrice.gasPrice.denom]) + + useEffect(() => { + if (!selectedValidator) { + setLoadingSelectedValidator(true) + if (toValidator) { + setSelectedValidator(toValidator) + } else { + if (mode === 'DELEGATE') { + const validator = Object.values(validators ?? {}).find( + (v: Validator) => v.custom_attributes?.priority === 0, + ) + if (validator) { + setSelectedValidator(validator) + } + } + } + setLoadingSelectedValidator(false) + } + }, [mode, selectedValidator, toValidator, validators]) + + const onGasPriceOptionChange = useCallback( + (value: GasPriceOptionValue, feeBaseDenom: FeeTokenData) => { + setGasPriceOption(value) + if (value.option) { + setGasOption(value.option) + } + setFeeDenom(feeBaseDenom.denom) + }, + [setFeeDenom, setGasOption], + ) + + const txCallback = useCallback(() => { + navigate('/stake-pending-txn', { + state: { + validator: selectedValidator, + mode, + } as StakeTxnPageState, + }) + mixpanel.track(EventName.TransactionSigned, { + transactionType: getTransactionType(mode), + }) + }, [mode, navigate, selectedValidator]) + + const onSubmit = useCallback(async () => { + try { + const wallet = await getWallet() + await onReviewTransaction(wallet, txCallback, false, { + stdFee: customFee, + feeDenom: feeDenom, + }) + } catch (error) { + const _error = error as Error + setLedgerError(_error.message) + + setTimeout(() => { + setLedgerError('') + }, 6000) + } + }, [customFee, feeDenom, getWallet, onReviewTransaction, setLedgerError, txCallback]) + + useEffect(() => { + if (adjustAmount) { + if (new BigNumber(amount).gt(0)) { + setShowReviewStakeTx(true) + } + } + }, [adjustAmount, amount]) + + return ( +
+ navigate(-1), + type: HeaderActionType.BACK, + }} + title={mode === 'UNDELEGATE' ? 'Unstaking' : 'Staking'} + /> + } + > +
+
+ {fromValidator && ( + + )} + + + {loadingSelectedValidator ? ( + + ) : ( + + )} + {token && new BigNumber(token.amount).isEqualTo(0) && } + {selectedValidator && + selectedValidator.active === false && + (mode === 'DELEGATE' || mode === 'REDELEGATE') && } +
+
+ {new BigNumber(amount).isGreaterThan(0) && ( +
+
+ + Unstaking period + + + {unstakingPeriod} + +
+
setShowFeesSettingSheet(true)} + className='flex gap-x-1 items-center hover:cursor-pointer' + > + + local_gas_station + + + {displayFeeValue?.fiatValue} + + + expand_more + +
+
+ )} + { + if ( + mode === 'DELEGATE' && + parseFloat(amount) + (displayFeeValue?.value ?? 0) > + parseFloat(token?.amount ?? '') + ) { + setShowAdjustAmountSheet(true) + } else { + setShowReviewStakeTx(true) + } + }} + disabled={ + !new BigNumber(amount).isGreaterThan(0) || + hasError || + !selectedValidator || + !!ledgerError + } + > + {hasError ? 'Insufficient Balance' : `Review ${buttonTitle[mode]}`} + +
+
+
+ setShowSelectValidatorSheet(false)} + onValidatorSelect={(validator) => { + setSelectedValidator(validator) + setShowSelectValidatorSheet(false) + }} + validators={activeValidators} + apy={apy} + /> + {selectedValidator && ( + setShowReviewStakeTx(false)} + onSubmit={onSubmit} + tokenAmount={amount} + token={token} + validator={selectedValidator} + error={error} + gasError={gasError} + mode={mode} + unstakingPeriod={unstakingPeriod} + showLedgerPopup={showLedgerPopup} + /> + )} + + {mode === 'DELEGATE' && token && customFee && showAdjustAmountSheet ? ( + { + setShowAdjustAmountSheet(false) + setAdjustAmount(true) + // setShowReviewStakeTx(true) + }} + onCancel={() => { + setShowAdjustAmountSheet(false) + }} + isOpen={showAdjustAmountSheet} + /> + ) : null} + setUserPreferredGasLimit(Number(value.toString()))} + gasPriceOption={gasPriceOption} + onGasPriceOptionChange={onGasPriceOptionChange} + error={gasError} + setError={setGasError} + > + + setShowFeesSettingSheet(false)} + gasError={null} + /> + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/StakePage.tsx b/apps/extension/src/pages/stake-v2/StakePage.tsx new file mode 100644 index 00000000..9801bc2d --- /dev/null +++ b/apps/extension/src/pages/stake-v2/StakePage.tsx @@ -0,0 +1,363 @@ +import { + useActiveStakingDenom, + useLiquidStakingProviders, + useStaking, + WALLETTYPE, +} from '@leapwallet/cosmos-wallet-hooks' +import { SupportedChain } from '@leapwallet/cosmos-wallet-sdk' +import { Validator } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/validators' +import { Buttons, HeaderActionType, ThemeName, useTheme } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomNav, { BottomNavLabel } from 'components/bottom-nav/BottomNav' +import { WalletButton } from 'components/button' +import { EmptyCard } from 'components/empty-card' +import { PageHeader } from 'components/header' +import PopupLayout from 'components/layout/popup-layout' +import { AmountCardSkeleton } from 'components/Skeletons/StakeSkeleton' +import Text from 'components/text' +import { LEDGER_NAME_EDITED_SUFFIX_REGEX } from 'config/config' +import { walletLabels } from 'config/constants' +import { decodeChainIdToChain } from 'extension-scripts/utils' +import { useChainPageInfo } from 'hooks' +import { useSetActiveChain } from 'hooks/settings/useActiveChain' +import useActiveWallet from 'hooks/settings/useActiveWallet' +import { useDontShowSelectChain } from 'hooks/useDontShowSelectChain' +import { useGetWalletAddresses } from 'hooks/useGetWalletAddresses' +import useQuery from 'hooks/useQuery' +import { Images } from 'images' +import SelectChain from 'pages/home/SelectChain' +import SelectWallet from 'pages/home/SelectWallet' +import SideNav from 'pages/home/side-nav' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import Skeleton from 'react-loading-skeleton' +import { useNavigate } from 'react-router' +import { Colors } from 'theme/colors' +import { UserClipboard } from 'utils/clipboard' +import { formatWalletName } from 'utils/formatWalletName' +import { isCompassWallet } from 'utils/isCompassWallet' + +import ClaimInfo from './components/ClaimInfo' +import NotStakedCard from './components/NotStakedCard' +import ReviewClaimAndStakeTx from './components/ReviewClaimAndStakeTx' +import ReviewClaimTx from './components/ReviewClaimTx' +import SelectLSProvider from './components/SelectLSProvider' +import StakeAmountCard from './components/StakeAmountCard' +import StakeHeading from './components/StakeHeading' +import TabList from './components/TabList' +import { StakeInputPageState } from './StakeInputPage' + +export default function StakePage() { + const { headerChainImgSrc } = useChainPageInfo() + const dontShowSelectChain = useDontShowSelectChain() + const walletAddresses = useGetWalletAddresses() + const setActiveChain = useSetActiveChain() + + const query = useQuery() + const paramValidatorAddress = query.get('validatorAddress') ?? undefined + const paramChainId = query.get('chainId') ?? undefined + const paramAction = query.get('action') ?? undefined + + const navigate = useNavigate() + const { activeWallet } = useActiveWallet() + const { + network, + rewards, + delegations, + loadingDelegations, + loadingNetwork, + loadingRewards, + loadingUnboundingDelegations, + } = useStaking() + + const isLoadingAll = useMemo(() => { + return loadingDelegations || loadingNetwork || loadingRewards || loadingUnboundingDelegations + }, [loadingDelegations, loadingNetwork, loadingRewards, loadingUnboundingDelegations]) + + const [activeStakingDenom] = useActiveStakingDenom() + const { theme } = useTheme() + + const [showChainSelector, setShowChainSelector] = useState(false) + const [showSelectWallet, setShowSelectWallet] = useState(false) + const [showSideNav, setShowSideNav] = useState(false) + const [showReviewClaimTx, setShowReviewClaimTx] = useState(false) + const [showReviewClaimAndStakeTx, setShowReviewClaimAndStakeTx] = useState(false) + const [showClaimInfo, setShowClaimInfo] = useState(false) + const [showSelectLSProvider, setShowSelectLSProvider] = useState(false) + const [isWalletAddressCopied, setIsWalletAddressCopied] = useState(false) + + const { isLoading: isLSProvidersLoading, data: lsProviders } = useLiquidStakingProviders() + + const handleOpenSelectChainSheet = useCallback(() => setShowChainSelector(true), []) + const handleOpenWalletSheet = useCallback(() => setShowSelectWallet(true), []) + const handleCopyClick = useCallback(() => { + setIsWalletAddressCopied(true) + setTimeout(() => setIsWalletAddressCopied(false), 2000) + + UserClipboard.copyText(walletAddresses?.[0]) + }, [walletAddresses]) + + const tokenLSProviders = useMemo(() => { + const _sortedTokenProviders = lsProviders[activeStakingDenom.coinDenom]?.sort((a, b) => { + const priorityA = a.priority + const priorityB = b.priority + + if (priorityA !== undefined && priorityB !== undefined) { + return priorityA - priorityB + } else if (priorityA !== undefined) { + return -1 + } else if (priorityB !== undefined) { + return 1 + } else { + return 0 + } + }) + return _sortedTokenProviders + }, [activeStakingDenom.coinDenom, lsProviders]) + + const walletAvatar = useMemo(() => { + if (activeWallet?.avatar) { + return activeWallet.avatar + } + + if (isCompassWallet()) { + return Images.Logos.CompassCircle + } + + return Images.Logos.LeapLogo28 + }, [activeWallet?.avatar]) + + const chainRewards = useMemo(() => { + const rewardMap: Record = {} + + rewards?.rewards?.forEach((rewardObj: any) => { + const validatorAddress = rewardObj.validator_address + + if (!rewardMap[validatorAddress]) { + rewardMap[validatorAddress] = { + validator_address: validatorAddress, + reward: [], + } + } + const accumulatedAmounts: any = {} + rewardObj.reward.forEach((reward: any) => { + const { denom, amount, tokenInfo } = reward + const numAmount = parseFloat(amount) + + if (accumulatedAmounts[denom]) { + accumulatedAmounts[denom] += numAmount * Math.pow(10, tokenInfo?.coinDecimals ?? 6) + } else { + accumulatedAmounts[denom] = numAmount * Math.pow(10, tokenInfo?.coinDecimals ?? 6) + } + }) + rewardMap[validatorAddress].reward.push( + ...Object.keys(accumulatedAmounts).map((denom) => ({ + denom, + amount: accumulatedAmounts[denom], + })), + ) + }) + + const totalRewards = rewards?.total.find( + (reward) => reward.denom === activeStakingDenom.coinMinimalDenom, + ) + + const rewardsStatus = '' + const usdValueStatus = '' + return { + rewardsUsdValue: new BigNumber(totalRewards?.currenyAmount ?? '0'), + rewardsStatus, + usdValueStatus, + denom: totalRewards?.denom, + rewardsDenomValue: new BigNumber(totalRewards?.amount ?? '0'), + rewards: { + rewardMap, + }, + } + }, [activeStakingDenom, rewards]) + + useEffect(() => { + async function updateChain() { + if (paramChainId) { + const chainIdToChain = await decodeChainIdToChain() + const chain = chainIdToChain[paramChainId] as SupportedChain + setActiveChain(chain) + } + } + updateChain() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paramChainId]) + + const redirectToInputPage = useCallback(() => { + const validators = network?.getValidators() as Record + navigate('/stakeInput', { + state: { + mode: 'DELEGATE', + toValidator: paramValidatorAddress ? validators[paramValidatorAddress] : undefined, + } as StakeInputPageState, + replace: true, + }) + }, [navigate, network, paramValidatorAddress]) + + useEffect(() => { + switch (paramAction) { + case 'CLAIM_REWARDS': + setShowReviewClaimTx(true) + break + case 'OPEN_LIQUID_STAKING': + setShowSelectLSProvider(true) + break + case 'DELEGATE': + redirectToInputPage() + break + default: + break + } + }, [paramAction, redirectToInputPage]) + + const handleCloseLSProviderSheet = useCallback(() => { + setShowSelectLSProvider(false) + }, []) + + const walletName = useMemo(() => { + if (!activeWallet) { + return '-' + } + return activeWallet.walletType === WALLETTYPE.LEDGER && + !LEDGER_NAME_EDITED_SUFFIX_REGEX.test(activeWallet.name) + ? `${walletLabels[activeWallet.walletType]} Wallet ${activeWallet.addressIndex + 1}` + : formatWalletName(activeWallet.name) + }, [activeWallet]) + + if (!activeWallet) { + return ( +
+ +
+ +
+
+
+ ) + } + + return ( +
+ setShowSideNav(!showSideNav)} /> + setShowSideNav(true), + type: HeaderActionType.NAVIGATION, + className: 'w-[48px] h-[40px] px-3 bg-[#FFFFFF] dark:bg-gray-950 rounded-full', + }} + imgSrc={headerChainImgSrc} + onImgClick={dontShowSelectChain ? undefined : handleOpenSelectChainSheet} + title={ + + } + /> + } + > +
+ + {isLoadingAll && } + {!isLoadingAll && + (Object.values(delegations ?? {}).length > 0 ? ( + setShowReviewClaimTx(true)} + onClaimAndStake={() => setShowClaimInfo(true)} + /> + ) : ( + + ))} +
+ {isLSProvidersLoading && } + {!isLSProvidersLoading && ( + <> + {tokenLSProviders?.length > 0 && ( + setShowSelectLSProvider(true)} + > + Liquid Stake + + )} + { + navigate('/stakeInput', { + state: { + mode: 'DELEGATE', + } as StakeInputPageState, + }) + }} + > + Stake + + + )} +
+ +
+
+ setShowChainSelector(false)} /> + setShowSelectWallet(false)} + title='Wallets' + /> + {!loadingNetwork && ( + <> + setShowReviewClaimTx(false)} + validators={network?.getValidators({}) as Record} + /> + setShowClaimInfo(false)} + onClaim={() => { + setShowClaimInfo(false) + setShowReviewClaimTx(true) + }} + onClaimAndStake={() => { + setShowClaimInfo(false) + setShowReviewClaimAndStakeTx(true) + }} + /> + {chainRewards && ( + setShowReviewClaimAndStakeTx(false)} + validators={network?.getValidators({}) as Record} + chainRewards={chainRewards} + /> + )} + + )} + {tokenLSProviders && ( + + )} + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/StakeTxnPage.tsx b/apps/extension/src/pages/stake-v2/StakeTxnPage.tsx new file mode 100644 index 00000000..be464c44 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/StakeTxnPage.tsx @@ -0,0 +1,300 @@ +import { isDeliverTxSuccess } from '@cosmjs/stargate' +import { + sliceAddress, + useInvalidateDelegations, + useInvalidateTokenBalances, + usePendingTxState, + useSelectedNetwork, + useStaking, + useValidatorImage, +} from '@leapwallet/cosmos-wallet-hooks' +import { STAKE_MODE } from '@leapwallet/cosmos-wallet-hooks' +import { sliceWord } from '@leapwallet/cosmos-wallet-hooks/dist/utils/strings' +import { Validator } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/validators' +import { Buttons, GenericCard, Header, ThemeName, useTheme } from '@leapwallet/leap-ui' +import PopupLayout from 'components/layout/popup-layout' +import { LoaderAnimation } from 'components/loader/Loader' +import Text from 'components/text' +import { PageName } from 'config/analytics' +import { useActiveChain } from 'hooks/settings/useActiveChain' +import { useChainInfos } from 'hooks/useChainInfos' +import { Images } from 'images' +import { GenericLight } from 'images/logos' +import React, { useEffect, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router' +import { Colors } from 'theme/colors' +import { UserClipboard } from 'utils/clipboard' +import { imgOnError } from 'utils/imgOnError' + +export type StakeTxnPageState = { + validator: Validator + mode: STAKE_MODE | 'CLAIM_AND_DELEGATE' +} + +type TransactionStatusTexts = { + loading: string + success: string + failed: string +} + +type TransactionTypes = { + [key in STAKE_MODE | 'CLAIM_AND_DELEGATE']: TransactionStatusTexts +} + +const txStatusStyles = { + loading: { + title: 'In Progress...', + }, + success: { + title: 'Complete', + }, + failed: { + title: 'Failed', + }, +} + +const txStatusText: TransactionTypes = { + CLAIM_REWARDS: { + loading: 'claiming rewards', + success: 'claimed successfully', + failed: 'failed claiming', + }, + DELEGATE: { + loading: 'staking', + success: 'staked successfully', + failed: 'failed staking', + }, + UNDELEGATE: { + loading: 'unstaking', + success: 'unstaked successfully', + failed: 'failed unstaking', + }, + CANCEL_UNDELEGATION: { + loading: 'cancelling unstake', + success: 'unstake cancelled successfully', + failed: 'failed cancelling unstake', + }, + REDELEGATE: { + loading: 'switching validator', + success: 'validator switched successfully', + failed: 'failed switching validator', + }, + CLAIM_AND_DELEGATE: { + loading: 'claiming and staking rewards', + success: 'claimed and staked successfully', + failed: 'failed claiming and staking', + }, +} + +export default function StakeTxnPage() { + const { validator, mode } = useLocation().state as StakeTxnPageState + const { pendingTx, setPendingTx } = usePendingTxState() + const navigate = useNavigate() + const chainInfos = useChainInfos() + const activeChain = useActiveChain() + const [txHash, setTxHash] = useState('') + const [amount, setAmount] = useState('') + const [copied, setCopied] = useState(false) + const selectedNetwork = useSelectedNetwork() + const { data: imageUrl } = useValidatorImage(validator) + const invalidateBalances = useInvalidateTokenBalances() + const invalidateDelegations = useInvalidateDelegations() + const { refetchDelegatorRewards } = useStaking() + const { theme } = useTheme() + useEffect(() => { + setTxHash(pendingTx?.txHash) + }, [pendingTx?.txHash]) + + useEffect(() => { + if (pendingTx && pendingTx.promise) { + pendingTx.promise + .then( + (result) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (result && isDeliverTxSuccess(result)) { + setPendingTx({ ...pendingTx, txStatus: 'success' }) + } else { + setPendingTx({ ...pendingTx, txStatus: 'failed' }) + } + }, + () => setPendingTx({ ...pendingTx, txStatus: 'failed' }), + ) + .catch(() => { + setPendingTx({ ...pendingTx, txStatus: 'failed' }) + }) + .finally(() => { + invalidateBalances(activeChain) + invalidateDelegations() + refetchDelegatorRewards() + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const txnUrl = useMemo(() => { + const txExplorer = chainInfos[activeChain].txExplorer + + if (!txExplorer?.[selectedNetwork]?.txUrl || !txHash) { + return '' + } + + return `${txExplorer?.[selectedNetwork]?.txUrl}/${txHash}` + }, [chainInfos, activeChain, selectedNetwork, txHash]) + + useEffect(() => { + const _amount = + mode === 'CLAIM_REWARDS' || mode === 'UNDELEGATE' + ? pendingTx?.receivedUsdValue + : pendingTx?.sentUsdValue + setAmount(_amount) + }, [mode, pendingTx?.receivedUsdValue, pendingTx?.sentUsdValue]) + + const handleCopyClick = () => { + UserClipboard.copyText(txHash ?? '') + setCopied(true) + + setTimeout(() => { + setCopied(false) + }, 2000) + } + + return ( + +
+
+
+
+
+ {pendingTx?.txStatus === 'loading' && ( + + )} + {pendingTx?.txStatus === 'success' && ( + + )} + {pendingTx?.txStatus === 'failed' && ( + + )} +
+ +
+ + {amount} + + + {txStatusText[mode][pendingTx?.txStatus ?? 'loading']} + +
+
+ + + {sliceWord(validator.moniker, 30, 0)} + +
+
+ {txHash && ( + + Transaction ID + + } + subtitle={ + + {sliceAddress(txHash)} + + } + className='py-4 px-6 bg-white-100 dark:bg-gray-950' + size='md' + icon={ +
+ {copied ? ( + + ) : ( + + content_copy + + )} + + {txnUrl && ( + { + event.stopPropagation() + window.open(txnUrl, '_blank') + }} + > + open_in_new + + )} +
+ } + /> + )} +
+ +
+ navigate('/home')} + size='normal' + color={theme === ThemeName.DARK ? Colors.gray800 : Colors.gray200} + className='mt-auto w-full' + > + Home + + { + if (mode === 'DELEGATE') { + navigate(-1) + } else { + navigate(`/stake?pageSource=${PageName.StakeTxnPage}`) + } + }} + color={ + pendingTx?.txStatus === 'failed' || mode === 'DELEGATE' + ? Colors.green600 + : theme === ThemeName.DARK + ? Colors.white100 + : Colors.black100 + } + size='normal' + className='mt-auto w-full' + disabled={pendingTx?.txStatus === 'loading'} + > + + {pendingTx?.txStatus === 'failed' + ? 'Retry' + : mode === 'DELEGATE' + ? 'Stake Again' + : 'Done'} + + +
+
+ + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/AutoAdjustModal.tsx b/apps/extension/src/pages/stake-v2/components/AutoAdjustModal.tsx new file mode 100644 index 00000000..de3d40ae --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/AutoAdjustModal.tsx @@ -0,0 +1,97 @@ +import { Token } from '@leapwallet/cosmos-wallet-hooks' +import { fromSmall, toSmall } from '@leapwallet/cosmos-wallet-sdk' +import { Buttons, ThemeName, useTheme } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import React, { useCallback, useMemo } from 'react' +import { Colors } from 'theme/colors' + +type AutoAdjustSheetProps = { + onCancel: () => void + onAdjust: () => void + isOpen: boolean + tokenAmount: string + fee: { amount: string; denom: string } + // eslint-disable-next-line no-unused-vars + setTokenAmount: (amount: string) => void + token: Token +} + +export default function AutoAdjustAmountSheet({ + isOpen, + tokenAmount, + fee, + setTokenAmount, + onAdjust, + onCancel, + token, +}: AutoAdjustSheetProps) { + const { theme } = useTheme() + const updatedAmount = useMemo(() => { + const tokenAmount = toSmall(token.amount ?? '0', token?.coinDecimals ?? 6) + const maxMinimalTokens = new BigNumber(tokenAmount).minus(fee?.amount ?? '') + if (maxMinimalTokens.lte(0)) return '0' + const maxTokens = new BigNumber( + fromSmall(maxMinimalTokens.toString(), token?.coinDecimals ?? 6), + ).toFixed(6, 1) + return maxTokens + }, [fee?.amount, token.amount, token?.coinDecimals]) + + const handleAdjust = useCallback(() => { + if (updatedAmount) { + setTokenAmount(updatedAmount) + onAdjust() + } else { + onCancel() + } + }, [onAdjust, onCancel, setTokenAmount, updatedAmount]) + + const displayTokenAmount = useMemo(() => { + return `${tokenAmount} ${token.symbol ?? ''}` + }, [token.symbol, tokenAmount]) + + const displayUpdatedAmount = useMemo(() => { + if (updatedAmount) { + return `${updatedAmount} ${token.symbol ?? ''}` + } + return null + }, [token.symbol, updatedAmount]) + + return ( + +
+

Insufficient {token.symbol ?? ''} balance to pay transaction fees.

+

+ Should we adjust the amount from{' '} + {displayTokenAmount} to{' '} + {displayUpdatedAmount ?? '-'}? +

+
+
+ + Cancel Transaction + + + Auto-adjust + +
+
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/ClaimInfo.tsx b/apps/extension/src/pages/stake-v2/components/ClaimInfo.tsx new file mode 100644 index 00000000..276d9b13 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/ClaimInfo.tsx @@ -0,0 +1,175 @@ +import { + formatTokenAmount, + useActiveStakingDenom, + useStaking, +} from '@leapwallet/cosmos-wallet-hooks' +import { GenericCard } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import Text from 'components/text' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { useHideAssets } from 'hooks/settings/useHideAssets' +import React, { useMemo } from 'react' + +interface ClaimInfoProps { + isOpen: boolean + onClose: () => void + onClaim: () => void + onClaimAndStake: () => void +} + +export default function ClaimInfo({ isOpen, onClose, onClaim, onClaimAndStake }: ClaimInfoProps) { + const { formatHideBalance } = useHideAssets() + const [formatCurrency] = useFormatCurrency() + const [activeStakingDenom] = useActiveStakingDenom() + + const { totalRewardsDollarAmt, rewards } = useStaking() + const nativeTokenReward = useMemo(() => { + if (rewards) { + return rewards.total?.find((token) => token.denom === activeStakingDenom.coinMinimalDenom) + } + }, [activeStakingDenom.coinMinimalDenom, rewards]) + + const isClaimAndStakeDisabled = useMemo( + () => !nativeTokenReward || new BigNumber(nativeTokenReward.amount).lt(0.00001), + [nativeTokenReward], + ) + + const formattedNativeTokenReward = useMemo(() => { + return formatHideBalance( + formatTokenAmount(nativeTokenReward?.amount ?? '', activeStakingDenom.coinDenom), + ) + }, [activeStakingDenom.coinDenom, formatHideBalance, nativeTokenReward?.amount]) + + const nativeRewardTitle = useMemo(() => { + if (new BigNumber(nativeTokenReward?.currenyAmount ?? '').gt(0)) { + return formatHideBalance( + formatCurrency(new BigNumber(nativeTokenReward?.currenyAmount ?? '')), + ) + } else { + return formattedNativeTokenReward + } + }, [ + formatCurrency, + formatHideBalance, + formattedNativeTokenReward, + nativeTokenReward?.currenyAmount, + ]) + + const nativeRewardSubtitle = useMemo(() => { + if (new BigNumber(nativeTokenReward?.currenyAmount ?? '').gt(0)) { + return formattedNativeTokenReward + } + return '' + }, [formattedNativeTokenReward, nativeTokenReward?.currenyAmount]) + + const formattedTokenReward = useMemo(() => { + return formatHideBalance( + `${formatTokenAmount(nativeTokenReward?.amount ?? '', activeStakingDenom.coinDenom)} ${ + rewards?.total?.length > 1 ? `+${rewards.total.length - 1} more` : '' + }`, + ) + }, [ + activeStakingDenom.coinDenom, + formatHideBalance, + nativeTokenReward?.amount, + rewards?.total.length, + ]) + + const totalRewardTitle = useMemo(() => { + if (new BigNumber(totalRewardsDollarAmt).gt(0)) { + return formatHideBalance(formatCurrency(new BigNumber(totalRewardsDollarAmt))) + } else { + return formattedTokenReward + } + }, [formatCurrency, formatHideBalance, formattedTokenReward, totalRewardsDollarAmt]) + + const totalRewardSubtitle = useMemo(() => { + if (new BigNumber(totalRewardsDollarAmt).gt(0)) { + return formattedTokenReward + } + return '' + }, [formattedTokenReward, totalRewardsDollarAmt]) + + return ( + +
+
+ {`Claim rewards on ${activeStakingDenom.coinDenom}`} + + {totalRewardTitle} + + } + subtitle={ + + {totalRewardSubtitle} + + } + size='md' + isRounded + className='bg-white-100 dark:bg-gray-950' + title2={ + + } + /> +
+ +
+ + Auto stake the rewards earned + + + {nativeRewardTitle} + + } + subtitle={ + + {nativeRewardSubtitle} + + } + size='md' + isRounded + className='bg-white-100 dark:bg-gray-950' + title2={ + + } + /> +
+
+
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/ComingSoonCard.tsx b/apps/extension/src/pages/stake-v2/components/ComingSoonCard.tsx new file mode 100644 index 00000000..241d217f --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/ComingSoonCard.tsx @@ -0,0 +1,22 @@ +import { useActiveChain } from '@leapwallet/cosmos-wallet-hooks' +import { useChainInfos } from 'hooks/useChainInfos' +import React from 'react' + +import StakeStatusCard from './StakeStatusCard' + +export default function ComingSoonCard({ onAction }: { onAction: () => void }) { + const activeChain = useActiveChain() + const chainInfos = useChainInfos() + const activeChainInfo = chainInfos[activeChain] + + return ( + + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/InactiveValidatorCard.tsx b/apps/extension/src/pages/stake-v2/components/InactiveValidatorCard.tsx new file mode 100644 index 00000000..4f9a08c6 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/InactiveValidatorCard.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +export default function InactiveValidatorCard() { + return ( +
+ + info + +

+ You are staking with an{' '} + inactive validator and won’t + earn any rewards as long as the validator is inactive. +

+
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/InsufficientBalanceCard.tsx b/apps/extension/src/pages/stake-v2/components/InsufficientBalanceCard.tsx new file mode 100644 index 00000000..68b89c0f --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/InsufficientBalanceCard.tsx @@ -0,0 +1,34 @@ +import { useActiveStakingDenom, useChainInfo } from '@leapwallet/cosmos-wallet-hooks' +import Text from 'components/text' +import React from 'react' +import { useNavigate } from 'react-router' + +export default function InsufficientBalanceCard() { + const [activeStakingDenom] = useActiveStakingDenom() + const chain = useChainInfo() + const navigate = useNavigate() + const osmosisChainInfo = useChainInfo('osmosis') + + return ( +
+
+ + Insufficient balance to stake + + + Get {activeStakingDenom.coinDenom} to stake and earn rewards + +
+ +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/NotStakedCard.tsx b/apps/extension/src/pages/stake-v2/components/NotStakedCard.tsx new file mode 100644 index 00000000..93fac0c5 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/NotStakedCard.tsx @@ -0,0 +1,19 @@ +import { useActiveStakingDenom } from '@leapwallet/cosmos-wallet-hooks' +import Text from 'components/text' +import { Images } from 'images' +import React from 'react' + +export default function NotStakedCard() { + const [activeStakingDenom] = useActiveStakingDenom() + return ( +
+ + You haven't staked any {activeStakingDenom.coinDenom} + + + Stake tokens to earn rewards + + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/NotSupportedCard.tsx b/apps/extension/src/pages/stake-v2/components/NotSupportedCard.tsx new file mode 100644 index 00000000..dd33a7e2 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/NotSupportedCard.tsx @@ -0,0 +1,22 @@ +import { useActiveChain } from '@leapwallet/cosmos-wallet-hooks' +import { useChainInfos } from 'hooks/useChainInfos' +import React from 'react' + +import StakeStatusCard from './StakeStatusCard' + +export default function NotSupportedCard({ onAction }: { onAction: () => void }) { + const activeChain = useActiveChain() + const chainInfos = useChainInfos() + const activeChainInfo = chainInfos[activeChain] + return ( + + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/PendingUnstakeList.tsx b/apps/extension/src/pages/stake-v2/components/PendingUnstakeList.tsx new file mode 100644 index 00000000..26c01a7c --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/PendingUnstakeList.tsx @@ -0,0 +1,134 @@ +import { + sliceWord, + useIsCancleUnstakeSupported, + useStaking, + useValidatorImage, +} from '@leapwallet/cosmos-wallet-hooks' +import { UnbondingDelegation, UnbondingDelegationEntry } from '@leapwallet/cosmos-wallet-sdk' +import { Validator } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/validators' +import BigNumber from 'bignumber.js' +import { ValidatorListItemSkeleton } from 'components/Skeletons/ValidatorListSkeleton' +import Text from 'components/text' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { useHideAssets } from 'hooks/settings/useHideAssets' +import { Images } from 'images' +import React, { useState } from 'react' +import { imgOnError } from 'utils/imgOnError' +import { timeLeft } from 'utils/timeLeft' + +import UnstakedValidatorDetails from './UnstakedValidatorDetails' + +export default function PendingUnstakeList() { + const { isCancleUnstakeSupported } = useIsCancleUnstakeSupported() + const { unboundingDelegationsInfo, network, loadingUnboundingDelegations } = useStaking() + const isLoading = loadingUnboundingDelegations + const [formatCurrency] = useFormatCurrency() + const { formatHideBalance } = useHideAssets() + const [showUnstakeValidatorDetails, setShowUnstakeValidatorDetails] = useState(false) + const [selectedUnbondingDelegation, setSelectedUnbondingDelegation] = useState< + UnbondingDelegation | undefined + >() + const [selectedDelegationEntry, setSelectedDelegationEntry] = useState< + UnbondingDelegationEntry | undefined + >() + const validators = network?.getValidators({}) as Record + + if (!isLoading && (Object.values(unboundingDelegationsInfo ?? {}).length === 0 || !validators)) { + return <> + } + + return ( + <> + {isLoading && } + {!isLoading && validators && unboundingDelegationsInfo && ( +
+
+ + Validator + + + Amount Staked + +
+ {Object.values(unboundingDelegationsInfo ?? {}).map((uds) => { + const validator = validators[uds?.validator_address] + return uds.entries.map((ud, idx) => { + const timeLeftText = timeLeft(ud.completion_time) + const Component = () => { + const { data: imageUrl } = useValidatorImage(validator) + return ( +
{ + if (isCancleUnstakeSupported) { + setShowUnstakeValidatorDetails(true) + setSelectedUnbondingDelegation(uds) + setSelectedDelegationEntry(ud) + } + }} + className={`flex justify-between items-center px-4 py-3 bg-white-100 dark:bg-gray-950 cursor-pointer rounded-xl ${ + isCancleUnstakeSupported && 'cursor-pointer' + }`} + > +
+ +
+
+ + {sliceWord(validator.moniker, 10, 3)} + + + {timeLeftText} + +
+
+ + {formatCurrency(new BigNumber(ud.currencyBalance ?? ''))} + + + {formatHideBalance(ud.formattedBalance ?? '')} + +
+
+
+
+ ) + } + return + }) + })} +
+ )} + {selectedUnbondingDelegation && selectedDelegationEntry && validators && ( + setShowUnstakeValidatorDetails(false)} + unbondingDelegation={selectedUnbondingDelegation} + unbondingDelegationEntry={selectedDelegationEntry} + validator={validators[selectedUnbondingDelegation.validator_address]} + /> + )} + + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/ReviewCancelUnstakeTx.tsx b/apps/extension/src/pages/stake-v2/components/ReviewCancelUnstakeTx.tsx new file mode 100644 index 00000000..bb4a6d34 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/ReviewCancelUnstakeTx.tsx @@ -0,0 +1,259 @@ +import { + daysLeft, + FeeTokenData, + sliceWord, + useActiveStakingDenom, + useStakeTx, + useValidatorImage, +} from '@leapwallet/cosmos-wallet-hooks' +import { UnbondingDelegationEntry, Validator } from '@leapwallet/cosmos-wallet-sdk' +import { Buttons, ThemeName, useTheme } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import Text from 'components/text' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { Wallet } from 'hooks/wallet/useWallet' +import { Images } from 'images' +import React, { useCallback, useEffect, useState } from 'react' +import { useNavigate } from 'react-router' +import { timeLeft } from 'utils/timeLeft' +import useGetWallet = Wallet.useGetWallet + +import BottomModal from 'components/bottom-modal' +import GasPriceOptions, { useDefaultGasPrice } from 'components/gas-price-options' +import { GasPriceOptionValue } from 'components/gas-price-options/context' +import { DisplayFee } from 'components/gas-price-options/display-fee' +import { FeesSettingsSheet } from 'components/gas-price-options/fees-settings-sheet' +import LedgerConfirmationPopup from 'components/ledger-confirmation/LedgerConfirmationPopup' +import { LoaderAnimation } from 'components/loader/Loader' +import { EventName } from 'config/analytics' +import { useDefaultTokenLogo } from 'hooks' +import { useCaptureTxError } from 'hooks/utility/useCaptureTxError' +import { GenericLight } from 'images/logos' +import mixpanel from 'mixpanel-browser' +import { Colors } from 'theme/colors' +import { imgOnError } from 'utils/imgOnError' + +import { StakeTxnPageState } from '../StakeTxnPage' + +interface ReviewCancelUnstakeTxProps { + isOpen: boolean + onClose: () => void + validator: Validator + unbondingDelegationEntry?: UnbondingDelegationEntry +} + +export default function ReviewCancelUnstakeTx({ + isOpen, + onClose, + validator, + unbondingDelegationEntry, +}: ReviewCancelUnstakeTxProps) { + const getWallet = useGetWallet() + const defaultGasPrice = useDefaultGasPrice() + + const [formatCurrency] = useFormatCurrency() + const [activeStakingDenom] = useActiveStakingDenom() + const defaultTokenLogo = useDefaultTokenLogo() + const { theme } = useTheme() + + const { + showLedgerPopup, + onReviewTransaction, + isLoading, + error, + setAmount, + recommendedGasLimit, + userPreferredGasLimit, + setUserPreferredGasLimit, + gasOption, + setGasOption, + userPreferredGasPrice, + setFeeDenom, + setCreationHeight, + ledgerError, + setLedgerError, + customFee, + feeDenom, + } = useStakeTx('CANCEL_UNDELEGATION', validator as Validator) + const [showFeesSettingSheet, setShowFeesSettingSheet] = useState(false) + const [gasError, setGasError] = useState(null) + const [gasPriceOption, setGasPriceOption] = useState({ + option: gasOption, + gasPrice: userPreferredGasPrice ?? defaultGasPrice.gasPrice, + }) + const navigate = useNavigate() + const { data: imageUrl } = useValidatorImage(validator) + + useCaptureTxError(error) + + useEffect(() => { + setCreationHeight((unbondingDelegationEntry as UnbondingDelegationEntry).creation_height) + setAmount((unbondingDelegationEntry as UnbondingDelegationEntry).balance) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [unbondingDelegationEntry]) + + const onGasPriceOptionChange = useCallback( + (value: GasPriceOptionValue, feeBaseDenom: FeeTokenData) => { + setGasPriceOption(value) + setFeeDenom(feeBaseDenom.denom) + if (value.option) { + setGasOption(value.option) + } + }, + [setFeeDenom, setGasOption], + ) + + const handleCloseFeeSettingSheet = useCallback(() => { + setShowFeesSettingSheet(false) + }, []) + + const txCallback = useCallback(() => { + navigate('/stake-pending-txn', { + state: { + validator: validator, + mode: 'CANCEL_UNDELEGATION', + } as StakeTxnPageState, + }) + mixpanel.track(EventName.TransactionSigned, { + transactionType: 'stake_cancel_undelegate', + }) + }, [navigate, validator]) + + const onSubmit = useCallback(async () => { + try { + const wallet = await getWallet() + onReviewTransaction(wallet, txCallback, false, { + stdFee: customFee, + feeDenom: feeDenom, + }) + } catch (error) { + const _error = error as Error + setLedgerError(_error.message) + + setTimeout(() => { + setLedgerError('') + }, 6000) + } + }, [customFee, feeDenom, getWallet, onReviewTransaction, setLedgerError, txCallback]) + + return ( + setUserPreferredGasLimit(Number(value.toString()))} + gasPriceOption={gasPriceOption} + onGasPriceOptionChange={onGasPriceOptionChange} + error={gasError} + setError={setGasError} + > + +
+
+
+ + info + + + Confirm Unstaking + +
+ + This will reset the unstaking period and stake the tokens back to the validator + +
+
+ +
+ + {formatCurrency(new BigNumber(unbondingDelegationEntry?.currencyBalance ?? ''))} + + + {unbondingDelegationEntry?.formattedBalance} + +
+
+ + {timeLeft(unbondingDelegationEntry?.completion_time ?? '')} + + + {unbondingDelegationEntry?.completion_time && + daysLeft(unbondingDelegationEntry?.completion_time)} + +
+
+ +
+ +
+ + {sliceWord(validator?.moniker, 10, 3)} + + + Validator + +
+
+ + + + {ledgerError &&

{ledgerError}

} + {error &&

{error}

} + {gasError && !showFeesSettingSheet && ( +

{gasError}

+ )} +
+ + Cancel + + + {isLoading ? : 'Confirm'} + +
+
+
+ {showLedgerPopup && } + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/ReviewClaimAndStakeTx.tsx b/apps/extension/src/pages/stake-v2/components/ReviewClaimAndStakeTx.tsx new file mode 100644 index 00000000..76d54d33 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/ReviewClaimAndStakeTx.tsx @@ -0,0 +1,254 @@ +import { formatTokenAmount, sliceWord, useValidatorImage } from '@leapwallet/cosmos-wallet-hooks' +import { FeeTokenData, useActiveStakingDenom, useStaking } from '@leapwallet/cosmos-wallet-hooks' +import { GasPrice, Validator } from '@leapwallet/cosmos-wallet-sdk' +import { Buttons, Card } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import { LoaderAnimation } from 'components/loader/Loader' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { useHideAssets } from 'hooks/settings/useHideAssets' +import { useDefaultTokenLogo } from 'hooks/utility/useDefaultTokenLogo' +import { Wallet } from 'hooks/wallet/useWallet' +import { Images } from 'images' +import React, { useCallback, useMemo, useState } from 'react' +import { useNavigate } from 'react-router' +import { Colors } from 'theme/colors' +import { imgOnError } from 'utils/imgOnError' +import useGetWallet = Wallet.useGetWallet +import { ChainRewards } from '@leapwallet/cosmos-wallet-hooks' +import { useClaimAndStakeRewards } from '@leapwallet/cosmos-wallet-hooks' +import GasPriceOptions, { useDefaultGasPrice } from 'components/gas-price-options' +import { GasPriceOptionValue } from 'components/gas-price-options/context' +import { DisplayFee } from 'components/gas-price-options/display-fee' +import { FeesSettingsSheet } from 'components/gas-price-options/fees-settings-sheet' +import Text from 'components/text' +import { EventName } from 'config/analytics' +import { useCaptureTxError } from 'hooks/utility/useCaptureTxError' +import mixpanel from 'mixpanel-browser' + +import { StakeTxnPageState } from '../StakeTxnPage' + +interface ReviewClaimAndStakeTxProps { + isOpen: boolean + onClose: () => void + validator?: Validator + validators?: Record + chainRewards: ChainRewards +} + +export default function ReviewClaimAndStakeTx({ + isOpen, + onClose, + validators, + chainRewards, +}: ReviewClaimAndStakeTxProps) { + const getWallet = useGetWallet() + const [formatCurrency] = useFormatCurrency() + const { formatHideBalance } = useHideAssets() + const defaultTokenLogo = useDefaultTokenLogo() + const [activeStakingDenom] = useActiveStakingDenom() + const { delegations, totalRewardsDollarAmt, rewards } = useStaking() + const [error, setError] = useState('') + const defaultGasPrice = useDefaultGasPrice() + const { + claimAndStakeRewards, + loading, + recommendedGasLimit, + userPreferredGasLimit, + setUserPreferredGasLimit, + gasOption, + setGasOption, + userPreferredGasPrice, + setFeeDenom, + } = useClaimAndStakeRewards(delegations, chainRewards, setError) + const [gasError, setGasError] = useState(null) + const [showFeesSettingSheet, setShowFeesSettingSheet] = useState(false) + const [gasPriceOption, setGasPriceOption] = useState({ + option: gasOption, + gasPrice: (userPreferredGasPrice ?? defaultGasPrice.gasPrice) as GasPrice, + }) + const navigate = useNavigate() + + const nativeTokenReward = useMemo(() => { + if (rewards) { + return rewards.total?.find((token) => token.denom === activeStakingDenom.coinMinimalDenom) + } + }, [activeStakingDenom.coinMinimalDenom, rewards]) + + const rewardValidators = useMemo(() => { + if (rewards && validators) { + return rewards.rewards + .filter((reward) => + reward.reward.some((r) => r.denom === activeStakingDenom.coinMinimalDenom), + ) + .map((reward) => validators[reward.validator_address]) + } + }, [activeStakingDenom.coinMinimalDenom, rewards, validators]) + const { data: imageUrl } = useValidatorImage(rewardValidators && rewardValidators[0]) + + useCaptureTxError(error) + + const txCallback = useCallback(() => { + navigate('/stake-pending-txn', { + state: { + validator: rewardValidators && rewardValidators[0], + mode: 'CLAIM_AND_DELEGATE', + } as StakeTxnPageState, + }) + mixpanel.track(EventName.TransactionSigned, { + transactionType: 'stake_claim_and_delegate', + }) + }, [navigate, rewardValidators]) + + const onClaimRewardsClick = async () => { + const wallet = await getWallet() + await claimAndStakeRewards(wallet, { + success: txCallback, + }) + } + + const onGasPriceOptionChange = useCallback( + (value: GasPriceOptionValue, feeBaseDenom: FeeTokenData) => { + setGasPriceOption(value) + setFeeDenom(feeBaseDenom.denom) + if (value.option) { + setGasOption(value.option) + } + }, + [setFeeDenom, setGasOption], + ) + + const handleCloseFeeSettingSheet = useCallback(() => { + setShowFeesSettingSheet(false) + }, []) + + const formattedTokenAmount = useMemo(() => { + return formatHideBalance( + formatTokenAmount(nativeTokenReward?.amount ?? '', activeStakingDenom.coinDenom), + ) + }, [activeStakingDenom.coinDenom, formatHideBalance, nativeTokenReward?.amount]) + + const titleText = useMemo(() => { + if (new BigNumber(totalRewardsDollarAmt).gt(0)) { + return formatHideBalance(formatCurrency(new BigNumber(totalRewardsDollarAmt))) + } else { + return formattedTokenAmount + } + }, [formatCurrency, formatHideBalance, formattedTokenAmount, totalRewardsDollarAmt]) + + const subTitleText = useMemo(() => { + if (new BigNumber(totalRewardsDollarAmt).gt(0)) { + return formattedTokenAmount + } + return '' + }, [formattedTokenAmount, totalRewardsDollarAmt]) + + return ( + setUserPreferredGasLimit(Number(value.toString()))} + gasPriceOption={gasPriceOption} + onGasPriceOptionChange={onGasPriceOptionChange} + error={gasError} + setError={setGasError} + > + +
+
+ + } + isRounded + size='md' + title={ + + {titleText} + + } + subtitle={ + + {subTitleText} + + } + /> + + + } + isRounded + size='md' + title={ + + {rewardValidators && sliceWord(rewardValidators[0]?.moniker, 10, 3)} + + } + subtitle={ + + {rewardValidators && + (rewardValidators.length > 1 + ? `+${rewardValidators.length - 1} more validators` + : '')} + + } + /> +
+
+ + {error &&

{error}

} + {gasError &&

{gasError}

} + + + {loading ? : 'Confirm Claim & Stake'} + +
+
+
+ +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/ReviewClaimTx.tsx b/apps/extension/src/pages/stake-v2/components/ReviewClaimTx.tsx new file mode 100644 index 00000000..29af7a06 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/ReviewClaimTx.tsx @@ -0,0 +1,291 @@ +import { formatTokenAmount, sliceWord, useValidatorImage } from '@leapwallet/cosmos-wallet-hooks' +import { + FeeTokenData, + useActiveStakingDenom, + useStakeTx, + useStaking, +} from '@leapwallet/cosmos-wallet-hooks' +import { Validator } from '@leapwallet/cosmos-wallet-sdk' +import { Buttons, Card } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import { LoaderAnimation } from 'components/loader/Loader' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { useHideAssets } from 'hooks/settings/useHideAssets' +import { useDefaultTokenLogo } from 'hooks/utility/useDefaultTokenLogo' +import { Wallet } from 'hooks/wallet/useWallet' +import { Images } from 'images' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router' +import { Colors } from 'theme/colors' +import { imgOnError } from 'utils/imgOnError' +import useGetWallet = Wallet.useGetWallet +import GasPriceOptions, { useDefaultGasPrice } from 'components/gas-price-options' +import { GasPriceOptionValue } from 'components/gas-price-options/context' +import { DisplayFee } from 'components/gas-price-options/display-fee' +import { FeesSettingsSheet } from 'components/gas-price-options/fees-settings-sheet' +import LedgerConfirmationPopup from 'components/ledger-confirmation/LedgerConfirmationPopup' +import Text from 'components/text' +import { EventName } from 'config/analytics' +import { useCaptureTxError } from 'hooks/utility/useCaptureTxError' +import mixpanel from 'mixpanel-browser' + +import { StakeTxnPageState } from '../StakeTxnPage' + +interface ReviewClaimTxProps { + isOpen: boolean + onClose: () => void + validator?: Validator + validators?: Record +} +export default function ReviewClaimTx({ + isOpen, + onClose, + validator, + validators, +}: ReviewClaimTxProps) { + const getWallet = useGetWallet() + const defaultGasPrice = useDefaultGasPrice() + + const [formatCurrency] = useFormatCurrency() + const { formatHideBalance } = useHideAssets() + const defaultTokenLogo = useDefaultTokenLogo() + const [activeStakingDenom] = useActiveStakingDenom() + + const { delegations, totalRewardsDollarAmt, rewards, totalRewards } = useStaking() + const { + showLedgerPopup, + onReviewTransaction, + isLoading, + error, + setAmount, + recommendedGasLimit, + userPreferredGasLimit, + setUserPreferredGasLimit, + gasOption, + setGasOption, + userPreferredGasPrice, + setFeeDenom, + ledgerError, + setLedgerError, + customFee, + feeDenom, + } = useStakeTx( + 'CLAIM_REWARDS', + validator as Validator, + undefined, + Object.values(delegations ?? {}), + ) + + const [showFeesSettingSheet, setShowFeesSettingSheet] = useState(false) + const [gasError, setGasError] = useState(null) + const [gasPriceOption, setGasPriceOption] = useState({ + option: gasOption, + gasPrice: userPreferredGasPrice ?? defaultGasPrice.gasPrice, + }) + const navigate = useNavigate() + + const rewardValidators = useMemo(() => { + if (rewards && validators) { + return rewards.rewards.map((reward) => validators[reward.validator_address]) + } + }, [rewards, validators]) + const { data: imageUrl } = useValidatorImage(rewardValidators && rewardValidators[0]) + + const nativeTokenReward = useMemo(() => { + if (rewards) { + return rewards?.total?.find((token) => token.denom === activeStakingDenom.coinMinimalDenom) + } + }, [activeStakingDenom.coinMinimalDenom, rewards]) + + useCaptureTxError(error) + useEffect(() => { + setAmount(nativeTokenReward?.amount ?? '0') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [totalRewards]) + + const onGasPriceOptionChange = useCallback( + (value: GasPriceOptionValue, feeBaseDenom: FeeTokenData) => { + setGasPriceOption(value) + setFeeDenom(feeBaseDenom.denom) + if (value.option) { + setGasOption(value.option) + } + }, + [setFeeDenom, setGasOption], + ) + + const handleCloseFeeSettingSheet = useCallback(() => { + setShowFeesSettingSheet(false) + }, []) + + const formattedTokenAmount = useMemo(() => { + return formatHideBalance( + `${formatTokenAmount(nativeTokenReward?.amount ?? '', activeStakingDenom.coinDenom)} ${ + rewards?.total?.length > 1 ? `+${rewards.total.length - 1} more` : '' + }`, + ) + }, [ + activeStakingDenom.coinDenom, + formatHideBalance, + nativeTokenReward?.amount, + rewards?.total.length, + ]) + + const titleText = useMemo(() => { + if (new BigNumber(totalRewardsDollarAmt).gt(0)) { + return formatHideBalance(formatCurrency(new BigNumber(totalRewardsDollarAmt))) + } else { + return formattedTokenAmount + } + }, [formatCurrency, formatHideBalance, formattedTokenAmount, totalRewardsDollarAmt]) + + const subTitleText = useMemo(() => { + if (new BigNumber(totalRewardsDollarAmt).gt(0)) { + return formattedTokenAmount + } + return '' + }, [formattedTokenAmount, totalRewardsDollarAmt]) + + const txCallback = useCallback(() => { + navigate('/stake-pending-txn', { + state: { + validator: rewardValidators && rewardValidators[0], + mode: 'CLAIM_REWARDS', + } as StakeTxnPageState, + }) + mixpanel.track(EventName.TransactionSigned, { + transactionType: 'stake_claim', + }) + }, [navigate, rewardValidators]) + + const onClaimRewardsClick = useCallback(async () => { + try { + const wallet = await getWallet() + onReviewTransaction(wallet, txCallback, false, { + stdFee: customFee, + feeDenom: feeDenom, + }) + } catch (error) { + const _error = error as Error + setLedgerError(_error.message) + + setTimeout(() => { + setLedgerError('') + }, 6000) + } + }, [customFee, feeDenom, getWallet, onReviewTransaction, setLedgerError, txCallback]) + + return ( + setUserPreferredGasLimit(Number(value.toString()))} + gasPriceOption={gasPriceOption} + onGasPriceOptionChange={onGasPriceOptionChange} + error={gasError} + setError={setGasError} + > + +
+
+ + } + isRounded + size='md' + title={ + + {titleText} + + } + subtitle={ + + {subTitleText} + + } + /> + + } + isRounded + size='md' + title={ + + {rewardValidators && sliceWord(rewardValidators[0]?.moniker, 10, 3)} + + } + subtitle={ + + {rewardValidators && + (rewardValidators.length > 1 + ? `+${rewardValidators.length - 1} more validators` + : '')} + + } + /> +
+
+ + + {ledgerError &&

{ledgerError}

} + {error &&

{error}

} + {gasError && !showFeesSettingSheet && ( +

{gasError}

+ )} + + + {isLoading ? : 'Confirm Claim'} + +
+
+
+ {showLedgerPopup && } + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/ReviewStakeTx.tsx b/apps/extension/src/pages/stake-v2/components/ReviewStakeTx.tsx new file mode 100644 index 00000000..9ec3f564 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/ReviewStakeTx.tsx @@ -0,0 +1,165 @@ +import { + formatTokenAmount, + sliceWord, + STAKE_MODE, + Token, + useformatCurrency, + useValidatorImage, +} from '@leapwallet/cosmos-wallet-hooks' +import { Validator } from '@leapwallet/cosmos-wallet-sdk' +import { Buttons, Card } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import LedgerConfirmationPopup from 'components/ledger-confirmation/LedgerConfirmationPopup' +import { LoaderAnimation } from 'components/loader/Loader' +import Text from 'components/text' +import { Images } from 'images' +import { GenericLight } from 'images/logos' +import React from 'react' +import { Colors } from 'theme/colors' +import { imgOnError } from 'utils/imgOnError' + +type ReviewStakeTxProps = { + isVisible: boolean + isLoading: boolean + onClose: () => void + onSubmit: () => void + tokenAmount: string + token?: Token + error: string | undefined + gasError: string | null + validator?: Validator + mode: STAKE_MODE + unstakingPeriod: string + showLedgerPopup: boolean +} + +export const buttonTitle = { + DELEGATE: 'Stake', + UNDELEGATE: 'Unstake', + REDELEGATE: 'Switching Validator', + CLAIM_REWARDS: 'Claiming', + CANCEL_UNDELEGATION: 'Cancel Unstake', +} + +export default function ReviewStakeTx({ + isVisible, + onClose, + onSubmit, + tokenAmount, + token, + validator, + isLoading, + error, + mode, + unstakingPeriod, + gasError, + showLedgerPopup, +}: ReviewStakeTxProps) { + const [formatCurrency] = useformatCurrency() + const { data: imageUrl } = useValidatorImage(validator) + + return ( + <> + +
+
+ {mode === 'REDELEGATE' && ( +
+ + info + + + Redelegating to a new validator takes {unstakingPeriod} as funds unbond from the + source validator, then moved to the new one. + +
+ )} +
+ + } + isRounded + size='md' + title={ + + {`${formatTokenAmount(tokenAmount)} ${token?.symbol}`} + + } + subtitle={ + + {`${formatCurrency(new BigNumber(tokenAmount)?.times(token?.usdPrice ?? 1))}`} + + } + /> + + } + isRounded + size='md' + title={ + + {sliceWord(validator?.moniker, 10, 2)} + + } + subtitle={ + + Validator + + } + /> +
+
+
+ {error &&

{error}

} + {gasError &&

{gasError}

} + + {isLoading ? ( + + ) : ( + `Confirm ${buttonTitle[mode]}` + )} + +
+
+
+ {showLedgerPopup && } + + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/SelectLSProvider.tsx b/apps/extension/src/pages/stake-v2/components/SelectLSProvider.tsx new file mode 100644 index 00000000..938368a4 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/SelectLSProvider.tsx @@ -0,0 +1,107 @@ +import { LSProvider, useActiveStakingDenom } from '@leapwallet/cosmos-wallet-hooks' +import { GenericCard } from '@leapwallet/leap-ui' +import BottomModal from 'components/bottom-modal' +import { ValidatorItemSkeleton } from 'components/Skeletons/StakeSkeleton' +import Text from 'components/text' +import { EventName } from 'config/analytics' +import currency from 'currency.js' +import { GenericLight } from 'images/logos' +import mixpanel from 'mixpanel-browser' +import React from 'react' +import { imgOnError } from 'utils/imgOnError' + +interface SelectLSProviderProps { + isVisible: boolean + onClose: () => void + providers: LSProvider[] +} + +interface ProviderCardProps { + provider: LSProvider + backgroundColor: string +} + +export function ProviderCard({ provider, backgroundColor }: ProviderCardProps) { + const [activeStakingDenom] = useActiveStakingDenom() + return ( + { + window.open(provider.url, '_blank') + mixpanel.track(EventName.ButtonClick, { + buttonType: 'stake', + buttonName: 'liquid staking redirection', + redirectURL: provider.url, + stakeToken: activeStakingDenom.coinDenom, + }) + }} + className={`${backgroundColor} w-full`} + img={ + + } + isRounded + size='md' + title={ + + {provider.name} + + } + subtitle={ + + {provider.apy + ? `APY ${currency((provider.apy * 100).toString(), { + precision: 2, + symbol: '', + }).format()}%` + : 'N/A'} + + } + icon={ + + open_in_new + + } + /> + ) +} + +export default function SelectLSProvider({ isVisible, onClose, providers }: SelectLSProviderProps) { + return ( + +
+ {providers.length === 0 && } + {providers.length > 0 && + providers.map((provider) => ( +
+ {provider.priority && ( +
+ Promoted +
+ )} + +
+ ))} +
+
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/SelectSortBySheet.tsx b/apps/extension/src/pages/stake-v2/components/SelectSortBySheet.tsx new file mode 100644 index 00000000..8ce8f27d --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/SelectSortBySheet.tsx @@ -0,0 +1,55 @@ +import { SupportedChain } from '@leapwallet/cosmos-wallet-sdk' +import { GenericCard } from '@leapwallet/leap-ui' +import BottomModal from 'components/bottom-modal' +import { Images } from 'images' +import React from 'react' + +import { STAKE_SORT_BY } from './SelectValidatorSheet' + +type SelectSortByProps = { + sortBy: STAKE_SORT_BY + setSortBy: (s: STAKE_SORT_BY) => void + isVisible: boolean + setVisible: (v: boolean) => void + onClose: () => void + activeChain: SupportedChain +} + +export default function SelectSortBySheet({ + sortBy, + setSortBy, + isVisible, + setVisible, + onClose, +}: SelectSortByProps) { + return ( + +
+ {(['Random', 'Amount staked', 'APY'] as STAKE_SORT_BY[]).map((element) => ( + { + setVisible(false) + setSortBy(element) + }} + icon={ + sortBy === (element as STAKE_SORT_BY) ? ( + + ) : undefined + } + size='md' + title={element} + isRounded={true} + className='p-4 !h-auto bg-white-100 dark:bg-gray-950' + /> + ))} +
+
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/SelectValidatorCard.tsx b/apps/extension/src/pages/stake-v2/components/SelectValidatorCard.tsx new file mode 100644 index 00000000..af79c39f --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/SelectValidatorCard.tsx @@ -0,0 +1,81 @@ +import { sliceWord, useValidatorImage } from '@leapwallet/cosmos-wallet-hooks' +import { Validator } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/validators' +import { ThemeName, useTheme } from '@leapwallet/leap-ui' +import Text from 'components/text' +import { Images } from 'images' +import { GenericDark, GenericLight } from 'images/logos' +import React from 'react' +import { imgOnError } from 'utils/imgOnError' + +export type SelectValidatorCardProps = { + selectedValidator?: Validator + setShowSelectValidatorSheet: (val: boolean) => void + selectDisabled: boolean + title: string +} + +export default function SelectValidatorCard({ + selectedValidator, + setShowSelectValidatorSheet, + selectDisabled, + title, +}: SelectValidatorCardProps) { + const { data: imageUrl } = useValidatorImage(selectedValidator) + const theme = useTheme().theme + return ( +
+ + {title} + + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/SelectValidatorSheet.tsx b/apps/extension/src/pages/stake-v2/components/SelectValidatorSheet.tsx new file mode 100644 index 00000000..29b2c38b --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/SelectValidatorSheet.tsx @@ -0,0 +1,217 @@ +import { + sliceWord, + useActiveStakingDenom, + useValidatorImage, +} from '@leapwallet/cosmos-wallet-hooks' +import { Validator } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/validators' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import { EmptyCard } from 'components/empty-card' +import { SearchInput } from 'components/search-input' +import ValidatorListSkeleton from 'components/Skeletons/ValidatorListSkeleton' +import Text from 'components/text' +import currency from 'currency.js' +import { useActiveChain } from 'hooks/settings/useActiveChain' +import { Images } from 'images' +import { GenericLight } from 'images/logos' +import React, { useMemo, useState } from 'react' +import { imgOnError } from 'utils/imgOnError' + +import SelectSortBySheet from './SelectSortBySheet' + +type SelectValidatorSheetProps = { + isVisible: boolean + onClose: () => void + onValidatorSelect: (validator: Validator) => void + validators: Validator[] + apy?: Record +} + +export type STAKE_SORT_BY = 'Amount staked' | 'APY' | 'Random' + +type ValidatorCardProps = { + validator: Validator + onClick: () => void +} + +export function ValidatorCard({ validator, onClick }: ValidatorCardProps) { + const [activeStakingDenom] = useActiveStakingDenom() + const { data: imageUrl } = useValidatorImage(validator) + + return ( +
+ {validator.custom_attributes?.priority !== undefined && + validator.custom_attributes.priority >= 1 && ( +
+ Promoted +
+ )} +
+ +
+ + {sliceWord(validator.moniker, 30, 0)} + +
+ + {`${currency( + (validator.delegations?.total_tokens_display ?? validator.tokens) as string, + { + symbol: '', + precision: 0, + }, + ).format()} ${activeStakingDenom.coinDenom}`} + + + Commission:{' '} + {validator.commission?.commission_rates.rate + ? `${new BigNumber(validator.commission.commission_rates.rate) + .multipliedBy(100) + .toFixed(0)}%` + : 'N/A'} + +
+
+
+
+ ) +} + +export default function SelectValidatorSheet({ + isVisible, + onClose, + onValidatorSelect, + validators, + apy, +}: SelectValidatorSheetProps) { + const [searchedTerm, setSearchedTerm] = useState('') + const [showSortBy, setShowSortBy] = useState(false) + const [sortBy, setSortBy] = useState('Random') + const activeChain = useActiveChain() + const [isLoading, setIsLoading] = useState(false) + const [activeValidators, inactiveValidators] = useMemo(() => { + setIsLoading(true) + const filteredValidators = validators.filter( + (validator) => + validator.moniker.toLowerCase().includes(searchedTerm.toLowerCase()) || + validator.address.includes(searchedTerm), + ) + filteredValidators.sort((a, b) => { + switch (sortBy) { + case 'Amount staked': + return +(a.tokens ?? '') < +(b.tokens ?? '') ? 1 : -1 + case 'APY': + return apy ? (apy[a.address] < apy[b.address] ? 1 : -1) : 0 + case 'Random': + return 0 + } + }) + if (sortBy === 'Random') { + filteredValidators.sort((a, b) => { + const priorityA = a.custom_attributes?.priority + const priorityB = b.custom_attributes?.priority + + if (priorityA !== undefined && priorityB !== undefined) { + return priorityA - priorityB + } else if (priorityA !== undefined) { + return -1 + } else if (priorityB !== undefined) { + return 1 + } else { + return 0 + } + }) + } + const _activeValidators = filteredValidators.filter((validator) => validator.active !== false) + const _inactiveValidators = filteredValidators.filter((validator) => validator.active === false) + setIsLoading(false) + return [_activeValidators, searchedTerm ? _inactiveValidators : []] + }, [validators, searchedTerm, sortBy, apy]) + + return ( + { + setSearchedTerm('') + onClose() + }} + closeOnBackdropClick={true} + title='Select Validator' + className='p-6' + > +
+
+ setSearchedTerm(e.target.value)} + data-testing-id='validator-input-search' + placeholder='Search validators' + onClear={() => setSearchedTerm('')} + /> + setShowSortBy(true)} + className='material-icons-round text-black-100 dark:text-white-100 rounded-3xl cursor-pointer dark:bg-gray-950 bg-white-100 p-2' + > + sort + +
+ + {isLoading && } + {!isLoading && activeValidators?.length === 0 && inactiveValidators.length === 0 && ( + + )} + {!isLoading && activeValidators.length !== 0 && ( +
+ {activeValidators.map((validator) => ( + onValidatorSelect(validator)} + /> + ))} +
+ )} + {!isLoading && searchedTerm && inactiveValidators.length !== 0 && ( +
+ + Inactive validator + + {inactiveValidators.map((validator) => ( + onValidatorSelect(validator)} + /> + ))} +
+ )} +
+ setShowSortBy(false)} + isVisible={showSortBy} + setVisible={setShowSortBy} + setSortBy={setSortBy} + sortBy={sortBy} + activeChain={activeChain} + /> +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/StakeAmountCard.tsx b/apps/extension/src/pages/stake-v2/components/StakeAmountCard.tsx new file mode 100644 index 00000000..1b4e32ce --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/StakeAmountCard.tsx @@ -0,0 +1,47 @@ +import { useStaking } from '@leapwallet/cosmos-wallet-hooks' +import BigNumber from 'bignumber.js' +import Text from 'components/text' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { useHideAssets } from 'hooks/settings/useHideAssets' +import React, { useMemo } from 'react' +import Skeleton from 'react-loading-skeleton' + +import StakeRewardCard from './StakeRewardCard' + +interface StakeAmountCardProps { + onClaim: () => void + onClaimAndStake?: () => void +} + +export default function StakeAmountCard({ onClaim, onClaimAndStake }: StakeAmountCardProps) { + const { formatHideBalance } = useHideAssets() + const { loadingDelegations, currencyAmountDelegation, totalDelegationAmount } = useStaking() + const [formatCurrency] = useFormatCurrency() + + const formattedCurrencyAmountDelegation = useMemo(() => { + if (new BigNumber(currencyAmountDelegation).gt(0)) { + return formatCurrency(new BigNumber(currencyAmountDelegation)) + } + }, [currencyAmountDelegation, formatCurrency]) + + return ( +
+ + Your deposited amount + + {loadingDelegations && } + {!loadingDelegations && ( +
+ + {formattedCurrencyAmountDelegation && + formatHideBalance(formattedCurrencyAmountDelegation)} + + + {formatHideBalance(totalDelegationAmount)} + +
+ )} + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/StakeHeading.tsx b/apps/extension/src/pages/stake-v2/components/StakeHeading.tsx new file mode 100644 index 00000000..8ce70bed --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/StakeHeading.tsx @@ -0,0 +1,38 @@ +import { useStaking } from '@leapwallet/cosmos-wallet-hooks' +import Text from 'components/text' +import currency from 'currency.js' +import { useActiveChain } from 'hooks/settings/useActiveChain' +import { useChainInfos } from 'hooks/useChainInfos' +import { useDefaultTokenLogo } from 'hooks/utility/useDefaultTokenLogo' +import React, { useMemo } from 'react' + +export default function StakeHeading() { + const activeChain = useActiveChain() + const chainInfos = useChainInfos() + const defaultTokenLogo = useDefaultTokenLogo() + const activeChainInfo = chainInfos[activeChain] + const { network } = useStaking() + + const apyValue = useMemo(() => { + if (network?.chainApy) { + return currency((network?.chainApy * 100).toString(), { + precision: 2, + symbol: '', + }).format() + } + }, [network?.chainApy]) + + return ( +
+
+ + + {activeChainInfo.chainName} + +
+ + {apyValue ? `APY ${apyValue}%` : '-'} + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/StakeRewardCard.tsx b/apps/extension/src/pages/stake-v2/components/StakeRewardCard.tsx new file mode 100644 index 00000000..51cf4559 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/StakeRewardCard.tsx @@ -0,0 +1,91 @@ +import { + formatTokenAmount, + useActiveStakingDenom, + useStaking, +} from '@leapwallet/cosmos-wallet-hooks' +import BigNumber from 'bignumber.js' +import Text from 'components/text' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { useHideAssets } from 'hooks/settings/useHideAssets' +import React, { useMemo } from 'react' +import Skeleton from 'react-loading-skeleton' + +interface StakeRewardCardProps { + onClaim?: () => void + onClaimAndStake?: () => void +} + +export default function StakeRewardCard({ onClaim, onClaimAndStake }: StakeRewardCardProps) { + const [formatCurrency] = useFormatCurrency() + const { formatHideBalance } = useHideAssets() + const [activeStakingDenom] = useActiveStakingDenom() + const { totalRewards, totalRewardsDollarAmt, loadingRewards, rewards } = useStaking() + const isClaimDisabled = useMemo( + () => !totalRewards || new BigNumber(totalRewards).lt(0.00001), + [totalRewards], + ) + + const nativeTokenReward = useMemo(() => { + if (rewards) { + return rewards.total?.find((token) => token.denom === activeStakingDenom.coinMinimalDenom) + } + }, [activeStakingDenom.coinMinimalDenom, rewards]) + + const formattedRewardAmount = useMemo(() => { + if (new BigNumber(totalRewardsDollarAmt).gt(0)) { + return formatHideBalance(formatCurrency(new BigNumber(totalRewardsDollarAmt))) + } else { + return formatHideBalance( + `${formatTokenAmount(nativeTokenReward?.amount ?? '', activeStakingDenom.coinDenom)} ${ + rewards?.total?.length > 1 ? `+${rewards.total.length - 1} more` : '' + }`, + ) + } + }, [ + activeStakingDenom.coinDenom, + formatCurrency, + formatHideBalance, + nativeTokenReward?.amount, + rewards.total.length, + totalRewardsDollarAmt, + ]) + + return ( +
+
+ + You have earned + + {loadingRewards && } + {!loadingRewards && ( + + {formattedRewardAmount} + + )} +
+ {loadingRewards && } + {!loadingRewards && ( + + )} +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/StakeSelectSheet.tsx b/apps/extension/src/pages/stake-v2/components/StakeSelectSheet.tsx new file mode 100644 index 00000000..4e85ccf0 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/StakeSelectSheet.tsx @@ -0,0 +1,146 @@ +import { + useActiveStakingDenom, + useChainInfo, + useLiquidStakingProviders, + useStaking, +} from '@leapwallet/cosmos-wallet-hooks' +import { GenericCard } from '@leapwallet/leap-ui' +import BottomModal from 'components/bottom-modal' +import Text from 'components/text' +import { EventName, PageName } from 'config/analytics' +import currency from 'currency.js' +import mixpanel from 'mixpanel-browser' +import React, { useMemo, useState } from 'react' +import { useNavigate } from 'react-router' + +import { StakeInputPageState } from '../StakeInputPage' +import { ProviderCard } from './SelectLSProvider' + +type StakeSelectSheetProps = { + isVisible: boolean + title: string + onClose: () => void +} + +export default function StakeSelectSheet({ isVisible, title, onClose }: StakeSelectSheetProps) { + const [showLSProviders, setShowLSProviders] = useState(false) + const [activeStakingDenom] = useActiveStakingDenom() + const chain = useChainInfo() + const { isLoading: isLSProvidersLoading, data: lsProviders } = useLiquidStakingProviders() + const tokenLSProviders = useMemo(() => { + return lsProviders[activeStakingDenom.coinDenom] + }, [activeStakingDenom.coinDenom, lsProviders]) + const { minMaxApy } = useStaking() + const avgApyValue = useMemo(() => { + if (minMaxApy) { + const avgApy = (minMaxApy[0] + minMaxApy[1]) / 2 + return currency((avgApy * 100).toString(), { precision: 2, symbol: '' }).format() + } + return null + }, [minMaxApy]) + const maxLSAPY = useMemo(() => { + if (tokenLSProviders?.length > 0) { + const _maxAPY = Math.max(...tokenLSProviders.map((provider) => provider.apy)) + return `APY ${currency((_maxAPY * 100).toString(), { precision: 2, symbol: '' }).format()}%` + } else { + return 'N/A' + } + }, [tokenLSProviders]) + const navigate = useNavigate() + + const handleStakeClick = () => { + navigate('/stakeInput', { + state: { + mode: 'DELEGATE', + } as StakeInputPageState, + }) + mixpanel.track(EventName.PageView, { + pageName: PageName.Stake, + pageViewSource: PageName.AssetDetails, + chainName: chain.chainName, + chainId: chain.chainId, + time: Date.now() / 1000, + }) + } + return ( + { + setShowLSProviders(false) + onClose() + }} + closeOnBackdropClick={true} + title={title} + className='p-6' + > +
+ + Stake + + } + subtitle={ + + APY {avgApyValue}% + + } + size='md' + isRounded + title2={ + + } + /> + {tokenLSProviders?.length > 0 && ( +
+
+
+ + Liquid Stake + + + {maxLSAPY} + +
+ setShowLSProviders(!showLSProviders)} + className='material-icons-round rounded-full text-xs font-bold bg-gray-50 dark:bg-gray-900 py-1 px-3 cursor-pointer flex items-center text-black-100 dark:text-white-100' + > + {showLSProviders ? 'expand_less' : 'expand_more'} + +
+ {showLSProviders && ( + <> +
+ {tokenLSProviders && + tokenLSProviders.map((provider) => ( +
+ {provider.priority && ( +
+ Promoted +
+ )} + +
+ ))} +
+ + )} +
+ )} +
+
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/StakeStatusCard.tsx b/apps/extension/src/pages/stake-v2/components/StakeStatusCard.tsx new file mode 100644 index 00000000..dd9ffe3a --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/StakeStatusCard.tsx @@ -0,0 +1,54 @@ +import { Buttons, ThemeName, useTheme } from '@leapwallet/leap-ui' +import Text from 'components/text' +import { Images } from 'images' +import React from 'react' +import { Colors } from 'theme/colors' + +type StakeStatusCardProps = { + title: string + message: string + backgroundColor: string + backgroundColorDark: string + color: string + onAction: () => void +} + +export default function StakeStatusCard({ + title, + message, + backgroundColor, + backgroundColorDark, + color, + onAction, +}: StakeStatusCardProps) { + const { theme } = useTheme() + return ( + <> +
+
+ info + + {title} + +
+ + {message} + +
+ +
+
+ + Stake on a different chain + + + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/StakingUnavailable.tsx b/apps/extension/src/pages/stake-v2/components/StakingUnavailable.tsx new file mode 100644 index 00000000..d3340705 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/StakingUnavailable.tsx @@ -0,0 +1,135 @@ +import { WALLETTYPE } from '@leapwallet/cosmos-wallet-hooks' +import { HeaderActionType } from '@leapwallet/leap-ui' +import BottomNav, { BottomNavLabel } from 'components/bottom-nav/BottomNav' +import { WalletButton } from 'components/button' +import { EmptyCard } from 'components/empty-card' +import { PageHeader } from 'components/header' +import PopupLayout from 'components/layout/popup-layout' +import { LEDGER_NAME_EDITED_SUFFIX_REGEX } from 'config/config' +import { walletLabels } from 'config/constants' +import { useChainPageInfo } from 'hooks' +import useActiveWallet from 'hooks/settings/useActiveWallet' +import { useDontShowSelectChain } from 'hooks/useDontShowSelectChain' +import { useGetWalletAddresses } from 'hooks/useGetWalletAddresses' +import { Images } from 'images' +import SelectChain from 'pages/home/SelectChain' +import SelectWallet from 'pages/home/SelectWallet' +import SideNav from 'pages/home/side-nav' +import React, { useCallback, useMemo, useState } from 'react' +import { UserClipboard } from 'utils/clipboard' +import { formatWalletName } from 'utils/formatWalletName' +import { isCompassWallet } from 'utils/isCompassWallet' + +import ComingSoonCard from './ComingSoonCard' +import NotSupportedCard from './NotSupportedCard' +import StakeHeading from './StakeHeading' + +export default function StakingUnavailable({ + isStakeNotSupported, + isStakeComingSoon, +}: { + isStakeNotSupported: boolean + isStakeComingSoon: boolean +}) { + const { headerChainImgSrc } = useChainPageInfo() + const dontShowSelectChain = useDontShowSelectChain() + const walletAddresses = useGetWalletAddresses() + + const { activeWallet } = useActiveWallet() + + const [showChainSelector, setShowChainSelector] = useState(false) + const [showSelectWallet, setShowSelectWallet] = useState(false) + const [showSideNav, setShowSideNav] = useState(false) + const [isWalletAddressCopied, setIsWalletAddressCopied] = useState(false) + + const handleOpenSelectChainSheet = useCallback(() => setShowChainSelector(true), []) + const handleOpenWalletSheet = useCallback(() => setShowSelectWallet(true), []) + const handleCopyClick = useCallback(() => { + setIsWalletAddressCopied(true) + setTimeout(() => setIsWalletAddressCopied(false), 2000) + + UserClipboard.copyText(walletAddresses?.[0]) + }, [walletAddresses]) + + const walletAvatar = useMemo(() => { + if (activeWallet?.avatar) { + return activeWallet.avatar + } + + if (isCompassWallet()) { + return Images.Logos.CompassCircle + } + + return Images.Logos.LeapLogo28 + }, [activeWallet?.avatar]) + + if (!activeWallet) { + return ( +
+ +
+ +
+
+
+ ) + } + + const walletName = + activeWallet.walletType === WALLETTYPE.LEDGER && + !LEDGER_NAME_EDITED_SUFFIX_REGEX.test(activeWallet.name) + ? `${walletLabels[activeWallet.walletType]} Wallet ${activeWallet.addressIndex + 1}` + : formatWalletName(activeWallet.name) + + function getCardComponent() { + if (isStakeNotSupported) { + return setShowChainSelector(true)} /> + } + if (isStakeComingSoon) { + return setShowChainSelector(true)} /> + } + } + + return ( +
+ setShowSideNav(!showSideNav)} /> + setShowSideNav(true), + type: HeaderActionType.NAVIGATION, + className: 'w-[48px] h-[40px] px-3 bg-[#FFFFFF] dark:bg-gray-950 rounded-full', + }} + imgSrc={headerChainImgSrc} + onImgClick={dontShowSelectChain ? undefined : handleOpenSelectChainSheet} + title={ + + } + /> + } + > +
+ + {getCardComponent()} +
+
+ setShowChainSelector(false)} /> + setShowSelectWallet(false)} + title='Wallets' + /> + +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/TabList.tsx b/apps/extension/src/pages/stake-v2/components/TabList.tsx new file mode 100644 index 00000000..3b6eb16f --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/TabList.tsx @@ -0,0 +1,75 @@ +import { useStaking } from '@leapwallet/cosmos-wallet-hooks' +import Text from 'components/text' +import React, { useMemo, useState } from 'react' + +import PendingUnstakeList from './PendingUnstakeList' +import ValidatorList from './ValidatorList' + +export enum TabElements { + YOUR_VALIDATORS = 'Your validators', + PENDING_UNSTAKE = 'Pending unstake', +} + +export default function TabList() { + const { + delegations, + unboundingDelegationsInfo, + loadingDelegations, + loadingUnboundingDelegations, + } = useStaking() + const [selectedTab, setSelectedTab] = useState() + const isLoading = loadingDelegations || loadingUnboundingDelegations + + const tabs: TabElements[] = useMemo(() => { + const _tabs = [] + if (Object.values(delegations ?? {}).length > 0) { + _tabs.push(TabElements.YOUR_VALIDATORS) + } + if (Object.values(unboundingDelegationsInfo ?? {}).length > 0) { + _tabs.push(TabElements.PENDING_UNSTAKE) + } + if (_tabs.length > 0) { + setSelectedTab(_tabs[0]) + } + return _tabs + }, [delegations, unboundingDelegationsInfo]) + + if (isLoading) { + return <> + } + + return ( +
+
+ {tabs.map((element) => ( +
{ + if (selectedTab !== element) { + setSelectedTab(element) + } + }} + key={element} + > + + {element} + +
+ ))} +
+ {selectedTab === TabElements.YOUR_VALIDATORS && } + {selectedTab === TabElements.PENDING_UNSTAKE && } +
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/components/UnstakedValidatorDetails.tsx b/apps/extension/src/pages/stake-v2/components/UnstakedValidatorDetails.tsx new file mode 100644 index 00000000..f1818549 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/UnstakedValidatorDetails.tsx @@ -0,0 +1,170 @@ +import { + daysLeft, + sliceWord, + useActiveStakingDenom, + useStaking, + useValidatorImage, +} from '@leapwallet/cosmos-wallet-hooks' +import { + UnbondingDelegation, + UnbondingDelegationEntry, + Validator, +} from '@leapwallet/cosmos-wallet-sdk' +import { Buttons, ThemeName, useTheme } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import Text from 'components/text' +import currency from 'currency.js' +import { useDefaultTokenLogo } from 'hooks' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { Images } from 'images' +import React, { useState } from 'react' +import { Colors } from 'theme/colors' +import { imgOnError } from 'utils/imgOnError' +import { timeLeft } from 'utils/timeLeft' + +import ReviewCancelUnstakeTx from './ReviewCancelUnstakeTx' + +interface UnstakedValidatorDetailsProps { + isOpen: boolean + onClose: () => void + validator: Validator + unbondingDelegation?: UnbondingDelegation + unbondingDelegationEntry?: UnbondingDelegationEntry +} + +export default function UnstakedValidatorDetails({ + isOpen, + onClose, + validator, + unbondingDelegationEntry, +}: UnstakedValidatorDetailsProps) { + const [activeStakingDenom] = useActiveStakingDenom() + const defaultTokenLogo = useDefaultTokenLogo() + const [formatCurrency] = useFormatCurrency() + const { theme } = useTheme() + const [showReviewCancelUnstakeTx, setShowReviewCancelUnstakeTx] = useState(false) + const { network } = useStaking() + const apys = network?.validatorApys + const { data: imageUrl } = useValidatorImage(validator) + + return ( + <> + +
+
+ + + {sliceWord(validator.moniker, 10, 3)} + +
+
+
+ + Total Staked + + + {currency(validator?.delegations?.total_tokens_display ?? 1, { + symbol: '', + precision: 0, + }).format()} + +
+
+
+ + Commission + + + {validator.commission?.commission_rates.rate + ? `${new BigNumber(validator.commission.commission_rates.rate) + .multipliedBy(100) + .toFixed(0)}%` + : 'N/A'} + +
+
+
+ + APY + + + {apys && + (apys[validator.address] + ? `${currency(apys[validator.address] * 100, { + precision: 2, + symbol: '', + }).format()}%` + : 'N/A')} + +
+
+ + Pending Unstake + +
+ +
+ + {formatCurrency(new BigNumber(unbondingDelegationEntry?.currencyBalance ?? ''))} + + + {unbondingDelegationEntry?.formattedBalance} + +
+
+ + {timeLeft(unbondingDelegationEntry?.completion_time ?? '')} + + + {unbondingDelegationEntry?.completion_time && + daysLeft(unbondingDelegationEntry?.completion_time)} + +
+
+ { + setShowReviewCancelUnstakeTx(true) + onClose() + }} + className='w-full' + size='normal' + color={theme === ThemeName.DARK ? Colors.white100 : Colors.black100} + > + Cancel Unstake + +
+ + setShowReviewCancelUnstakeTx(false)} + unbondingDelegationEntry={unbondingDelegationEntry} + validator={validator} + /> + + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/ValidatorList.tsx b/apps/extension/src/pages/stake-v2/components/ValidatorList.tsx new file mode 100644 index 00000000..e083d69e --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/ValidatorList.tsx @@ -0,0 +1,338 @@ +import { + sliceWord, + useActiveStakingDenom, + useStaking, + useValidatorImage, +} from '@leapwallet/cosmos-wallet-hooks' +import { Delegation, Validator } from '@leapwallet/cosmos-wallet-sdk' +import { Buttons, ThemeName, useTheme } from '@leapwallet/leap-ui' +import BigNumber from 'bignumber.js' +import BottomModal from 'components/bottom-modal' +import { ValidatorItemSkeleton } from 'components/Skeletons/StakeSkeleton' +import Text from 'components/text' +import currency from 'currency.js' +import { useDefaultTokenLogo } from 'hooks' +import { useFormatCurrency } from 'hooks/settings/useCurrency' +import { useHideAssets } from 'hooks/settings/useHideAssets' +import useQuery from 'hooks/useQuery' +import { Images } from 'images' +import React, { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router' +import { Colors } from 'theme/colors' +import { imgOnError } from 'utils/imgOnError' + +import { StakeInputPageState } from '../StakeInputPage' + +interface StakedValidatorDetailsProps { + isOpen: boolean + onClose: () => void + onSwitchValidator: () => void + onUnstake: () => void + validator: Validator + delegation: Delegation +} + +function StakedValidatorDetails({ + isOpen, + onClose, + onSwitchValidator, + onUnstake, + validator, + delegation, +}: StakedValidatorDetailsProps) { + const [activeStakingDenom] = useActiveStakingDenom() + const defaultTokenLogo = useDefaultTokenLogo() + const [formatCurrency] = useFormatCurrency() + const { network } = useStaking() + const apys = network?.validatorApys + const { data: imageUrl } = useValidatorImage(validator) + const { theme } = useTheme() + + return ( + +
+
+ + + {sliceWord(validator.moniker, 10, 3)} + +
+
+
+ + Total Staked + + + {currency(validator?.delegations?.total_tokens_display ?? 1, { + symbol: '', + precision: 0, + }).format()} + +
+
+
+ + Commission + + + {validator?.commission?.commission_rates?.rate + ? `${new BigNumber(validator.commission.commission_rates.rate) + .multipliedBy(100) + .toFixed(0)}%` + : 'N/A'} + +
+
+
+ + APY + + + {apys && + (apys[validator.address] + ? `${currency(apys[validator.address] * 100, { + precision: 2, + symbol: '', + }).format()}%` + : 'N/A')} + +
+
+
+ + Your deposited amount + +
+ +
+ + {formatCurrency(new BigNumber(delegation.balance.currenyAmount ?? ''))} + + + {delegation.balance.formatted_amount} + +
+
+
+
+ + Switch validator + + + Unstake + +
+
+ + ) +} + +interface ValidatorCardProps { + validator: Validator + delegation: Delegation + onClick: () => void +} + +function ValidatorCard({ validator, delegation, onClick }: ValidatorCardProps) { + const [formatCurrency] = useFormatCurrency() + const { formatHideBalance } = useHideAssets() + const { data: imageUrl } = useValidatorImage(validator) + return ( +
+
+ +
+
+ + {sliceWord(validator.moniker, 10, 3)} + + {validator.jailed && ( + + Jailed + + )} +
+
+ + {formatCurrency(new BigNumber(delegation.balance.currenyAmount ?? ''))} + + + {formatHideBalance(delegation.balance.formatted_amount ?? delegation.balance.amount)} + +
+
+
+
+ ) +} + +export default function ValidatorList() { + const navigate = useNavigate() + const [showStakedValidatorDetails, setShowStakedValidatorDetails] = useState(false) + const [selectedDelegation, setSelectedDelegation] = useState() + const { delegations, network, loadingNetwork, loadingDelegations } = useStaking() + const validators = network?.getValidators({}) as Record + const isLoading = loadingNetwork || loadingDelegations + + const query = useQuery() + const paramValidatorAddress = query.get('validatorAddress') ?? undefined + const paramAction = query.get('action') ?? undefined + + useEffect(() => { + if (paramValidatorAddress && paramAction !== 'DELEGATE') { + const delegation = Object.values(delegations ?? {}).find( + (d) => d.delegation.validator_address === paramValidatorAddress, + ) + + if (delegation) { + setSelectedDelegation(delegation) + setShowStakedValidatorDetails(true) + } + } + }, [delegations, paramAction, paramValidatorAddress]) + + const [activeValidatorDelegations, inactiveValidatorDelegations] = useMemo(() => { + const _sortedDelegations = Object.values(delegations ?? {}).sort( + (a, b) => parseFloat(b.balance.amount) - parseFloat(a.balance.amount), + ) + const _activeValidatorDelegations = _sortedDelegations.filter((d) => { + const validator = validators?.[d?.delegation?.validator_address] + if (!validator || validator.active === false) return false + return true + }) + const _inactiveValidatorDelegations = _sortedDelegations.filter((d) => { + const validator = validators?.[d?.delegation?.validator_address] + if (!validator || validator.active !== false) return false + return true + }) + return [_activeValidatorDelegations, _inactiveValidatorDelegations] + }, [delegations, validators]) + + return ( + <> + {isLoading && } +
+ {!isLoading && validators && activeValidatorDelegations.length > 0 && ( + <> +
+ + Validator + + + Amount Staked + +
+ {activeValidatorDelegations.map((d) => { + const validator = validators?.[d?.delegation?.validator_address] + return ( + { + setSelectedDelegation(d) + setShowStakedValidatorDetails(true) + }} + /> + ) + })} + + )} + {!isLoading && validators && inactiveValidatorDelegations.length > 0 && ( + <> +
+ + Inactive validator + + + Amount Staked + +
+ {inactiveValidatorDelegations.map((d) => { + const validator = validators[d?.delegation?.validator_address] + return ( + { + setSelectedDelegation(d) + setShowStakedValidatorDetails(true) + }} + /> + ) + })} + + )} +
+ {selectedDelegation && network && ( + setShowStakedValidatorDetails(false)} + onSwitchValidator={() => { + navigate('/stakeInput', { + state: { + mode: 'REDELEGATE', + fromValidator: validators[selectedDelegation.delegation.validator_address], + delegation: selectedDelegation, + } as StakeInputPageState, + }) + }} + onUnstake={() => { + navigate('/stakeInput', { + state: { + mode: 'UNDELEGATE', + toValidator: validators[selectedDelegation.delegation.validator_address], + delegation: selectedDelegation, + } as StakeInputPageState, + }) + }} + validator={validators[selectedDelegation?.delegation?.validator_address]} + delegation={selectedDelegation} + /> + )} + + ) +} diff --git a/apps/extension/src/pages/stake-v2/components/YouStake.tsx b/apps/extension/src/pages/stake-v2/components/YouStake.tsx new file mode 100644 index 00000000..b0072f33 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/components/YouStake.tsx @@ -0,0 +1,287 @@ +import { + formatTokenAmount, + STAKE_MODE, + useActiveStakingDenom, + useformatCurrency, +} from '@leapwallet/cosmos-wallet-hooks' +import { fromSmall, toSmall } from '@leapwallet/cosmos-wallet-sdk' +import { Delegation } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/staking' +import BigNumber from 'bignumber.js' +import Text from 'components/text' +import { GenericLight } from 'images/logos' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import Skeleton from 'react-loading-skeleton' +import { Colors } from 'theme/colors' +import { Token } from 'types/bank' +import { hex2rgba } from 'utils/hextorgba' +import { imgOnError } from 'utils/imgOnError' + +interface YouStakeProps { + token?: Token + setAmount: (val: string) => void + fees?: { amount: string; denom: string } + hasError: boolean + setHasError: (val: boolean) => void + mode: STAKE_MODE + delegation?: Delegation + amount: string + adjustAmount: boolean + setAdjustAmount: (val: boolean) => void +} + +export default function YouStake({ + token, + amount, + setAmount, + fees, + hasError, + setHasError, + mode, + delegation, + adjustAmount, + setAdjustAmount, +}: YouStakeProps) { + const [activeStakingDenom] = useActiveStakingDenom() + const [formatCurrency] = useformatCurrency() + const [inputValue, setInputValue] = useState('') + const [isDollarInput, setIsDollarInput] = useState(false) + const [balanceLoading, setBalanceLoading] = useState(false) + + const displayedValue = useMemo(() => { + return inputValue + ? isDollarInput + ? new BigNumber(inputValue).dividedBy(new BigNumber(token?.usdPrice ?? '0')).toString() + : new BigNumber(inputValue).multipliedBy(new BigNumber(token?.usdPrice ?? '0')).toString() + : '' + }, [inputValue, isDollarInput, token?.usdPrice]) + + const maxValue = useMemo(() => { + if (mode === 'DELEGATE') { + const tokenAmount = toSmall(token?.amount ?? '0', token?.coinDecimals ?? 6) + const maxMinimalTokens = new BigNumber(tokenAmount).minus(fees?.amount ?? '0') + if (maxMinimalTokens.lte(0)) return '0' + const maxTokens = new BigNumber( + fromSmall(maxMinimalTokens.toString(), token?.coinDecimals ?? 6), + ).toFixed(6, 1) + + return isDollarInput + ? new BigNumber(maxTokens).multipliedBy(token?.usdPrice ?? '0').toFixed(6, 1) + : maxTokens + } else { + return isDollarInput + ? new BigNumber(delegation?.balance.currenyAmount ?? '').toFixed(6, 1) + : new BigNumber(delegation?.balance.amount ?? '').toFixed(6, 1) + } + }, [ + delegation?.balance.amount, + delegation?.balance.currenyAmount, + fees?.amount, + isDollarInput, + mode, + token?.amount, + token?.coinDecimals, + token?.usdPrice, + ]) + const showMaxButton = !new BigNumber(inputValue).isEqualTo(maxValue) + + const validateInput = useCallback( + (value: string) => { + const numericValue = new BigNumber(value) + if (numericValue.isLessThan(0)) { + setHasError(true) + return + } + let limit + if (mode === 'DELEGATE') { + limit = isDollarInput ? token?.usdValue ?? '0' : token?.amount ?? '0' + } else { + limit = isDollarInput + ? delegation?.balance.currenyAmount ?? '' + : delegation?.balance.amount ?? '' + } + if (numericValue.isGreaterThan(limit)) { + setHasError(true) + return + } + setHasError(false) + }, + [ + delegation?.balance.amount, + delegation?.balance.currenyAmount, + isDollarInput, + mode, + setHasError, + token, + ], + ) + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + .replace(/\$/, '') + .replace?.(/^0+(?=\d)/, '') + ?.replace?.(/(\.+)/g, '.') + if (/^\d*\.?\d*$/.test(value)) { + setInputValue(value) + validateInput(value) + } + } + + const handleSwapClick = () => { + if (inputValue) { + const newInputValue = isDollarInput + ? (parseFloat(inputValue) / parseFloat(token?.usdPrice ?? '0')).toFixed(6) + : (parseFloat(inputValue) * parseFloat(token?.usdPrice ?? '0')).toFixed(6) + setInputValue(newInputValue) + } + setIsDollarInput(!isDollarInput) + } + + const handleMaxClick = () => { + setInputValue(maxValue ?? '') + } + + const balance = useMemo(() => { + if (isDollarInput) { + const currenyAmount = new BigNumber( + (mode === 'DELEGATE' ? token?.usdValue : delegation?.balance.currenyAmount) ?? '', + ) + return currenyAmount + } else { + const tokenAmount = new BigNumber( + (mode === 'DELEGATE' ? token?.amount : delegation?.balance.amount) ?? '', + ) + return tokenAmount + } + }, [ + delegation?.balance.amount, + delegation?.balance.currenyAmount, + isDollarInput, + mode, + token?.amount, + token?.usdValue, + ]) + + useEffect(() => { + if (adjustAmount) { + if (isDollarInput) { + setInputValue((parseFloat(amount) * parseFloat(token?.usdPrice ?? '0')).toFixed(6)) + } else { + setInputValue(amount) + } + setAdjustAmount(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [adjustAmount]) + + useEffect(() => { + if (inputValue) { + const tokenAmount = isDollarInput + ? parseFloat(inputValue) / parseFloat(token?.usdPrice ?? '0') + : parseFloat(inputValue) + setAmount(tokenAmount.toFixed(6)) + } else { + setAmount('') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue, isDollarInput, token?.usdPrice]) + + useEffect(() => { + if ((mode === 'DELEGATE' && !token) || (mode !== 'DELEGATE' && !delegation)) { + setBalanceLoading(true) + } else { + setBalanceLoading(false) + } + }, [delegation, mode, token]) + + return ( +
+ + You are {mode === 'DELEGATE' || mode === 'REDELEGATE' ? 'staking' : 'unstaking'} + +
+ +
+ + + {activeStakingDenom?.coinDenom} + +
+
+
+ {!inputValue && ( +
+ + Switch to {isDollarInput ? 'TOKEN' : 'USD'}{' '} + + + swap_vert + +
+ )} + {inputValue && ( +
+ + {isDollarInput + ? `${formatTokenAmount(displayedValue)} ${activeStakingDenom?.coinDenom}` + : formatCurrency(new BigNumber(displayedValue))} + + + swap_vert + +
+ )} +
+ {balanceLoading && } + {!balanceLoading && ( + + Bal{' '} + {balance.eq(0) + ? '0' + : isDollarInput + ? formatCurrency(balance) + : formatTokenAmount(balance.toString())} + + )} + {showMaxButton && new BigNumber(token?.amount ?? '0').isGreaterThan(0) && ( +
+ + Max + +
+ )} +
+
+
+ ) +} diff --git a/apps/extension/src/pages/stake-v2/index.tsx b/apps/extension/src/pages/stake-v2/index.tsx new file mode 100644 index 00000000..f2271eb1 --- /dev/null +++ b/apps/extension/src/pages/stake-v2/index.tsx @@ -0,0 +1,50 @@ +import { useActiveChain, useIsFeatureExistForChain } from '@leapwallet/cosmos-wallet-hooks' +import { PageName } from 'config/analytics' +import { AGGREGATED_CHAIN_KEY } from 'config/constants' +import { usePageView } from 'hooks/analytics/usePageView' +import useQuery from 'hooks/useQuery' +import { AggregatedStake } from 'pages/stake/components' +import React, { useMemo } from 'react' +import { AggregatedSupportedChain } from 'types/utility' + +import StakingUnavailable from './components/StakingUnavailable' +import StakePage from './StakePage' + +export default function Stake() { + const pageViewSource = useQuery().get('pageSource') ?? undefined + const pageViewAdditionalProperties = useMemo( + () => ({ + pageViewSource, + }), + [pageViewSource], + ) + usePageView(PageName.Stake, true, pageViewAdditionalProperties) + const activeChain = useActiveChain() as AggregatedSupportedChain + + const isStakeComingSoon = useIsFeatureExistForChain({ + checkForExistenceType: 'comingSoon', + feature: 'stake', + platform: 'Extension', + }) + + const isStakeNotSupported = useIsFeatureExistForChain({ + checkForExistenceType: 'notSupported', + feature: 'stake', + platform: 'Extension', + }) + + if (activeChain === AGGREGATED_CHAIN_KEY) { + return + } + + if (isStakeNotSupported || isStakeComingSoon) { + return ( + + ) + } + + return +} diff --git a/apps/extension/src/pages/swaps-v2/index.tsx b/apps/extension/src/pages/swaps-v2/index.tsx index 735018d0..5146260e 100644 --- a/apps/extension/src/pages/swaps-v2/index.tsx +++ b/apps/extension/src/pages/swaps-v2/index.tsx @@ -488,6 +488,10 @@ export default function Swap() { pageSourceFormatted = 'Quick Search' break } + case 'stake': { + pageSourceFormatted = PageName.Stake + break + } default: { break } diff --git a/apps/extension/src/theme/colors.tsx b/apps/extension/src/theme/colors.tsx index fca9b521..7bfc2bb8 100644 --- a/apps/extension/src/theme/colors.tsx +++ b/apps/extension/src/theme/colors.tsx @@ -18,6 +18,7 @@ export namespace Colors { export const gray900 = '#212121' export const gray400 = '#9E9E9E' export const gray300 = '#B8B8B8' + export const gray200 = '#D6D6D6' export const gray100 = '#E8E8E8' export const gray800 = '#383838' export const gray950 = '#141414' @@ -27,11 +28,26 @@ export namespace Colors { export const junoPrimary = '#E18881' export const osmosisPrimary = '#726FDC' export const white100 = '#FFFFFF' + export const black100 = '#000000' export const red300 = '#FF707E' export const red400 = '#FF3D50' export const red600 = '#D10014' + export const orange100 = '#FFEDD1' + export const orange200 = '#FFDFAD' + export const orange300 = '#FFC770' + export const orange500 = '#FF9F0A' + export const orange600 = '#D17F00' + export const orange800 = '#704400' + export const orange900 = '#422800' + + export const blue200 = '#ADD6FF' + export const blue400 = '#3D9EFF' + export const blue600 = '#0A84FF' + export const blue800 = '#003870' + export const blue900 = '#002142' + export function getChainColor( chainName: SupportedChain, activeChainInfo?: { diff --git a/apps/extension/tailwind.config.js b/apps/extension/tailwind.config.js index fe63241c..fcfdbb16 100644 --- a/apps/extension/tailwind.config.js +++ b/apps/extension/tailwind.config.js @@ -88,12 +88,22 @@ module.exports = { 600: '#D1A700', }, orange: { + 100: '#FFEDD1', 200: '#FFDFAD', 300: '#FFC770', 500: '#FF9F0A', 600: '#D17F00', + 800: '#704400', 900: '#422800', }, + blue: { + 100: '#D1E8FF', + 200: '#ADD6FF', + 400: '#3D9EFF', + 600: '#0A84FF', + 800: '#003870', + 900: '#002142', + }, teal: { 500: '#0AB8FF', }, diff --git a/packages/wallet-hooks/package.json b/packages/wallet-hooks/package.json index 0e725935..d4ec2f8c 100644 --- a/packages/wallet-hooks/package.json +++ b/packages/wallet-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@leapwallet/cosmos-wallet-hooks", - "version": "0.10.7", + "version": "0.11.0", "description": "Wallet hooks for cosmos mobile app and extension.", "main": "dist/index.js", "scripts": { @@ -25,7 +25,7 @@ }, "dependencies": { "@leapwallet/parser-parfait": "0.7.0", - "@leapwallet/cosmos-wallet-sdk": "0.10.7", + "@leapwallet/cosmos-wallet-sdk": "0.11.0", "@tanstack/react-query": "4.2.3", "@tanstack/react-query-devtools": "4.2.3", "dotenv": "16.3.1", @@ -42,4 +42,4 @@ "jest": "28.1.0", "typescript": "5.2.2" } -} \ No newline at end of file +} diff --git a/packages/wallet-hooks/src/apis/LeapWalletApi.ts b/packages/wallet-hooks/src/apis/LeapWalletApi.ts index ac21f0f6..5a2c7c47 100644 --- a/packages/wallet-hooks/src/apis/LeapWalletApi.ts +++ b/packages/wallet-hooks/src/apis/LeapWalletApi.ts @@ -298,7 +298,7 @@ export namespace LeapWalletApi { ...txLogMap, }; - return blockchains[activeChain] ?? activeChain?.toUpperCase(); + return blockchains[activeChain]; } export function sanitizeUrl(url: string) { @@ -361,10 +361,11 @@ export namespace LeapWalletApi { const txLogMap = await getTxLogCosmosBlockchainMapStoreSnapshot(); const blockchain = getCosmosNetwork(activeChain, txLogMap); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore const logReq = { app: getPlatform(), txHash, - blockchain, isMainnet, wallet, walletAddress, @@ -381,6 +382,12 @@ export namespace LeapWalletApi { logReq.amount = amount; } + if (blockchain !== undefined) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + logReq.blockchain = blockchain; + } + try { if (isCompassWallet) { await txnLeapApi.operateSeiTx(logReq); @@ -426,10 +433,11 @@ export namespace LeapWalletApi { const blockchain = getCosmosNetwork(chain, txLogMap); try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore const logReq = { app: getPlatform(), txHash, - blockchain, isMainnet, wallet: primaryAddress ?? address, walletAddress: address, @@ -440,6 +448,12 @@ export namespace LeapWalletApi { chainId: _chainId ?? '', } as CosmosTxRequest; + if (blockchain !== undefined) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + logReq.blockchain = blockchain; + } + if (isCompassWallet) { await txnLeapApi.operateSeiTx(logReq); } else { diff --git a/packages/wallet-hooks/src/nfts/useTransferNFTs.ts b/packages/wallet-hooks/src/nfts/useTransferNFTs.ts index 343ed937..215ef8ea 100644 --- a/packages/wallet-hooks/src/nfts/useTransferNFTs.ts +++ b/packages/wallet-hooks/src/nfts/useTransferNFTs.ts @@ -25,11 +25,13 @@ import { getCompassSeiEvmConfigStoreSnapshot, PendingTx, useActiveChain, + useActiveWalletStore, useChainApis, useDefaultGasEstimates, usePendingTxState, useSelectedNetwork, } from '../store'; +import { WALLETTYPE } from '../types'; import { GasOptions, getMetaDataForNFTSendTx, @@ -43,7 +45,7 @@ import { useChainId, useChainInfo, useFetchAccountDetails } from '../utils-hooks import { ExecuteInstruction, UseSendNftReturnType } from './types'; export const useSendNft = (collectionId: string, forceChain?: SupportedChain): UseSendNftReturnType => { - const [showLedgerPopup] = useState(false); + const [showLedgerPopup, setShowLedgerPopup] = useState(false); const [isSending, setIsSending] = useState(false); const chainInfo = useChainInfo(); const { setPendingTx } = usePendingTxState(); @@ -51,6 +53,7 @@ export const useSendNft = (collectionId: string, forceChain?: SupportedChain): U const selectedNetwork = useSelectedNetwork(); const txPostToDB = LeapWalletApi.useOperateCosmosTx(); + const { activeWallet } = useActiveWalletStore(); const activeChainId = useChainId(activeChain, selectedNetwork); const defaultGasEstimates = useDefaultGasEstimates(); const { @@ -227,6 +230,10 @@ export const useSendNft = (collectionId: string, forceChain?: SupportedChain): U txHash = result.hash; isEvmTx = true; } else { + if (activeWallet?.walletType === WALLETTYPE.LEDGER) { + setShowLedgerPopup(true); + } + const pollForTx = new PollForTx(lcdUrl); const tx = { msg: { @@ -244,7 +251,9 @@ export const useSendNft = (collectionId: string, forceChain?: SupportedChain): U const result: any = await client.execute(fromAddress, collectionId, tx.msg, tx.fee, tx.memo, tx.funds); if (result && result.code !== undefined && result.code !== 0) { + setShowLedgerPopup(false); setIsSending(false); + return { success: false, errors: ['Transaction declined'], @@ -303,10 +312,12 @@ export const useSendNft = (collectionId: string, forceChain?: SupportedChain): U txPostToDB({ ..._result.data, chainId: activeChainId }); } + setShowLedgerPopup(false); setIsSending(false); setPendingTx({ ..._result.pendingTx, toAddress: toAddress }); return _result; } catch (e) { + setShowLedgerPopup(false); setIsSending(false); if ((e as Error).message.toLowerCase().includes('out of gas')) { diff --git a/packages/wallet-hooks/src/send/useSimpleSend.ts b/packages/wallet-hooks/src/send/useSimpleSend.ts index 882ec548..cdfe21f3 100644 --- a/packages/wallet-hooks/src/send/useSimpleSend.ts +++ b/packages/wallet-hooks/src/send/useSimpleSend.ts @@ -355,14 +355,15 @@ export const useSimpleSend = (forceChain?: SupportedChain, forceNetwork?: 'mainn ); } + const denomInfo = denoms[denom]; const pendingTx: PendingTx = { txHash: result.hash, img: chainInfo.chainSymbolImageUrl, sentAmount: value.toString(), - sentTokenInfo: denoms.usei, + sentTokenInfo: denomInfo, sentUsdValue: '', subtitle1: sliceAddress(toAddress), - title1: `${value.toString()} Sei`, + title1: `${value.toString()} ${denomInfo.coinDenom}`, txStatus: 'success', txType: 'send', isEvmTx: true, diff --git a/packages/wallet-hooks/src/staking/index.ts b/packages/wallet-hooks/src/staking/index.ts index 6fd1bed7..18b036b0 100644 --- a/packages/wallet-hooks/src/staking/index.ts +++ b/packages/wallet-hooks/src/staking/index.ts @@ -1,3 +1,4 @@ +export * from './useClaimAndStakeRewards'; export * from './useFetchStakeClaimRewards'; export * from './useFetchStakeDelegations'; export * from './useFetchStakeUndelegations'; diff --git a/packages/wallet-hooks/src/staking/useClaimAndStakeRewards.ts b/packages/wallet-hooks/src/staking/useClaimAndStakeRewards.ts new file mode 100644 index 00000000..1623045c --- /dev/null +++ b/packages/wallet-hooks/src/staking/useClaimAndStakeRewards.ts @@ -0,0 +1,347 @@ +import { OfflineSigner } from '@cosmjs/proto-signing'; +import { calculateFee, Coin, GasPrice, StdFee } from '@cosmjs/stargate'; +import { + ChainInfo, + DefaultGasEstimates, + feeDenoms, + fromSmall, + getSimulationFee, + NativeDenom, + simulateTx, + SupportedChain, +} from '@leapwallet/cosmos-wallet-sdk'; +import { getDelegateMsg } from '@leapwallet/cosmos-wallet-sdk'; +import { Delegation } from '@leapwallet/cosmos-wallet-sdk/dist/browser/types/staking'; +import { CosmosTxType } from '@leapwallet/leap-api-js'; +import BigNumber from 'bignumber.js'; +import { MsgDelegate } from 'cosmjs-types/cosmos/staking/v1beta1/tx'; +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; + +import { LeapWalletApi } from '../apis'; +import { useGasAdjustmentForChain } from '../fees'; +import { useformatCurrency } from '../settings'; +import { + useActiveChain, + useAddress, + useChainApis, + useDefaultGasEstimates, + useDenoms, + useGasPriceSteps, + useGetChains, + usePendingTxState, + useSelectedNetwork, + useStakeClaimRewards, + useTxMetadata, +} from '../store'; +import { useTxHandler } from '../tx'; +import { Amount } from '../types'; +import { + formatTokenAmount, + GasOptions, + getChainId, + getTxnLogAmountValue, + useGasRateQuery, + useNativeFeeDenom, +} from '../utils'; + +export type ChainRewards = { + rewards: any; + rewardsUsdValue: BigNumber; + rewardsStatus: string; + usdValueStatus: string; + denom: any; + rewardsDenomValue: BigNumber; +}; + +export function getNativeDenom( + chainInfos: Record, + activeChain: SupportedChain, + selectedNetwork: 'mainnet' | 'testnet', +) { + const nativeDenoms = Object.values(chainInfos[activeChain].nativeDenoms); + return selectedNetwork === 'testnet' && nativeDenoms.length > 1 ? nativeDenoms[1] : nativeDenoms[0]; +} + +export async function simulateClaimAndStake( + lcdEndpoint: string, + fromAddress: string, + validatorsWithRewards: { validator: string; amount: Coin }[], + fee: Coin[], +) { + const encodedClaimAndStakeMsgs: { + typeUrl: string; + value: Uint8Array; + }[] = []; + validatorsWithRewards.map((validatorWithReward) => { + const msg = getDelegateMsg(fromAddress, validatorWithReward.validator, validatorWithReward.amount); + const delegateMsg = { + typeUrl: msg.typeUrl, + value: MsgDelegate.encode(msg.value).finish(), + }; + encodedClaimAndStakeMsgs.push(delegateMsg); + }); + return await simulateTx(lcdEndpoint, fromAddress, encodedClaimAndStakeMsgs, { amount: fee }); +} + +export function useClaimAndStakeRewards( + delegations: Record | undefined, + chainRewards: ChainRewards, + setError: Dispatch>, + forceChain?: SupportedChain, + forceAddress?: string, +) { + const denoms = useDenoms(); + const txMetadata = useTxMetadata(); + const userAddress = useAddress(); + const address = forceAddress ?? userAddress; + const activeChain = useActiveChain(); + const chain = forceChain ?? activeChain; + const chainInfos = useGetChains(); + const defaultGasEstimates = useDefaultGasEstimates(); + const gasPriceSteps = useGasPriceSteps(); + const txPostToDB = LeapWalletApi.useOperateCosmosTx(); + const getTxHandler = useTxHandler(); + const { lcdUrl } = useChainApis(chain as SupportedChain); + const { setPendingTx } = usePendingTxState(); + const selectedNetwork = useSelectedNetwork(); + const { refetchDelegatorRewards } = useStakeClaimRewards(); + const [formatCurrency] = useformatCurrency(); + + const [loading, setLoading] = useState(false); + const [userPreferredGasPrice, setUserPreferredGasPrice] = useState(undefined); + const nativeFeeDenom = useNativeFeeDenom(); + const gasAdjustment = useGasAdjustmentForChain(); + const gasPrices = useGasRateQuery(activeChain, selectedNetwork); + + const [userPreferredGasLimit, setUserPreferredGasLimit] = useState(undefined); + const [feeDenom, setFeeDenom] = useState(nativeFeeDenom); + const [gasPriceOptions, setGasPriceOptions] = useState(gasPrices?.[feeDenom.coinMinimalDenom]); + const [gasOption, setGasOption] = useState(GasOptions.LOW); + const [recommendedGasLimit, setRecommendedGasLimit] = useState(() => { + return defaultGasEstimates[activeChain]?.DEFAULT_GAS_STAKE.toString() ?? DefaultGasEstimates?.DEFAULT_GAS_STAKE; + }); + + const customFee = useMemo(() => { + const _gasLimit = userPreferredGasLimit ?? Number(recommendedGasLimit); + const _gasPrice = userPreferredGasPrice ?? gasPriceOptions?.[gasOption]; + if (!_gasPrice) return; + + return calculateFee(Math.ceil(_gasLimit * gasAdjustment), _gasPrice as GasPrice); + }, [userPreferredGasLimit, recommendedGasLimit, userPreferredGasPrice, gasPriceOptions, gasOption, gasAdjustment]); + + const { validatorsWithRewards, totalRewardsToBeClaimedAndStaked } = useMemo(() => { + const nativeDenom = getNativeDenom(chainInfos, chain as SupportedChain, 'mainnet'); + + const _validatorsWithRewards: { + validator: string; + amount: Amount; + }[] = []; + + let _totalRewardsToBeClaimedAndStaked = 0; + Object.values(chainRewards.rewards.rewardMap).forEach((rewardObj: any) => { + const nativeDenomReward = rewardObj?.reward?.find((r: any) => r?.denom === nativeDenom.coinMinimalDenom); + const sanitizedDenomRewardAmount = Math.floor(Number(nativeDenomReward?.amount ?? 0)); + + const sanitizedDenomReward = nativeDenomReward + ? { + denom: nativeDenomReward?.denom, + amount: String(sanitizedDenomRewardAmount), + } + : undefined; + if (sanitizedDenomRewardAmount && sanitizedDenomReward) { + _validatorsWithRewards.push({ + validator: rewardObj?.validator_address as string, + amount: sanitizedDenomReward as Amount, + }); + _totalRewardsToBeClaimedAndStaked += sanitizedDenomRewardAmount; + } + }); + + return { + validatorsWithRewards: _validatorsWithRewards, + totalRewardsToBeClaimedAndStaked: _totalRewardsToBeClaimedAndStaked, + }; + }, [chain, chainRewards.rewards.rewardMap]); + + const claimAndStakeRewards = useCallback( + async (wallet: OfflineSigner, callbacks?: { success?: () => void; error?: () => void }) => { + try { + if (!address) { + return; + } + if (delegations) { + setLoading(true); + + if (!wallet) { + throw new Error('Unable to fetch offline signer'); + } + + if (!totalRewardsToBeClaimedAndStaked) { + throw new Error('Amount to be claimed and staked is low'); + } + + const txHandler = await getTxHandler(wallet); + + const defaultGasStake = defaultGasEstimates[chain as SupportedChain]?.DEFAULT_GAS_STAKE; + + let gasEstimate = defaultGasStake; + try { + const feeDenom = getNativeDenom(chainInfos, chain, selectedNetwork); + const fee = getSimulationFee(feeDenom?.coinMinimalDenom); + try { + const { gasUsed } = await simulateClaimAndStake(lcdUrl ?? '', address, validatorsWithRewards, fee); + gasEstimate = gasUsed; + } catch (e) { + // + } + } catch (e: any) { + if (e.code === 'ERR_BAD_REQUEST') { + throw 'Insufficient balance for transaction fee.'; + } else { + throw e.message; + } + } + const gasPriceStep = gasPriceSteps[chain as SupportedChain].low.toString(); + const denom = feeDenoms['mainnet'][chain as SupportedChain]; // fee denom for given chain + const gasPrice = GasPrice.fromString(`${gasPriceStep + denom.coinMinimalDenom}`); + let fee: StdFee; + if (customFee !== undefined) { + fee = customFee; + } else { + fee = calculateFee(Math.round((gasEstimate ?? defaultGasStake) * 1.5), gasPrice); + } + + txHandler + .claimAndStake(address, validatorsWithRewards, fee) + .then(async (txHash: any) => { + const txResult = txHandler.pollForTx(txHash); + const metadata = { + ...txMetadata, + token: { + amount: totalRewardsToBeClaimedAndStaked, + denom: denom.coinMinimalDenom, + }, + }; + const denomChainInfo = chainInfos[denom.chain as SupportedChain]; + const coinDenomAmount = fromSmall(String(totalRewardsToBeClaimedAndStaked), denom?.coinDecimals ?? 6); + const txnLogAmountValue = await getTxnLogAmountValue(coinDenomAmount, { + coinGeckoId: denoms?.[denom.coinMinimalDenom]?.coinGeckoId, + chain: denoms?.[denom.coinMinimalDenom]?.chain as SupportedChain, + coinMinimalDenom: denom.coinMinimalDenom, + chainId: getChainId(denomChainInfo, selectedNetwork), + }); + await txPostToDB({ + txHash, + txType: CosmosTxType.StakeClaimAndDelegate, + metadata, + feeDenomination: fee.amount[0].denom, + amount: txnLogAmountValue, + feeQuantity: fee.amount[0].amount, + }); + setPendingTx({ + img: chainInfos[chain as SupportedChain].chainSymbolImageUrl, + sentAmount: formatTokenAmount(chainRewards.rewardsDenomValue.toString(), '', 4), + sentTokenInfo: denom, + sentUsdValue: formatCurrency(chainRewards.rewardsUsdValue), + subtitle1: `Validator ${'Unknown'}`, + title1: `Claim and Stake Rewards`, + txStatus: 'loading', + txType: 'delegate', + promise: txResult, + txHash, + }); + setLoading(false); + refetchDelegatorRewards(); + callbacks?.success?.(); + // navigate(`/portfolio/activity?chain=${chain}`) + }) + .catch((e: any) => { + setLoading(false); + if (callbacks?.error) { + callbacks?.error?.(); + } + if (typeof e === 'string') { + setError(e); + } else if (e instanceof Error) { + setError(e.message); + } + setTimeout(() => setError(''), 3000); + }); + } + } catch (e) { + setLoading(false); + if (callbacks?.error) { + callbacks?.error?.(); + } + if (typeof e === 'string') { + setError(e); + } else if (e instanceof Error) { + setError(e.message); + } + setTimeout(() => setError(''), 3000); + } + }, + [ + address, + chain, + chainRewards.rewardsUsdValue, + defaultGasEstimates, + customFee, + delegations, + gasPriceSteps, + getTxHandler, + lcdUrl, + refetchDelegatorRewards, + selectedNetwork, + setError, + setPendingTx, + totalRewardsToBeClaimedAndStaked, + txMetadata, + txPostToDB, + validatorsWithRewards, + ], + ); + + const { rewardsToBeDelegated, rewardsToBeDelegatedFormatted } = useMemo(() => { + const nativeDenom = Object.values(chainInfos[chain].nativeDenoms).find( + (d) => d.coinDenom === chainInfos[chain].denom, + ); + + const amount = fromSmall(String(totalRewardsToBeClaimedAndStaked), nativeDenom?.coinDecimals ?? 6); + return { + rewardsToBeDelegated: amount, + rewardsToBeDelegatedFormatted: formatTokenAmount(amount, nativeDenom?.coinDenom), + }; + }, [chain, totalRewardsToBeClaimedAndStaked]); + + useEffect(() => { + (async function () { + if (!address) { + return; + } + const defaultGasStake = + defaultGasEstimates[chain as SupportedChain]?.DEFAULT_GAS_STAKE ?? DefaultGasEstimates?.DEFAULT_GAS_STAKE; + let gasEstimate = defaultGasStake; + const feeDenom = getNativeDenom(chainInfos, chain, selectedNetwork); + const fee = getSimulationFee(feeDenom?.coinMinimalDenom); + const { gasUsed } = await simulateClaimAndStake(lcdUrl ?? '', address, validatorsWithRewards, fee); + gasEstimate = gasUsed; + setRecommendedGasLimit(gasEstimate.toString()); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [totalRewardsToBeClaimedAndStaked]); + + return { + claimAndStakeRewards, + loading, + rewardsToBeDelegated, + rewardsToBeDelegatedFormatted, + recommendedGasLimit, + userPreferredGasLimit, + setUserPreferredGasLimit, + userPreferredGasPrice, + gasOption, + setGasOption, + setFeeDenom, + }; +} diff --git a/packages/wallet-hooks/src/staking/useFetchStakeValidators.ts b/packages/wallet-hooks/src/staking/useFetchStakeValidators.ts index 5c4c39fd..258af34f 100644 --- a/packages/wallet-hooks/src/staking/useFetchStakeValidators.ts +++ b/packages/wallet-hooks/src/staking/useFetchStakeValidators.ts @@ -1,11 +1,4 @@ -import { - ChainInfo, - getApr, - getChainInfo, - getUnbondingTime, - SupportedChain, - Validator, -} from '@leapwallet/cosmos-wallet-sdk'; +import { ChainInfo, getChainInfo, getUnbondingTime, SupportedChain, Validator } from '@leapwallet/cosmos-wallet-sdk'; import CosmosDirectory from '@leapwallet/cosmos-wallet-sdk/dist/browser/chains/cosmosDirectory'; import { useEffect, useMemo } from 'react'; @@ -19,6 +12,7 @@ import { } from '../store'; import { cachedRemoteDataWithLastModified, + getChainsApr, getPlatformType, getStakingActiveChain, getStakingSelectedNetwork, @@ -100,7 +94,7 @@ export function useFetchStakeValidators(forceChain?: SupportedChain, forceNetwor const { unbonding_time = 0 } = await getUnbondingTime(chainId, isTestnet, lcdUrl, chainInfos, chainData); if (isCancelled) return; - const calculatedApr = await getApr(activeChain, isTestnet, chainInfos, chainData); + const calculatedApr = await getChainsApr(activeChain, isTestnet, chainInfos, chainData); if (isCancelled) return; let priorityValidatorsByChain: PriorityValidatorByChains = {}; diff --git a/packages/wallet-hooks/src/staking/useStaking.ts b/packages/wallet-hooks/src/staking/useStaking.ts index 159e1094..0a6280b6 100644 --- a/packages/wallet-hooks/src/staking/useStaking.ts +++ b/packages/wallet-hooks/src/staking/useStaking.ts @@ -488,6 +488,7 @@ export function useStakeTx( }; const onTxSuccess = async (promise: any, txHash: string, callback?: TxCallback) => { const amtKey = mode === 'UNDELEGATE' || mode === 'CLAIM_REWARDS' ? 'receivedAmount' : 'sentAmount'; + const usdAmtKey = mode === 'UNDELEGATE' || mode === 'CLAIM_REWARDS' ? 'receivedUsdValue' : 'sentUsdValue'; let title = mode === 'CLAIM_REWARDS' ? 'claim rewards' : mode.toLowerCase(); switch (mode) { case 'CLAIM_REWARDS': @@ -514,6 +515,7 @@ export function useStakeTx( setPendingTx({ img: chainInfos[activeChain].chainSymbolImageUrl, [amtKey]: formatTokenAmount(amount, '', 4), + [usdAmtKey]: formatCurrency(new BigNumber(amount).multipliedBy(tokenFiatValue ?? '')), sentTokenInfo: denom, title1: `${capitalize(title)}`, subtitle1, @@ -865,7 +867,7 @@ export function useStakeTx( }, 750); return () => clearTimeout(timeoutID); - }, [amount]); + }, [amount, toValidator, fromValidator]); const displayFeeText = amount.length === 0 || !fees diff --git a/packages/wallet-hooks/src/staking/useStrideLiquidStaking.ts b/packages/wallet-hooks/src/staking/useStrideLiquidStaking.ts index b94889a3..47e8f919 100644 --- a/packages/wallet-hooks/src/staking/useStrideLiquidStaking.ts +++ b/packages/wallet-hooks/src/staking/useStrideLiquidStaking.ts @@ -168,7 +168,7 @@ export function useStrideLiquidStaking({ forceStrideAddress }: { forceStrideAddr gasPrice, }; }, - [chainInfos], + [chainInfos, gasPriceSteps, defaultGasPriceStep, selectedDenom], ); const executeLiquidStakeTx = async ({ @@ -270,6 +270,8 @@ export function useStrideLiquidStaking({ forceStrideAddress }: { forceStrideAddr feeDenomination: fee.amount[0].denom, feeQuantity: fee.amount[0].amount, chainId: activeChainId, + forceChain: 'stride', + forceWalletAddress: strideAddress, }); const txResult = tx.pollForTx(txHash); diff --git a/packages/wallet-hooks/src/store/index.ts b/packages/wallet-hooks/src/store/index.ts index a530c427..83e8e683 100644 --- a/packages/wallet-hooks/src/store/index.ts +++ b/packages/wallet-hooks/src/store/index.ts @@ -21,6 +21,7 @@ export * from './useBetaNativeTokens'; export * from './useBetaNFTsCollections'; export * from './useChainCosmosSDK'; export * from './useChainInfosConfig'; +export * from './useChainsAprStore'; export * from './useChainsStore'; export * from './useCoingeckoPricesStore'; export * from './useCustomChains'; diff --git a/packages/wallet-hooks/src/store/useChainsAprStore.ts b/packages/wallet-hooks/src/store/useChainsAprStore.ts new file mode 100644 index 00000000..6d8b7f6c --- /dev/null +++ b/packages/wallet-hooks/src/store/useChainsAprStore.ts @@ -0,0 +1,34 @@ +import create from 'zustand'; + +type ChainsAprData = { [key: string]: number }; + +type ChainsApr = { + chainsApr: ChainsAprData; + setChainsApr: (chainsApr: ChainsAprData) => void; +}; + +export const getChainsAprSnapshot = (): Promise => { + const currentState = useChainsAprStore.getState().chainsApr; + + if (currentState === null) { + return new Promise((resolve) => { + const unsubscribe = useChainsAprStore.subscribe((state) => { + if (state.chainsApr !== null) { + unsubscribe(); + resolve(state.chainsApr); + } + }); + }); + } + + return Promise.resolve(currentState); +}; + +export const useChainsAprStore = create((set) => ({ + chainsApr: {}, + setChainsApr: (chainsApr) => set(() => ({ chainsApr })), +})); + +export const useChainsApr = () => { + return useChainsAprStore((store) => store.chainsApr); +}; diff --git a/packages/wallet-hooks/src/utils-hooks/use-fill-aggregated-stake.ts b/packages/wallet-hooks/src/utils-hooks/use-fill-aggregated-stake.ts index 25cc0a8c..a1297d41 100644 --- a/packages/wallet-hooks/src/utils-hooks/use-fill-aggregated-stake.ts +++ b/packages/wallet-hooks/src/utils-hooks/use-fill-aggregated-stake.ts @@ -1,10 +1,11 @@ -import { getApr, getChainInfo, SupportedChain } from '@leapwallet/cosmos-wallet-sdk'; +import { getChainInfo, SupportedChain } from '@leapwallet/cosmos-wallet-sdk'; import { useQuery } from '@tanstack/react-query'; import { BigNumber } from 'bignumber.js'; import { useUserPreferredCurrency } from '../settings'; import { AggregatedStake, useAddress, useChainApis, useGetChains } from '../store'; import { + getChainsApr, getClaimRewardsForChain, getDelegationsForChain, getPlatformType, @@ -120,7 +121,7 @@ export function useFillAggregatedStake( // APR if (chainInfoResponse.status === 'fulfilled') { - const calculatedApr = await getApr(chain, false, chains, chainInfoResponse.value); + const calculatedApr = await getChainsApr(chain, false, chains, chainInfoResponse.value); const chainData = { params: { ...(chainInfoResponse.value?.params ?? {}), calculated_apr: calculatedApr }, }; diff --git a/packages/wallet-hooks/src/utils-hooks/use-get-fee-market-gas-prices-steps.ts b/packages/wallet-hooks/src/utils-hooks/use-get-fee-market-gas-prices-steps.ts index fa5b8057..764843d6 100644 --- a/packages/wallet-hooks/src/utils-hooks/use-get-fee-market-gas-prices-steps.ts +++ b/packages/wallet-hooks/src/utils-hooks/use-get-fee-market-gas-prices-steps.ts @@ -20,18 +20,10 @@ export function useGetFeeMarketGasPricesSteps(forceChain?: SupportedChain, force const baseGasPriceStep = useGasPriceStepForChain(activeChain, activeNetwork); return useCallback( - async function getFeeMarketGasPricesSteps( - feeDenom: string, - forceBaseGasPriceStep?: GasPriceStep, - isIbcDenom?: boolean, - ) { + async function getFeeMarketGasPricesSteps(feeDenom: string, forceBaseGasPriceStep?: GasPriceStep) { const feeMarketData: FeeMarketGasPrices = await getFeeMarketGasPrices(lcdUrl ?? ''); - let feeDenomKey = feeDenom; - if (isIbcDenom) { - feeDenomKey = feeDenomKey.slice(0, 1) + 'ibc' + feeDenomKey.slice(1); - } + const feeMarketDenomData = feeMarketData.find(({ denom }) => denom === feeDenom); - const feeMarketDenomData = feeMarketData.find(({ denom }) => denom === feeDenomKey); if (feeMarketDenomData) { const minGasPrice = roundOf(Number(feeMarketDenomData.amount), 4); @@ -39,7 +31,11 @@ export function useGetFeeMarketGasPricesSteps(forceChain?: SupportedChain, force const medium = minGasPrice * 1.2; const high = minGasPrice * 1.3; - return { low, medium, high }; + return { + low: roundOf(low, 5), + medium: roundOf(medium, 5), + high: roundOf(high, 5), + }; } else { return forceBaseGasPriceStep || baseGasPriceStep; } diff --git a/packages/wallet-hooks/src/utils/daysLeft.ts b/packages/wallet-hooks/src/utils/daysLeft.ts new file mode 100644 index 00000000..a3289f65 --- /dev/null +++ b/packages/wallet-hooks/src/utils/daysLeft.ts @@ -0,0 +1,20 @@ +export function daysLeft(dateString: string) { + const date = new Date(dateString); + + const day = date.getUTCDate(); + const month = date.toLocaleString('default', { month: 'long' }); + + let daySuffix; + if (day % 10 == 1 && day != 11) { + daySuffix = 'st'; + } else if (day % 10 == 2 && day != 12) { + daySuffix = 'nd'; + } else if (day % 10 == 3 && day != 13) { + daySuffix = 'rd'; + } else { + daySuffix = 'th'; + } + + const formattedDate = `${day}${daySuffix} ${month}`; + return formattedDate; +} diff --git a/packages/wallet-hooks/src/utils/formatNewChainInfo.ts b/packages/wallet-hooks/src/utils/formatNewChainInfo.ts index 1e61dd51..7546b34e 100644 --- a/packages/wallet-hooks/src/utils/formatNewChainInfo.ts +++ b/packages/wallet-hooks/src/utils/formatNewChainInfo.ts @@ -108,7 +108,10 @@ export function formatNewChainInfo(chainInfo: CustomChainsType) { gasPriceStep: gasPriceStep, ibcChannelIds: {}, nativeDenoms: { - [rest.coinMinimalDenom as string]: rest as NativeDenom, + [rest.coinMinimalDenom as string]: { + ...rest, + chain: chainInfo.chainRegistryPath, + } as NativeDenom, }, theme: chainInfo.theme || { primaryColor: '#E18881', diff --git a/packages/wallet-hooks/src/utils/get-chains-apr.ts b/packages/wallet-hooks/src/utils/get-chains-apr.ts new file mode 100644 index 00000000..8f7d44ee --- /dev/null +++ b/packages/wallet-hooks/src/utils/get-chains-apr.ts @@ -0,0 +1,34 @@ +import { ChainData, ChainInfo, ChainInfos, getApr, getRestUrl, SupportedChain } from '@leapwallet/cosmos-wallet-sdk'; +import axios from 'axios'; + +import { getChainsAprSnapshot } from '../store'; + +export async function getChainsApr( + chain: SupportedChain, + testnet: boolean, + chainInfos?: Record, + chainData?: ChainData, +) { + try { + const chainsApr = await getChainsAprSnapshot(); + + if (chainsApr[ChainInfos[chain].chainId] !== undefined) { + return chainsApr[ChainInfos[chain].chainId]; + } + + const denom = Object.values((chainInfos ?? ChainInfos)[chain].nativeDenoms)[0]; + const lcd = getRestUrl(chainInfos ?? ChainInfos, chain, testnet); + const url = `${process.env.LEAP_WALLET_BACKEND_API_URL}/market/apr-changes`; + + const requestData = { + testnet: false, + denom: denom.coinMinimalDenom, + chainRegistryPath: chainInfos?.[chain].chainRegistryPath, + url: lcd, + }; + const response = await axios.post(url, requestData); + return response.data.apr; + } catch (error) { + return await getApr(chain, testnet, chainInfos, chainData); + } +} diff --git a/packages/wallet-hooks/src/utils/getMetadataForTxn.ts b/packages/wallet-hooks/src/utils/getMetadataForTxn.ts index a61040c8..6df7d3f5 100644 --- a/packages/wallet-hooks/src/utils/getMetadataForTxn.ts +++ b/packages/wallet-hooks/src/utils/getMetadataForTxn.ts @@ -55,7 +55,7 @@ export function getMetaDataForSecretTokenTransfer(contract: string) { /************* Secret *************/ export function getMetaDataForRedelegateTx( - fromValidate: string, + fromValidator: string, toValidator: string, token: { amount: string; denom: string }, ) { @@ -63,7 +63,7 @@ export function getMetaDataForRedelegateTx( return { ...globalTxMeta, - fromValidate, + fromValidator, toValidator, token, }; diff --git a/packages/wallet-hooks/src/utils/index.ts b/packages/wallet-hooks/src/utils/index.ts index 06427faf..f6c4f653 100644 --- a/packages/wallet-hooks/src/utils/index.ts +++ b/packages/wallet-hooks/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './cached-remote-data'; export * from './convertScientificNotation'; export * from './convertScrtToDenom'; export * from './cw20TokensQueryParams'; +export * from './daysLeft'; export * from './DenomFetcher'; export * from './fetch-proposal-metadata-from-link'; export * from './filter-spam-proposals'; @@ -10,6 +11,7 @@ export * from './findUSDValue'; export * from './format-proposal'; export * from './formatBigNumber'; export * from './formatNewChainInfo'; +export * from './get-chains-apr'; export * from './get-claim-rewards-for-chain'; export * from './get-delegations-for-chain'; export * from './get-proposal-content-from-messages'; @@ -51,6 +53,7 @@ export * from './useInitAirdropsData'; export * from './useInitAirdropsEligibilityData'; export * from './useInitAstroportPoolsChains'; export * from './useInitBetaNFTsCollections'; +export * from './useInitChainsApr'; export * from './useInitCoingeckoPrices'; export * from './useInitCustomChannelsStore'; export * from './useInitDefaultGasEstimates'; @@ -69,6 +72,7 @@ export * from './useInitLSStrideEnabledDenoms'; export * from './useInitNftChains'; export * from './useInitSpamProposals'; export * from './useInitTxMetadata'; +export * from './useLiquidStakingProviders'; export * from './useLowGasPriceStep'; export * from './useNativeFeeDenom'; export * from './useSetDisabledCW20InStorage'; diff --git a/packages/wallet-hooks/src/utils/useInitChainsApr.ts b/packages/wallet-hooks/src/utils/useInitChainsApr.ts new file mode 100644 index 00000000..b77231f0 --- /dev/null +++ b/packages/wallet-hooks/src/utils/useInitChainsApr.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +import { getChainsAprSnapshot, useChainsAprStore } from '../store'; + +export function useInitChainsApr() { + const { setChainsApr } = useChainsAprStore(); + + useQuery(['query-init-chains-apr'], async function () { + const chainsApr = await getChainsAprSnapshot(); + if (Object.values(chainsApr).length) { + setChainsApr(chainsApr); + } else { + const url = `${process.env.LEAP_WALLET_BACKEND_API_URL}/market/apr-changes`; + const { data } = await axios.get(url); + setChainsApr(data); + } + }); +} diff --git a/packages/wallet-hooks/src/utils/useLiquidStakingProviders.ts b/packages/wallet-hooks/src/utils/useLiquidStakingProviders.ts new file mode 100644 index 00000000..41884a45 --- /dev/null +++ b/packages/wallet-hooks/src/utils/useLiquidStakingProviders.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +import { storage, useGetStorageLayer } from '../utils'; +import { cachedRemoteDataWithLastModified } from './cached-remote-data'; + +export type LSProvider = { + name: string; + apy: number; + image: string; + url: string; + priority?: number; +}; + +export type LiquidStakingProviders = { + [key: string]: LSProvider[]; +}; + +export function getLiquidStakingProviders(storage: storage): Promise { + return cachedRemoteDataWithLastModified({ + remoteUrl: 'https://assets.leapwallet.io/cosmos-registry/v1/config/liquid-staking-providers.json', + storageKey: 'ls-providers', + storage, + }); +} + +export function useLiquidStakingProviders() { + const storage = useGetStorageLayer(); + + const queryRes = useQuery(['ls-providers-list'], () => getLiquidStakingProviders(storage), { + retry: 2, + }); + + const data = useMemo(() => { + return queryRes?.data ?? {}; + }, [queryRes?.data]); + + return { + ...queryRes, + data, + }; +} diff --git a/packages/wallet-provider/package.json b/packages/wallet-provider/package.json index 29299075..4f0b1087 100644 --- a/packages/wallet-provider/package.json +++ b/packages/wallet-provider/package.json @@ -1,6 +1,6 @@ { "name": "@leapwallet/cosmos-wallet-provider", - "version": "0.10.7", + "version": "0.11.0", "description": "Cosmos Wallet Provider for Leap Wallet.", "main": "dist/index.js", "files": [ @@ -17,7 +17,7 @@ "prepublish": "yarn build" }, "dependencies": { - "@leapwallet/cosmos-wallet-sdk": "0.10.7", + "@leapwallet/cosmos-wallet-sdk": "0.11.0", "@metamask/post-message-stream": "6.0.0", "cosmjs-types": "0.8.0", "uuid": "9.0.0" diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index ca13a8e2..914f9ce8 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@leapwallet/cosmos-wallet-sdk", - "version": "0.10.7", + "version": "0.11.0", "description": "TypeScript library to create and manage non-custodial wallets for cosmos blockchains.", "main": "dist/node/index.js", "scripts": { diff --git a/packages/wallet-sdk/src/constants/chain-infos.ts b/packages/wallet-sdk/src/constants/chain-infos.ts index 649b6d0c..1a18bd51 100644 --- a/packages/wallet-sdk/src/constants/chain-infos.ts +++ b/packages/wallet-sdk/src/constants/chain-infos.ts @@ -3682,7 +3682,7 @@ export const ChainInfos: Record = { chainName: 'Humans.ai', key: 'humans', chainRegistryPath: 'humans', - chainSymbolImageUrl: 'https://assets.leapwallet.io/humans.svg', + chainSymbolImageUrl: 'https://assets.leapwallet.io/humans.png', apis: { rest: 'https://rest.cosmos.directory/humans', rpc: 'https://rpc.cosmos.directory/humans', diff --git a/packages/wallet-sdk/src/constants/denoms.ts b/packages/wallet-sdk/src/constants/denoms.ts index c302cebf..e8511153 100644 --- a/packages/wallet-sdk/src/constants/denoms.ts +++ b/packages/wallet-sdk/src/constants/denoms.ts @@ -1433,7 +1433,7 @@ export const denoms = { coinDenom: 'HEART', coinDecimals: 18, coinMinimalDenom: 'aheart', - icon: 'https://assets.leapwallet.io/humans.svg', + icon: 'https://assets.leapwallet.io/humans.png', chain: 'humans', coinGeckoId: 'humans-ai', }, diff --git a/packages/wallet-sdk/src/key/eth-sign.ts b/packages/wallet-sdk/src/key/eth-sign.ts index 4108160e..c3194736 100644 --- a/packages/wallet-sdk/src/key/eth-sign.ts +++ b/packages/wallet-sdk/src/key/eth-sign.ts @@ -89,7 +89,11 @@ export async function ethSign( const hash = keccak256(Buffer.from(tx)); if (wallet instanceof LeapLedgerSignerEth) { const signature = await wallet.signPersonalMessage(signerAddress, Buffer.from(tx).toString('hex')); - const formattedSignature = concat([signature.r, signature.s, Buffer.from('1b', 'hex')]); + const formattedSignature = concat([ + signature.r, + signature.s, + signature.v ? Buffer.from('1c', 'hex') : Buffer.from('1b', 'hex'), + ]); return { signed: signDoc, signature: { @@ -101,7 +105,11 @@ export async function ethSign( const signature = await wallet.sign(signerAddress, hash); - const formattedSignature = concat([signature.r, signature.s, Buffer.from('1b', 'hex')]); + const formattedSignature = concat([ + signature.r, + signature.s, + signature.v ? Buffer.from('1c', 'hex') : Buffer.from('1b', 'hex'), + ]); return { signed: signDoc, signature: { @@ -160,7 +168,11 @@ async function signEip712Tx( const data = await EIP712MessageValidator.validateAsync(JSON.parse(messageBuffer)); if (wallet instanceof LeapLedgerSignerEth) { const signature = await wallet.signEip712(signerAddress, data); - const formattedSignature = concat([signature.r, signature.s, Buffer.from('1b', 'hex')]); + const formattedSignature = concat([ + signature.r, + signature.s, + signature.v ? Buffer.from('1c', 'hex') : Buffer.from('1b', 'hex'), + ]); return { signed: signDoc, signature: { diff --git a/packages/wallet-sdk/src/tx/ethermint.ts b/packages/wallet-sdk/src/tx/ethermint.ts index 9c7a43ee..a66eb317 100644 --- a/packages/wallet-sdk/src/tx/ethermint.ts +++ b/packages/wallet-sdk/src/tx/ethermint.ts @@ -403,4 +403,23 @@ export class EthermintTxHandler { pubkey, }; } + + async claimAndStake( + delegatorAddress: string, + validatorsWithRewards: { validator: string; amount: Coin }[], + fees: StdFee, + memo?: string, + ) { + const walletAccount = await this.wallet.getAccounts(); + const sender = await this.getSender(delegatorAddress, Buffer.from(walletAccount[0].pubkey).toString('base64')); + const txFee = EthermintTxHandler.getFeeObject(fees); + const tx = transactions.createTxMsgMultipleDelegate(this.chain, sender, txFee, memo ?? '', { + values: validatorsWithRewards.map((validatorWithReward) => ({ + validatorAddress: validatorWithReward.validator, + amount: validatorWithReward.amount.amount, + denom: validatorWithReward.amount.denom, + })), + }); + return this.signAndBroadcast(delegatorAddress, sender.accountNumber, tx); + } } diff --git a/packages/wallet-sdk/src/tx/injectiveTx.ts b/packages/wallet-sdk/src/tx/injectiveTx.ts index ac67096f..feef3e74 100644 --- a/packages/wallet-sdk/src/tx/injectiveTx.ts +++ b/packages/wallet-sdk/src/tx/injectiveTx.ts @@ -1,5 +1,6 @@ import { Secp256k1 } from '@cosmjs/crypto'; -import { toBase64 } from '@cosmjs/encoding'; +import { sha256 } from '@cosmjs/crypto'; +import { fromBase64, toBase64, toHex } from '@cosmjs/encoding'; import { EncodeObject } from '@cosmjs/proto-signing'; import { calculateFee, @@ -25,7 +26,6 @@ import { MsgBeginRedelegate, MsgDelegate, MsgExecuteContract, - MsgExecuteContractCompat, MsgGrant, MsgRevoke, MsgSend, @@ -50,19 +50,7 @@ import { axiosWrapper } from '../healthy-nodes'; import { LeapLedgerSignerEth } from '../ledger'; import { getClientState, getRestUrl, sleep } from '../utils'; import { buildGrantMsg } from './msgs/cosmos'; - -enum MsgTypes { - GRANT = '/cosmos.authz.v1beta1.MsgGrant', - REVOKE = '/cosmos.authz.v1beta1.MsgRevoke', - SEND = '/cosmos.bank.v1beta1.MsgSend', - IBCTRANSFER = '/ibc.applications.transfer.v1.MsgTransfer', - GOV = '/cosmos.gov.v1beta1.MsgVote', - DELEGATE = '/cosmos.staking.v1beta1.MsgDelegate', - UNDELEGAGE = '/cosmos.staking.v1beta1.MsgUndelegate', - REDELEGATE = '/cosmos.staking.v1beta1.MsgBeginRedelegate', - WITHDRAW_REWARD = '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', - MSG_EXECUTE_CONTRACT = '/cosmwasm.wasm.v1.MsgExecuteContract', -} +import { getInjAminoMessage, MsgTypes } from './msgs/injective'; export class InjectiveTx { chainRestAuthApi: ChainRestAuthApi; @@ -602,12 +590,17 @@ export class InjectiveTx { if (this.wallet instanceof LeapLedgerSignerEth) { const wallet = this.wallet as LeapLedgerSignerEth; const { eip712Tx, txRaw } = await this.createEip712Tx(signerAddress, msgs, usedFee, memo); + const signature = await wallet.signEip712(signerAddress, eip712Tx); const signatureStr = joinSignature(signature as SignatureLike); const signatureBuffer = Buffer.from(signatureStr.replace('0x', ''), 'hex'); const web3Extension = createWeb3Extension({ ethereumChainId: this.testnet ? 888 : 1 }); + const txRawEip712 = createTxRawEIP712(txRaw, web3Extension); txRawEip712.signatures.push(signatureBuffer); + const encodedTx = TxClient.encode(txRawEip712); + const txHash = toHex(sha256(fromBase64(encodedTx))).toUpperCase(); + console.log('logging txHash', txHash); return txRawEip712; } const { txRaw, signBytes, signDoc } = await this.createTx(signerAddress, msgs, usedFee, memo); @@ -664,17 +657,6 @@ export class InjectiveTx { const chainRestTendermintApi = new ChainRestTendermintApi(this.restEndpoint); const latestBlock = await chainRestTendermintApi.fetchLatestBlock(); const latestHeight = latestBlock.header.height; - const formatIbcMessage = (msg: EncodeObject) => { - return { - ...msg.value, - timeout: '8446744073709551615', - height: { - revisionHeight: parseInt(msg.value.height.revisionHeight), - revisionNumber: parseInt(msg.value.height.revisionNumber), - }, - }; - }; - const fee = { amount: _fee.amount.map((amt) => { return { @@ -685,30 +667,7 @@ export class InjectiveTx { gas: _fee.gas, }; - const messages = msgs.map((msg) => { - switch (msg.typeUrl) { - case MsgTypes.IBCTRANSFER: - return MsgTransfer.fromJSON(formatIbcMessage(msg)); - case MsgTypes.MSG_EXECUTE_CONTRACT: - return MsgExecuteContractCompat.fromJSON(msg.value); - case MsgTypes.GOV: - return MsgVote.fromJSON({ ...msg.value, proposalId: msg.value.proposalId.toInt() }); - case MsgTypes.DELEGATE: - return MsgDelegate.fromJSON(msg.value); - case MsgTypes.UNDELEGAGE: - return MsgUndelegate.fromJSON(msg.value); - case MsgTypes.REDELEGATE: - return MsgBeginRedelegate.fromJSON(msg.value); - case MsgTypes.WITHDRAW_REWARD: - return MsgWithdrawDelegatorReward.fromJSON(msg.value); - case MsgTypes.GRANT: - return MsgGrant.fromJSON(msg.value); - case MsgTypes.REVOKE: - return MsgRevoke.fromJSON(msg.value); - default: - return MsgSend.fromJSON(msg.value); - } - }); + const messages = getInjAminoMessage(msgs); const timeoutHeight = new BigNumber(latestHeight).plus(90); @@ -797,7 +756,7 @@ export class InjectiveTx { return new MsgVote(msg.value); case MsgTypes.DELEGATE: return new MsgDelegate(msg.value); - case MsgTypes.UNDELEGAGE: + case MsgTypes.UNDELEGATE: return new MsgUndelegate(msg.value); case MsgTypes.REDELEGATE: return new MsgBeginRedelegate(msg.value); @@ -826,4 +785,46 @@ export class InjectiveTx { return tx; } + + async claimAndStake( + delegatorAddress: string, + validatorsWithRewards: { validator: string; amount: Coin }[], + fees: number | StdFee | 'auto', + memo?: string, + ) { + return (await this._claimAndStake(delegatorAddress, validatorsWithRewards, false, fees, memo)) as unknown as string; + } + + async simulateClaimAndStake(delegatorAddress: string, validatorsWithRewards: { validator: string; amount: Coin }[]) { + return (await this._claimAndStake(delegatorAddress, validatorsWithRewards, true)) as unknown as number; + } + + private async _claimAndStake( + delegatorAddress: string, + validatorsWithRewards: { validator: string; amount: Coin }[], + simulate: boolean, + fees?: number | StdFee | 'auto', + memo?: string, + ) { + const claimAndStakeMsgs: EncodeObject[] = []; + validatorsWithRewards.map((validatorWithReward) => { + const delegateMsg = { + typeUrl: '/cosmos.staking.v1beta1.MsgDelegate', + value: { + injectiveAddress: delegatorAddress, + validatorAddress: validatorWithReward.validator, + amount: { + amount: validatorWithReward.amount.amount, + denom: validatorWithReward.amount.denom, + }, + }, + }; + claimAndStakeMsgs.push(delegateMsg); + }); + if (simulate && !fees) { + return await this.simulate(delegatorAddress, claimAndStakeMsgs); + } else if (fees) { + return await this.signAndBroadcastTx(delegatorAddress, claimAndStakeMsgs, fees, memo); + } + } } diff --git a/packages/wallet-sdk/src/tx/msgs/cosmos.ts b/packages/wallet-sdk/src/tx/msgs/cosmos.ts index e4fd069d..5ca6ca51 100644 --- a/packages/wallet-sdk/src/tx/msgs/cosmos.ts +++ b/packages/wallet-sdk/src/tx/msgs/cosmos.ts @@ -185,3 +185,18 @@ export function buildRevokeMsg(type: string, granter: string, grantee: string) { value: value, }; } + +export function getClaimAndStakeMsgs( + delegatorAddress: string, + validatorsWithRewards: { validator: string; amount: Coin }[], +) { + const claimAndStakeMsgs: { + typeUrl: string; + value: { delegatorAddress: string; validatorAddress: string; amount: Coin }; + }[] = []; + validatorsWithRewards.forEach((validatorWithReward) => { + const delegateMsg = getDelegateMsg(delegatorAddress, validatorWithReward.validator, validatorWithReward.amount); + claimAndStakeMsgs.push(delegateMsg); + }); + return claimAndStakeMsgs; +} diff --git a/packages/wallet-sdk/src/tx/msgs/index.ts b/packages/wallet-sdk/src/tx/msgs/index.ts index cd71356c..28e3cf9f 100644 --- a/packages/wallet-sdk/src/tx/msgs/index.ts +++ b/packages/wallet-sdk/src/tx/msgs/index.ts @@ -1 +1,3 @@ export * from './cosmos'; +export * from './ethermint'; +export * from './injective'; diff --git a/packages/wallet-sdk/src/tx/msgs/injective.ts b/packages/wallet-sdk/src/tx/msgs/injective.ts new file mode 100644 index 00000000..4be12603 --- /dev/null +++ b/packages/wallet-sdk/src/tx/msgs/injective.ts @@ -0,0 +1,165 @@ +import { AminoMsg } from '@cosmjs/amino'; +import { EncodeObject } from '@cosmjs/proto-signing'; +import { + MsgBeginRedelegate, + MsgDelegate, + MsgExecuteContract, + MsgExecuteContractCompat, + MsgGrant, + MsgRevoke, + MsgSend, + MsgTransfer, + MsgUndelegate, + MsgVote, + MsgWithdrawDelegatorReward, +} from '@injectivelabs/sdk-ts'; + +export enum MsgTypes { + GRANT = '/cosmos.authz.v1beta1.MsgGrant', + REVOKE = '/cosmos.authz.v1beta1.MsgRevoke', + SEND = '/cosmos.bank.v1beta1.MsgSend', + IBCTRANSFER = '/ibc.applications.transfer.v1.MsgTransfer', + GOV = '/cosmos.gov.v1beta1.MsgVote', + DELEGATE = '/cosmos.staking.v1beta1.MsgDelegate', + UNDELEGATE = '/cosmos.staking.v1beta1.MsgUndelegate', + REDELEGATE = '/cosmos.staking.v1beta1.MsgBeginRedelegate', + WITHDRAW_REWARD = '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward', + MSG_EXECUTE_CONTRACT = '/cosmwasm.wasm.v1.MsgExecuteContract', +} + +export enum MsgTypesAmino { + GRANT = 'cosmos-sdk/MsgGrant', + REVOKE = 'cosmos-sdk/MsgRevoke', + SEND = 'cosmos-sdk/MsgSend', + IBCTRANSFER = 'cosmos-sdk/MsgTransfer', + GOV = 'cosmos-sdk/MsgVote', + DELEGATE = 'cosmos-sdk/MsgDelegate', + UNDELEGATE = 'cosmos-sdk/MsgUndelegate', + REDELEGATE = 'cosmos-sdk/MsgBeginRedelegate', + WITHDRAW_REWARD = 'cosmos-sdk/MsgWithdrawDelegationReward', + MSG_EXECUTE_CONTRACT = 'wasm/MsgExecuteContract', + MSG_EXECUTE_CONTRACT_COMPAT = 'wasmx/MsgExecuteContractCompat', +} + +export const formatIbcMessage = (msg: EncodeObject | AminoMsg) => { + return { + ...msg.value, + timeout: '8446744073709551615', + height: { + revisionHeight: parseInt(msg.value.height.revisionHeight), + revisionNumber: parseInt(msg.value.height.revisionNumber), + }, + }; +}; + +export const convertFundToObject = (inputString: string) => { + // Find the index where the number ends and the string begins + const splitIndex = inputString.search(/[^0-9]/); + + if (splitIndex === -1) { + throw new Error('Invalid input: string must contain both a number and non-numeric characters'); + } + + const amount = inputString.slice(0, splitIndex); + const denom = inputString.slice(splitIndex); + + return { + amount: parseInt(amount), + denom: denom, + }; +}; + +export function getInjAminoMessage(msgs: EncodeObject[]) { + const messages = msgs.map((msg) => { + switch (msg.typeUrl) { + case MsgTypes.IBCTRANSFER: + return MsgTransfer.fromJSON(formatIbcMessage(msg)); + case MsgTypes.MSG_EXECUTE_CONTRACT: + return MsgExecuteContractCompat.fromJSON(msg.value); + case MsgTypes.GOV: + return MsgVote.fromJSON({ ...msg.value, proposalId: msg.value.proposalId.toInt() }); + case MsgTypes.DELEGATE: + return MsgDelegate.fromJSON(msg.value); + case MsgTypes.UNDELEGATE: + return MsgUndelegate.fromJSON(msg.value); + case MsgTypes.REDELEGATE: + return MsgBeginRedelegate.fromJSON(msg.value); + case MsgTypes.WITHDRAW_REWARD: + return MsgWithdrawDelegatorReward.fromJSON(msg.value); + case MsgTypes.GRANT: + return MsgGrant.fromJSON(msg.value); + case MsgTypes.REVOKE: + return MsgRevoke.fromJSON(msg.value); + default: + return MsgSend.fromJSON(msg.value); + } + }); + return messages; +} + +// this is used to create messages for generating transaction hash for dapp messages. +export function getMsgFromAmino(msgs: AminoMsg[]) { + const messages = msgs.map((msg) => { + switch (msg.type) { + case MsgTypesAmino.IBCTRANSFER: + return MsgTransfer.fromJSON({ + ...msg.value, + amount: msg.value.token, + port: msg.value.source_port, + channelId: msg.value.source_channel, + timeout: parseInt(msg.value.timeout_timestamp), + height: { + revisionHeight: parseInt(msg.value.timeout_height.revision_height), + revisionNumber: parseInt(msg.value.timeout_height.revision_number), + }, + }); + case MsgTypesAmino.MSG_EXECUTE_CONTRACT_COMPAT: + return MsgExecuteContractCompat.fromJSON({ + ...msg.value, + contractAddress: msg.value.contract, + msg: JSON.parse(msg.value.msg), + funds: [convertFundToObject(msg.value.funds)], + }); + case MsgTypesAmino.MSG_EXECUTE_CONTRACT: + return MsgExecuteContract.fromJSON({ ...msg.value, contractAddress: msg.value.contract }); + case MsgTypesAmino.GOV: + return MsgVote.fromJSON({ ...msg.value, proposalId: msg.value.proposalId.toInt() }); + case MsgTypesAmino.DELEGATE: + return MsgDelegate.fromJSON({ + ...msg.value, + injectiveAddress: msg.value.delegator_address, + validatorAddress: msg.value.validator_address, + }); + case MsgTypesAmino.UNDELEGATE: + return MsgUndelegate.fromJSON({ + ...msg.value, + injectiveAddress: msg.value.delegator_address, + validatorAddress: msg.value.validator_address, + }); + case MsgTypesAmino.REDELEGATE: + return MsgBeginRedelegate.fromJSON({ + ...msg.value, + injectiveAddress: msg.value.delegator_address, + srcValidatorAddress: msg.value.validator_src_address, + dstValidatorAddress: msg.value.validator_dst_address, + }); + case MsgTypesAmino.WITHDRAW_REWARD: + return MsgWithdrawDelegatorReward.fromJSON({ + ...msg.value, + validatorAddress: msg.value.validator_address, + delegatorAddress: msg.value.delegator_address, + }); + case MsgTypesAmino.GRANT: + return MsgGrant.fromJSON(msg.value); + case MsgTypesAmino.REVOKE: + return MsgRevoke.fromJSON(msg.value); + default: + return MsgSend.fromJSON({ + ...msg.value, + srcInjectiveAddress: msg.value.from_address, + dstInjectiveAddress: msg.value.to_address, + }); + } + }); + return messages; +} diff --git a/packages/wallet-sdk/src/tx/simulate.ts b/packages/wallet-sdk/src/tx/simulate.ts index ce7908f5..56697507 100644 --- a/packages/wallet-sdk/src/tx/simulate.ts +++ b/packages/wallet-sdk/src/tx/simulate.ts @@ -314,10 +314,10 @@ export async function simulateTx( throw new Error(result.data?.error); } - const gasUsed = parseInt(result.data.gas_info.gas_used); - const gasWanted = parseInt(result.data.gas_info.gas_wanted); + const gasUsed = parseInt(result.data?.gas_info?.gas_used); + const gasWanted = parseInt(result.data?.gas_info?.gas_wanted); if (Number.isNaN(gasUsed)) { - throw new Error(`Invalid integer gas: ${result.data.gas_info.gas_used}`); + throw new Error(`Invalid integer gas: ${result.data?.gas_info?.gas_used}`); } return { gasUsed, diff --git a/packages/wallet-sdk/src/tx/tx.ts b/packages/wallet-sdk/src/tx/tx.ts index 7736fec5..a9f3b5a5 100644 --- a/packages/wallet-sdk/src/tx/tx.ts +++ b/packages/wallet-sdk/src/tx/tx.ts @@ -29,6 +29,7 @@ import { buildGrantMsg, buildRevokeMsg, getCancelUnDelegationMsg, + getClaimAndStakeMsgs, getDelegateMsg, getIbcTransferMsg, getRedelegateMsg, @@ -369,4 +370,25 @@ export class Tx { return await this.client?.sign(signerAddress, msgs, usedFee, memo); } + + async claimAndStake( + delegatorAddress: string, + validatorsWithRewards: { validator: string; amount: Coin }[], + fees: number | StdFee | 'auto', + memo?: string, + ) { + const msgs = getClaimAndStakeMsgs(delegatorAddress, validatorsWithRewards); + return await this.signAndBroadcastTx(delegatorAddress, msgs, fees, memo); + } + + async simulateClaimAndStake( + delegatorAddress: string, + validatorsWithRewards: { validator: string; amount: Coin }[], + memo?: string, + ) { + const msgs = getClaimAndStakeMsgs(delegatorAddress, validatorsWithRewards); + + const result = await this.simulateTx(delegatorAddress, msgs, memo); + return result; + } } diff --git a/packages/wallet-sdk/src/tx/utils.ts b/packages/wallet-sdk/src/tx/utils.ts index e49795ff..7daa9dcd 100644 --- a/packages/wallet-sdk/src/tx/utils.ts +++ b/packages/wallet-sdk/src/tx/utils.ts @@ -12,6 +12,7 @@ import { stakingTypes, vestingTypes, } from '@cosmjs/stargate/build/modules'; +import { createTxRawEIP712, createWeb3Extension, TxClient, TxRaw as InjTxRaw } from '@injectivelabs/sdk-ts'; import { Coin } from 'cosmjs-types/cosmos/base/v1beta1/coin'; import { PubKey } from 'cosmjs-types/cosmos/crypto/secp256k1/keys'; import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing'; @@ -92,6 +93,23 @@ export function getTxHashFromSignedTxAmino(signedTx: StdSignDoc, signature: any, return toHex(sha256(txRaw)).toUpperCase(); } +export function getEip712TxHash({ + signature, + ethereumChainId, + txRaw, +}: { + signature: string; + ethereumChainId: number; + txRaw: InjTxRaw; +}) { + const signatureBuffer = Buffer.from(signature, 'base64'); + const web3Extension = createWeb3Extension({ ethereumChainId }); + const txRawEip712 = createTxRawEIP712(txRaw, web3Extension); + txRawEip712.signatures = [signatureBuffer]; + const encodedTx = TxClient.encode(txRawEip712); + return toHex(sha256(fromBase64(encodedTx))).toUpperCase(); +} + function pubkeyTypeUrl(chain: SupportedChain) { if (chain === 'injective') { return '/injective.crypto.v1beta1.ethsecp256k1.PubKey'; diff --git a/yarn.lock b/yarn.lock index 4aeabde1..f668c9f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9418,6 +9418,11 @@ crypto-js@4.2.0, crypto-js@^4.1.1: resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== +crypto-js@^3.1.9-1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.3.0.tgz#846dd1cce2f68aacfa156c8578f926a609b7976b" + integrity sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q== + crypto@1.0.1, crypto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" @@ -11580,7 +11585,7 @@ forwarded@0.2.0: blakejs "^1.1.0" bn.js "^4.11.8" buffer "^5.2.1" - crypto-js "4.2.0" + crypto-js "^3.1.9-1" elliptic "^6.4.1" hmac-drbg "^1.0.1" lodash "^4.17.21" @@ -19334,7 +19339,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -19352,15 +19357,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.1.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -19460,7 +19456,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -19488,13 +19484,6 @@ strip-ansi@^5.1.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -21371,7 +21360,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -21397,15 +21386,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"