diff --git a/apps/browser-extension-wallet/src/popup.tsx b/apps/browser-extension-wallet/src/popup.tsx index a47e54f452..6f9ca0fac8 100644 --- a/apps/browser-extension-wallet/src/popup.tsx +++ b/apps/browser-extension-wallet/src/popup.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import * as ReactDOM from 'react-dom'; import { HashRouter } from 'react-router-dom'; -import { PopupView } from '@routes'; +import { PopupView, walletRoutePaths } from '@routes'; import { StoreProvider } from '@stores'; import { CurrencyStoreProvider } from '@providers/currency'; import { AppSettingsProvider, DatabaseProvider, ThemeProvider, AnalyticsProvider } from '@providers'; @@ -34,8 +34,8 @@ const App = (): React.ReactElement => { const newModeValue = changes.BACKGROUND_STORAGE?.newValue?.namiMigration; if (oldModeValue?.mode !== newModeValue?.mode) { setMode(newModeValue); - // Force back to original routing - window.location.hash = '#'; + // Force back to original routing unless it is staking route (see LW-11876) + if (window.location.hash.split('#')[1] !== walletRoutePaths.earn) window.location.hash = '#'; } }); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/staking/hooks.ts b/apps/browser-extension-wallet/src/views/browser-view/features/staking/hooks.ts index fc86c1b7cc..8348862eb0 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/staking/hooks.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/staking/hooks.ts @@ -1,8 +1,17 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useDelegationStore } from '@src/features/delegation/stores'; import { useWalletStore } from '@stores'; import { withSignTxConfirmation } from '@lib/wallet-api-ui'; import { useSecrets } from '@lace/core'; +import { useObservable } from '@lace/common'; +import { Wallet } from '@lace/cardano'; + +interface UseRewardAccountsDataType { + accountsWithRegisteredStakeCredsWithoutVotingDelegation: Wallet.Cardano.RewardAccountInfo[]; + accountsWithRegisteredStakeCreds: Wallet.Cardano.RewardAccountInfo[]; + poolIdToRewardAccountMap: Map; + lockedStakeRewards: bigint; +} export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Promise } => { const { password, clearSecrets } = useSecrets(); @@ -17,3 +26,54 @@ export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Pr return { signAndSubmitTransaction }; }; + +export const getPoolIdToRewardAccountMap = ( + rewardAccounts: Wallet.Cardano.RewardAccountInfo[] +): UseRewardAccountsDataType['poolIdToRewardAccountMap'] => + new Map( + rewardAccounts + ?.map((rewardAccount): [string, Wallet.Cardano.RewardAccountInfo] => { + const { delegatee } = rewardAccount; + const delagationInfo = delegatee?.nextNextEpoch || delegatee?.nextEpoch || delegatee?.currentEpoch; + + return [delagationInfo?.id.toString(), rewardAccount]; + }) + .filter(([poolId]) => !!poolId) + ); + +export const useRewardAccountsData = (): UseRewardAccountsDataType => { + const { inMemoryWallet } = useWalletStore(); + const rewardAccounts = useObservable(inMemoryWallet.delegation.rewardAccounts$); + const accountsWithRegisteredStakeCreds = rewardAccounts?.filter( + ({ credentialStatus }) => Wallet.Cardano.StakeCredentialStatus.Registered === credentialStatus + ); + + const accountsWithRegisteredStakeCredsWithoutVotingDelegation = useMemo( + () => accountsWithRegisteredStakeCreds?.filter(({ dRepDelegatee }) => !dRepDelegatee), + [accountsWithRegisteredStakeCreds] + ); + + const lockedStakeRewards = useMemo( + () => + BigInt( + accountsWithRegisteredStakeCredsWithoutVotingDelegation + ? Wallet.BigIntMath.sum( + accountsWithRegisteredStakeCredsWithoutVotingDelegation.map(({ rewardBalance }) => rewardBalance) + ) + : 0 + ), + [accountsWithRegisteredStakeCredsWithoutVotingDelegation] + ); + + const poolIdToRewardAccountMap = useMemo( + () => getPoolIdToRewardAccountMap(accountsWithRegisteredStakeCreds), + [accountsWithRegisteredStakeCreds] + ); + + return { + accountsWithRegisteredStakeCreds, + accountsWithRegisteredStakeCredsWithoutVotingDelegation, + lockedStakeRewards, + poolIdToRewardAccountMap + }; +}; diff --git a/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx b/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx index 2a533494f2..38e95a5fcf 100644 --- a/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx +++ b/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx @@ -18,7 +18,8 @@ import { useWalletManager, useBuildDelegation, useBalances, - useHandleResolver + useHandleResolver, + useRedirection } from '@hooks'; import { walletManager, withSignTxConfirmation } from '@lib/wallet-api-ui'; import { useAnalytics } from './hooks'; @@ -26,7 +27,7 @@ import { useDappContext, withDappContext } from '@src/features/dapp/context'; import { localDappService } from '../browser-view/features/dapp/components/DappList/localDappService'; import { isValidURL } from '@src/utils/is-valid-url'; import { CARDANO_COIN_SYMBOL } from './constants'; -import { useDelegationTransaction } from '../browser-view/features/staking/hooks'; +import { useDelegationTransaction, useRewardAccountsData } from '../browser-view/features/staking/hooks'; import { useSecrets } from '@lace/core'; import { useDelegationStore } from '@src/features/delegation/stores'; import { useStakePoolDetails } from '@src/features/stake-pool-details/store'; @@ -42,6 +43,7 @@ import { isKeyHashAddress } from '@cardano-sdk/wallet'; import { BackgroundStorage } from '@lib/scripts/types'; import { getWalletAccountsQtyString } from '@src/utils/get-wallet-count-string'; import { useNetworkError } from '@hooks/useNetworkError'; +import { walletRoutePaths } from '@routes'; const { AVAILABLE_CHAINS, DEFAULT_SUBMIT_API } = config(); @@ -177,6 +179,10 @@ export const NamiView = withDappContext((): React.ReactElement => { setDeletingWallet(false); }, [analytics, deleteWallet, setDeletingWallet, walletRepository]); + const { lockedStakeRewards } = useRewardAccountsData(); + + const redirectToStaking = useRedirection(walletRoutePaths.earn); + return ( { setDeletingWallet, chainHistoryProvider, protocolParameters: walletState?.protocolParameters, - assetInfo: walletState?.assetInfo + assetInfo: walletState?.assetInfo, + lockedStakeRewards, + redirectToStaking }} > void; } diff --git a/packages/nami/src/ui/app/components/upgradeToLaceBanner.tsx b/packages/nami/src/ui/app/components/upgradeToLaceBanner.tsx new file mode 100644 index 0000000000..77ea83cdbd --- /dev/null +++ b/packages/nami/src/ui/app/components/upgradeToLaceBanner.tsx @@ -0,0 +1,86 @@ +import React from 'react'; + +import { Box, Button, Text, Link, useColorModeValue } from '@chakra-ui/react'; +import { AnimatePresence, motion } from 'framer-motion'; + +import { useOutsideHandles } from '../../../features/outside-handles-provider'; + +export const UpgradeToLaceBanner = ({ + showSwitchToLaceBanner, +}: Readonly<{ showSwitchToLaceBanner: boolean }>) => { + const warningBackground = useColorModeValue('#fcf5e3', '#fcf5e3'); + const { openExternalLink, switchWalletMode, redirectToStaking } = + useOutsideHandles(); + + return ( + + {showSwitchToLaceBanner && ( + + + + Your ADA balance includes Locked Stake Rewards that can only be + withdrawn or transacted after registering your voting power. + Upgrade to Lace to continue. For more information, visit our{' '} + { + openExternalLink('https://www.lace.io/faq'); + }} + > + FAQs. + + + + + + )} + + ); +}; diff --git a/packages/nami/src/ui/app/pages/send.tsx b/packages/nami/src/ui/app/pages/send.tsx index 4d69c8182b..f0d2393b9e 100644 --- a/packages/nami/src/ui/app/pages/send.tsx +++ b/packages/nami/src/ui/app/pages/send.tsx @@ -7,7 +7,7 @@ /* eslint-disable unicorn/no-null */ /* eslint-disable @typescript-eslint/naming-convention */ import type { RefObject } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { Cardano, Serialization, ProviderUtil } from '@cardano-sdk/core'; import { @@ -41,7 +41,7 @@ import { } from '@chakra-ui/react'; import { useObservable } from '@lace/common'; import debouncePromise from 'debounce-promise'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; import latest from 'promise-latest'; import { MdModeEdit } from 'react-icons/md'; import { Planet } from 'react-kawaii'; @@ -68,6 +68,10 @@ import { NetworkConnectionStates, useCommonOutsideHandles, } from '../../../features/common-outside-handles-provider'; +import { + useOutsideHandles, + type OutsideHandlesContextValue, +} from '../../../features/outside-handles-provider'; import { useStoreActions, useStoreState } from '../../store'; import Account from '../components/account'; import AssetBadge from '../components/assetBadge'; @@ -77,9 +81,9 @@ import ConfirmModal from '../components/confirmModal'; import Copy from '../components/copy'; import { Scrollbars } from '../components/scrollbar'; import UnitDisplay from '../components/unitDisplay'; +import { UpgradeToLaceBanner } from '../components/upgradeToLaceBanner'; import type { UseAccount } from '../../../adapters/account'; -import type { OutsideHandlesContextValue } from '../../../features/outside-handles-provider'; import type { Asset as NamiAsset, AssetInput } from '../../../types/assets'; import type { AssetsModalRef } from '../components/assetsModal'; import type { ConfirmModalRef } from '../components/confirmModal'; @@ -126,6 +130,8 @@ const Send = ({ const isMounted = useIsMounted(); const { cardanoCoin, walletType, openHWFlow, networkConnection } = useCommonOutsideHandles(); + const { lockedStakeRewards } = useOutsideHandles(); + const [showSwitchToLaceBanner, setShowSwitchToLaceBanner] = useState(false); const [address, setAddress] = [ useStoreState(state => state.globalModel.sendStore.address), useStoreActions(actions => actions.globalModel.sendStore.setAddress), @@ -227,7 +233,7 @@ const Send = ({ setFee({ fee: '' }); setTx(null); - await new Promise((res, rej) => + await new Promise(res => setTimeout(() => { res(null); }), @@ -436,15 +442,35 @@ const Send = ({ const setMessageDebounced = useMemo(() => debounce(setMessage, 300), []); const isOffline = networkConnection === NetworkConnectionStates.OFFLINE; + const setShowSwitchToLaceBannerDebounced = useMemo( + () => debounce(setShowSwitchToLaceBanner, 300), + [], + ); + + const reachedMaxAdaAmount = useMemo( + () => + BigInt(toUnit(value.ada)) > + BigInt(BigInt(utxoTotal?.coins || 0) + BigInt(rewards || 0) || '0') - + BigInt(lockedStakeRewards.toString()), + [value.ada, utxoTotal?.coins, rewards, lockedStakeRewards], + ); + + useEffect(() => { + if (!lockedStakeRewards) return; + + setShowSwitchToLaceBannerDebounced(reachedMaxAdaAmount); + }, [lockedStakeRewards, reachedMaxAdaAmount]); + return ( <> {protocolParameters && isLoading ? ( + {!!lockedStakeRewards && ( + + )} - BigInt( - BigInt(utxoTotal?.coins || 0) + - BigInt(rewards || 0) || '0', - )) + reachedMaxAdaAmount) } onFocus={() => (focus.current = true)} placeholder="0.000000" @@ -599,38 +627,43 @@ const Send = ({ - - 0 && ( + - {value.assets.map(asset => ( - - - - ))} - - + + {value.assets.map(asset => ( + + + + ))} + + + )}