From c5bf50acbdc7926157f029cb626fec7002ea3e15 Mon Sep 17 00:00:00 2001 From: asanjeevrao Date: Mon, 29 Jul 2024 05:58:49 +0000 Subject: [PATCH] Release release-20240729-055842 --- .gitignore | 1 - apps/.gitignore | 1 + apps/extension/.gitignore | 1 + apps/extension/public/base_manifest.json | 2 +- apps/extension/public/compass/manifest.json | 2 +- .../public/leap-cosmos/manifest.json | 2 +- apps/extension/src/App.tsx | 2 + apps/extension/src/Routes.tsx | 20 +- .../Header/NewChainSupportTooltip.tsx | 76 ---- .../src/components/Header/PageHeader.tsx | 85 ---- apps/extension/src/components/Header/index.ts | 2 - .../components/Skeletons/StakeSkeleton.tsx | 32 ++ .../Skeletons/ValidatorListSkeleton.tsx | 26 ++ .../src/components/bottom-nav/BottomNav.tsx | 2 +- .../components/gas-price-options/index.tsx | 10 +- .../src/components/gas-price-options/utils.ts | 7 +- .../src/components/header/Header.tsx | 41 ++ apps/extension/src/components/header/index.ts | 1 + .../components/search-modal/SearchModal.tsx | 3 +- apps/extension/src/config/analytics.ts | 3 + .../src/extension-scripts/background.ts | 5 +- .../src/hooks/settings/useActiveWallet.ts | 2 +- apps/extension/src/hooks/wallet/useWallet.ts | 3 +- apps/extension/src/images/stake/index.ts | 3 +- apps/extension/src/images/stake/milkyway.png | Bin 0 -> 59251 bytes .../airdrops/components/EligibleWallets.tsx | 3 - .../components/chart-details/index.tsx | 26 ++ .../nfts-v2/components/send-nft/index.tsx | 4 +- .../send-nft/review-transfer-sheet.tsx | 4 +- .../sign-sei-evm/SignSeiEvmTransaction.tsx | 11 + .../components/MessageSignature.tsx | 2 +- .../components/SignTransaction.tsx | 2 +- .../sign-sei-evm/utils/shared-functions.ts | 2 +- .../src/pages/sign/sign-transaction.tsx | 80 ++-- .../utils/is-generic-or-send-authz-grant.ts | 39 +- .../src/pages/sign/utils/tx-logger.ts | 77 +++- .../src/pages/stake-v2/StakeInputPage.tsx | 403 ++++++++++++++++++ .../src/pages/stake-v2/StakePage.tsx | 363 ++++++++++++++++ .../src/pages/stake-v2/StakeTxnPage.tsx | 300 +++++++++++++ .../stake-v2/components/AutoAdjustModal.tsx | 97 +++++ .../pages/stake-v2/components/ClaimInfo.tsx | 175 ++++++++ .../stake-v2/components/ComingSoonCard.tsx | 22 + .../components/InactiveValidatorCard.tsx | 16 + .../components/InsufficientBalanceCard.tsx | 34 ++ .../stake-v2/components/NotStakedCard.tsx | 19 + .../stake-v2/components/NotSupportedCard.tsx | 22 + .../components/PendingUnstakeList.tsx | 134 ++++++ .../components/ReviewCancelUnstakeTx.tsx | 259 +++++++++++ .../components/ReviewClaimAndStakeTx.tsx | 254 +++++++++++ .../stake-v2/components/ReviewClaimTx.tsx | 291 +++++++++++++ .../stake-v2/components/ReviewStakeTx.tsx | 165 +++++++ .../stake-v2/components/SelectLSProvider.tsx | 107 +++++ .../stake-v2/components/SelectSortBySheet.tsx | 55 +++ .../components/SelectValidatorCard.tsx | 81 ++++ .../components/SelectValidatorSheet.tsx | 217 ++++++++++ .../stake-v2/components/StakeAmountCard.tsx | 47 ++ .../stake-v2/components/StakeHeading.tsx | 38 ++ .../stake-v2/components/StakeRewardCard.tsx | 91 ++++ .../stake-v2/components/StakeSelectSheet.tsx | 146 +++++++ .../stake-v2/components/StakeStatusCard.tsx | 54 +++ .../components/StakingUnavailable.tsx | 135 ++++++ .../src/pages/stake-v2/components/TabList.tsx | 75 ++++ .../components/UnstakedValidatorDetails.tsx | 170 ++++++++ .../stake-v2/components/ValidatorList.tsx | 338 +++++++++++++++ .../pages/stake-v2/components/YouStake.tsx | 287 +++++++++++++ apps/extension/src/pages/stake-v2/index.tsx | 50 +++ apps/extension/src/pages/swaps-v2/index.tsx | 4 + apps/extension/src/theme/colors.tsx | 16 + apps/extension/tailwind.config.js | 10 + packages/wallet-hooks/package.json | 6 +- .../wallet-hooks/src/apis/LeapWalletApi.ts | 20 +- .../wallet-hooks/src/nfts/useTransferNFTs.ts | 13 +- .../wallet-hooks/src/send/useSimpleSend.ts | 5 +- packages/wallet-hooks/src/staking/index.ts | 1 + .../src/staking/useClaimAndStakeRewards.ts | 347 +++++++++++++++ .../src/staking/useFetchStakeValidators.ts | 12 +- .../wallet-hooks/src/staking/useStaking.ts | 4 +- .../src/staking/useStrideLiquidStaking.ts | 4 +- packages/wallet-hooks/src/store/index.ts | 1 + .../src/store/useChainsAprStore.ts | 34 ++ .../utils-hooks/use-fill-aggregated-stake.ts | 5 +- .../use-get-fee-market-gas-prices-steps.ts | 18 +- packages/wallet-hooks/src/utils/daysLeft.ts | 20 + .../src/utils/formatNewChainInfo.ts | 5 +- .../wallet-hooks/src/utils/get-chains-apr.ts | 34 ++ .../src/utils/getMetadataForTxn.ts | 4 +- packages/wallet-hooks/src/utils/index.ts | 4 + .../src/utils/useInitChainsApr.ts | 19 + .../src/utils/useLiquidStakingProviders.ts | 42 ++ packages/wallet-provider/package.json | 4 +- packages/wallet-sdk/package.json | 2 +- .../wallet-sdk/src/constants/chain-infos.ts | 2 +- packages/wallet-sdk/src/constants/denoms.ts | 2 +- packages/wallet-sdk/src/key/eth-sign.ts | 18 +- packages/wallet-sdk/src/tx/ethermint.ts | 19 + packages/wallet-sdk/src/tx/injectiveTx.ts | 103 ++--- packages/wallet-sdk/src/tx/msgs/cosmos.ts | 15 + packages/wallet-sdk/src/tx/msgs/index.ts | 2 + packages/wallet-sdk/src/tx/msgs/injective.ts | 165 +++++++ packages/wallet-sdk/src/tx/simulate.ts | 6 +- packages/wallet-sdk/src/tx/tx.ts | 22 + packages/wallet-sdk/src/tx/utils.ts | 18 + yarn.lock | 38 +- 103 files changed, 5706 insertions(+), 365 deletions(-) delete mode 100644 apps/extension/src/components/Header/NewChainSupportTooltip.tsx delete mode 100644 apps/extension/src/components/Header/PageHeader.tsx delete mode 100644 apps/extension/src/components/Header/index.ts create mode 100644 apps/extension/src/components/Skeletons/ValidatorListSkeleton.tsx create mode 100644 apps/extension/src/components/header/Header.tsx create mode 100644 apps/extension/src/images/stake/milkyway.png create mode 100644 apps/extension/src/pages/stake-v2/StakeInputPage.tsx create mode 100644 apps/extension/src/pages/stake-v2/StakePage.tsx create mode 100644 apps/extension/src/pages/stake-v2/StakeTxnPage.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/AutoAdjustModal.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/ClaimInfo.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/ComingSoonCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/InactiveValidatorCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/InsufficientBalanceCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/NotStakedCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/NotSupportedCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/PendingUnstakeList.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/ReviewCancelUnstakeTx.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/ReviewClaimAndStakeTx.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/ReviewClaimTx.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/ReviewStakeTx.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/SelectLSProvider.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/SelectSortBySheet.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/SelectValidatorCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/SelectValidatorSheet.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/StakeAmountCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/StakeHeading.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/StakeRewardCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/StakeSelectSheet.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/StakeStatusCard.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/StakingUnavailable.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/TabList.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/UnstakedValidatorDetails.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/ValidatorList.tsx create mode 100644 apps/extension/src/pages/stake-v2/components/YouStake.tsx create mode 100644 apps/extension/src/pages/stake-v2/index.tsx create mode 100644 packages/wallet-hooks/src/staking/useClaimAndStakeRewards.ts create mode 100644 packages/wallet-hooks/src/store/useChainsAprStore.ts create mode 100644 packages/wallet-hooks/src/utils/daysLeft.ts create mode 100644 packages/wallet-hooks/src/utils/get-chains-apr.ts create mode 100644 packages/wallet-hooks/src/utils/useInitChainsApr.ts create mode 100644 packages/wallet-hooks/src/utils/useLiquidStakingProviders.ts create mode 100644 packages/wallet-sdk/src/tx/msgs/injective.ts 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 0000000000000000000000000000000000000000..f23df7b091278367c5b16ccaf26a571fae8c99fc GIT binary patch literal 59251 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

i}&gJ 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 literal 0 HcmV?d00001 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"