diff --git a/apps/browser-extension-wallet/src/popup.tsx b/apps/browser-extension-wallet/src/popup.tsx index a47e54f45..6f9ca0fac 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/__tests__/hooks.test.ts b/apps/browser-extension-wallet/src/views/browser-view/features/staking/__tests__/hooks.test.ts new file mode 100644 index 000000000..a00539fc3 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/staking/__tests__/hooks.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable no-magic-numbers */ +/* eslint-disable import/imports-first */ +const mockUseWalletStore = jest.fn(); +import { renderHook } from '@testing-library/react-hooks'; +import { useRewardAccountsData } from '../hooks'; +import { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; +import * as Stores from '@src/stores'; +import { Wallet } from '@lace/cardano'; + +const rewardAccounts$ = new BehaviorSubject([]); + +const inMemoryWallet = { + delegation: { + rewardAccounts$ + } +}; + +jest.mock('@src/stores', (): typeof Stores => ({ + ...jest.requireActual('@src/stores'), + useWalletStore: mockUseWalletStore +})); + +describe('Testing useRewardAccountsData hook', () => { + test('should return proper rewards accounts hook state', async () => { + mockUseWalletStore.mockReset(); + mockUseWalletStore.mockImplementation(() => ({ + inMemoryWallet + })); + + const hook = renderHook(() => useRewardAccountsData()); + expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false); + expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map()); + expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0)); + + act(() => { + rewardAccounts$.next([{ credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered }]); + }); + expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false); + expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map()); + expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0)); + + act(() => { + rewardAccounts$.next([ + { credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered, rewardBalance: BigInt(0) } + ]); + }); + expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(true); + expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map()); + expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0)); + + act(() => { + rewardAccounts$.next([ + { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered, + dRepDelegatee: { delegateRepresentative: { active: true } }, + rewardBalance: BigInt(1_000_000) + } + ]); + }); + expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false); + expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map()); + expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(0)); + + act(() => { + rewardAccounts$.next([ + { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered, + rewardBalance: BigInt(1_000_000) + }, + { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered, + dRepDelegatee: { delegateRepresentative: { active: false } }, + rewardBalance: BigInt(1_000_000) + }, + { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Unregistered, + dRepDelegatee: { delegateRepresentative: { active: true } }, + rewardBalance: BigInt(1_000_000) + }, + { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered, + rewardBalance: BigInt(1_000_000) + }, + { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered, + dRepDelegatee: { delegateRepresentative: { active: true } }, + rewardBalance: BigInt(1_000_000) + }, + { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered, + dRepDelegatee: { delegateRepresentative: { active: false } }, + rewardBalance: BigInt(1_000_000) + } + ]); + }); + expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false); + expect(hook.result.current.poolIdToRewardAccountsMap).toEqual(new Map()); + expect(hook.result.current.lockedStakeRewards).toEqual(BigInt(2_000_000)); + + const poolId = 'poolId'; + const rewardAccount = { + credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered, + dRepDelegatee: { delegateRepresentative: {} }, + delegatee: { nextNextEpoch: { id: poolId } }, + rewardBalance: BigInt(0) + }; + act(() => { + rewardAccounts$.next([rewardAccount]); + }); + expect(hook.result.current.areAllRegisteredStakeKeysWithoutVotingDelegation).toEqual(false); + expect(hook.result.current.poolIdToRewardAccountsMap.size).toEqual(1); + expect(hook.result.current.poolIdToRewardAccountsMap.get(poolId)).toEqual([rewardAccount]); + }); +}); 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 fc86c1b7c..7e7eb06e2 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'; +import groupBy from 'lodash/groupBy'; + +interface UseRewardAccountsDataType { + areAllRegisteredStakeKeysWithoutVotingDelegation: boolean; + poolIdToRewardAccountsMap: Map; + lockedStakeRewards: bigint; +} export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Promise } => { const { password, clearSecrets } = useSecrets(); @@ -17,3 +26,69 @@ export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Pr return { signAndSubmitTransaction }; }; + +export const getPoolIdToRewardAccountsMap = ( + rewardAccounts: Wallet.Cardano.RewardAccountInfo[] +): UseRewardAccountsDataType['poolIdToRewardAccountsMap'] => + new Map( + Object.entries( + groupBy(rewardAccounts, ({ delegatee }) => { + const delagationInfo = delegatee?.nextNextEpoch || delegatee?.nextEpoch || delegatee?.currentEpoch; + return delagationInfo?.id.toString() ?? ''; + }) + ).filter(([poolId]) => !!poolId) + ); + +export const useRewardAccountsData = (): UseRewardAccountsDataType => { + const { inMemoryWallet } = useWalletStore(); + const rewardAccounts = useObservable(inMemoryWallet.delegation.rewardAccounts$); + const accountsWithRegisteredStakeCreds = useMemo( + () => + rewardAccounts?.filter( + ({ credentialStatus }) => Wallet.Cardano.StakeCredentialStatus.Registered === credentialStatus + ) ?? [], + [rewardAccounts] + ); + + const areAllRegisteredStakeKeysWithoutVotingDelegation = useMemo( + () => + accountsWithRegisteredStakeCreds.length > 0 && + !accountsWithRegisteredStakeCreds.some(({ dRepDelegatee }) => dRepDelegatee), + [accountsWithRegisteredStakeCreds] + ); + + const accountsWithRegisteredStakeCredsWithoutVotingDelegation = useMemo( + () => + accountsWithRegisteredStakeCreds.filter( + ({ dRepDelegatee }) => + !dRepDelegatee || + (dRepDelegatee && + 'active' in dRepDelegatee.delegateRepresentative && + !dRepDelegatee.delegateRepresentative.active) + ), + [accountsWithRegisteredStakeCreds] + ); + + const lockedStakeRewards = useMemo( + () => + BigInt( + accountsWithRegisteredStakeCredsWithoutVotingDelegation + ? Wallet.BigIntMath.sum( + accountsWithRegisteredStakeCredsWithoutVotingDelegation.map(({ rewardBalance }) => rewardBalance) + ) + : 0 + ), + [accountsWithRegisteredStakeCredsWithoutVotingDelegation] + ); + + const poolIdToRewardAccountsMap = useMemo( + () => getPoolIdToRewardAccountsMap(accountsWithRegisteredStakeCreds), + [accountsWithRegisteredStakeCreds] + ); + + return { + areAllRegisteredStakeKeysWithoutVotingDelegation, + lockedStakeRewards, + poolIdToRewardAccountsMap + }; +}; 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 2e0e32d69..a029ff225 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'; @@ -43,6 +44,7 @@ import { BackgroundStorage } from '@lib/scripts/types'; import { getWalletAccountsQtyString } from '@src/utils/get-wallet-count-string'; import { useNetworkError } from '@hooks/useNetworkError'; import { createHistoricalOwnInputResolver } from '@src/utils/own-input-resolver'; +import { walletRoutePaths } from '@routes'; const { AVAILABLE_CHAINS, DEFAULT_SUBMIT_API } = config(); @@ -179,6 +181,10 @@ export const NamiView = withDappContext((): React.ReactElement => { setDeletingWallet(false); }, [analytics, deleteWallet, setDeletingWallet, walletRepository]); + const { lockedStakeRewards } = useRewardAccountsData(); + + const redirectToStaking = useRedirection(walletRoutePaths.earn); + return ( { chainHistoryProvider, protocolParameters: walletState?.protocolParameters, assetInfo: walletState?.assetInfo, - createHistoricalOwnInputResolver + createHistoricalOwnInputResolver, + lockedStakeRewards, + redirectToStaking }} > , ) => Wallet.Cardano.InputResolver; + lockedStakeRewards: bigint; + 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 000000000..77ea83cdb --- /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 abb4fb344..c2f039222 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,12 +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 { - OutsideHandlesContextValue, - useOutsideHandles, -} 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'; @@ -132,6 +133,8 @@ const Send = ({ const { secretsUtil } = useOutsideHandles(); + const { lockedStakeRewards } = useOutsideHandles(); + const [showSwitchToLaceBanner, setShowSwitchToLaceBanner] = useState(false); const [address, setAddress] = [ useStoreState(state => state.globalModel.sendStore.address), useStoreActions(actions => actions.globalModel.sendStore.setAddress), @@ -233,7 +236,7 @@ const Send = ({ setFee({ fee: '' }); setTx(null); - await new Promise((res, rej) => + await new Promise(res => setTimeout(() => { res(null); }), @@ -442,15 +445,34 @@ const Send = ({ const setMessageDebounced = useMemo(() => debounce(setMessage, 300), []); const isOffline = networkConnection === NetworkConnectionStates.OFFLINE; + const setShowSwitchToLaceBannerDebounced = useConstant(() => + debounce(setShowSwitchToLaceBanner, 500), + ); + + 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(() => { + setShowSwitchToLaceBannerDebounced( + !!lockedStakeRewards && reachedMaxAdaAmount, + ); + }, [lockedStakeRewards, reachedMaxAdaAmount]); + return ( <> {protocolParameters && isLoading ? ( + {!!lockedStakeRewards && ( + + )} - BigInt( - BigInt(utxoTotal?.coins || 0) + - BigInt(rewards || 0) || '0', - )) + reachedMaxAdaAmount) } onFocus={() => (focus.current = true)} placeholder="0.000000" @@ -605,38 +629,43 @@ const Send = ({ - - 0 && ( + - {value.assets.map(asset => ( - - - - ))} - - + + {value.assets.map(asset => ( + + + + ))} + + + )}