diff --git a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts index a4b3dfbd5..72269dd4a 100644 --- a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts +++ b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts @@ -16,7 +16,8 @@ export enum COIN_SELECTION_ERRORS { NOT_FRAGMENTED_ENOUGH_ERROR = 'UTxO Not Fragmented Enough', FULLY_DEPLETED_ERROR = 'UTxO Fully Depleted', MAXIMUM_INPUT_COUNT_EXCEEDED_ERROR = 'Maximum Input Count Exceeded', - BUNDLE_AMOUNT_IS_EMPTY = 'Bundle amount is empty' + BUNDLE_AMOUNT_IS_EMPTY = 'Bundle amount is empty', + AVAILABLE_BALANCE_INSUFFICIENT_ERROR = 'Amount is less than the total balance, but more than the available balance' } export const coinSelectionErrors = new Map([ @@ -24,7 +25,8 @@ export const coinSelectionErrors = new Map; assets: Map; setIsBundle: (value: boolean) => void; @@ -31,6 +32,7 @@ export const BundlesList = ({ isBundle, setIsBundle, insufficientBalanceInputs, + insufficientAvailableBalanceInputs, reachedMaxAmountList, assets, assetBalances, @@ -83,11 +85,13 @@ export const BundlesList = ({ )} handleAssetPicker(bundleId)} openAssetPicker={(coinId) => handleAssetPicker(bundleId, coinId)} canAddMoreAssets={canAddMoreAssets(bundleId)} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.module.scss b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.module.scss deleted file mode 100644 index aadcb1f42..000000000 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.module.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import '../../../../../../../../../packages/common/src/ui/styles/theme.scss'; - -.errorParagraph { - color: var(--data-red); - font-size: var(--bodyXS); - font-style: normal; - font-weight: 400; - line-height: size_unit(2); - letter-spacing: 0.02em; - text-align: left; -} - -.coinInputContainer { - flex: 1; -} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx index 62463e93c..1d8bc97fe 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx @@ -5,6 +5,8 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { getTemporaryTxDataFromStorage } from '../../../helpers'; import { UseSelectedCoinsProps, useSelectedCoins } from './useSelectedCoins'; +import { useRewardAccountsData } from '@src/views/browser-view/features/staking/hooks'; +import { LockedStakeRewardsBanner } from '../LockedStakeRewardsBanner/LockedStakeRewardsBanner'; export type CoinInputProps = { bundleId: string; @@ -12,6 +14,7 @@ export type CoinInputProps = { canAddMoreAssets?: boolean; onAddAsset?: () => void; spendableCoin: bigint; + isPopupView?: boolean; } & Omit; export const CoinInput = ({ @@ -20,11 +23,13 @@ export const CoinInput = ({ onAddAsset, canAddMoreAssets, spendableCoin, + isPopupView, ...selectedCoinsProps }: CoinInputProps): React.ReactElement => { const { t } = useTranslation(); const { setCoinValues } = useCoinStateSelector(bundleId); const { selectedCoins } = useSelectedCoins({ bundleId, assetBalances, spendableCoin, ...selectedCoinsProps }); + const { lockedStakeRewards } = useRewardAccountsData(); useEffect(() => { const { tempOutputs } = getTemporaryTxDataFromStorage(); @@ -33,11 +38,15 @@ export const CoinInput = ({ }, [bundleId, setCoinValues]); return ( - + <> + + {!!lockedStakeRewards && } + ); }; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts index 9aee39a5b..752fd8884 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-magic-numbers */ const mockCoinStateSelector = { uiOutputs: [], @@ -10,6 +11,7 @@ const mockUseCurrencyStore = jest.fn().mockReturnValue({ fiatCurrency: { code: ' const mockUseWalletStore = jest.fn().mockReturnValue({ walletUI: { cardanoCoin: { id: '1', name: 'Cardano', decimals: 6, symbol: 'ADA' }, appMode: 'popup' } }); +const mockUseRewardAccountsData = jest.fn().mockReturnValue({ lockedStakeRewards: 0 }); const mockUseCoinStateSelector = jest.fn().mockReturnValue(mockCoinStateSelector); const mockUseBuiltTxState = jest.fn().mockReturnValue({ builtTxData: { error: undefined } }); const mockUseAddressState = jest.fn().mockReturnValue({ address: undefined }); @@ -41,6 +43,12 @@ jest.mock('@stores', (): typeof Stores => ({ ...jest.requireActual('@stores'), useWalletStore: mockUseWalletStore })); + +jest.mock('@src/views/browser-view/features/staking/hooks', () => ({ + ...jest.requireActual('@src/views/browser-view/features/staking/hooks'), + useRewardAccountsData: mockUseRewardAccountsData +})); + jest.mock('../../../../store', (): typeof SendTransactionStore => ({ ...jest.requireActual('../../../../store'), useCoinStateSelector: mockUseCoinStateSelector, @@ -191,6 +199,33 @@ describe('useSelectedCoin', () => { expect(result.current.selectedCoins).toHaveLength(1); expect(result.current.selectedCoins[0].coin).toEqual({ id: '1', ticker: 'ADA', balance: 'Balance: 1.00M' }); }); + test('gets coin properties from walletUI cardanoCoin in store with compacts coin balance and locked rewards', () => { + mockUseRewardAccountsData.mockReturnValueOnce({ + lockedStakeRewards: '10000000000' + }); + + mockUseCoinStateSelector.mockReturnValueOnce({ + ...mockCoinStateSelector, + uiOutputs: [{ id: '1', value: '100' }] + }); + const props: UseSelectedCoinsProps = { + assetBalances: new Map(), + assets: new Map(), + bundleId: 'bundleId', + coinBalance: '1010000000000', + spendableCoin: BigInt(100) + }; + const { result } = renderUseSelectedCoins(props); + + expect(result.current.selectedCoins).toHaveLength(1); + expect(result.current.selectedCoins[0].coin).toEqual({ + id: '1', + ticker: 'ADA', + balance: 'Balance: 1.01M', + availableBalance: 'Available Balance: 1.00M', + lockedStakeRewards: 'Locked Stake Rewards: 10,000.00' + }); + }); test('converts coin value to fiat and set decimals from walletUI cardanoCoin', () => { mockUseCoinStateSelector.mockReturnValueOnce({ diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/util.test.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/util.test.ts index 64c5e159b..356f82f97 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/util.test.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/util.test.ts @@ -30,48 +30,63 @@ describe('CoinInput util', () => { describe('getADACoinProperties', () => { test('returns 0 for availableADA and false for hasMaxBtn when balance is 0', () => { - expect(getADACoinProperties('0', '1000000', '0', '0')).toEqual({ + expect(getADACoinProperties('0', '1000000', '0', '0', '0')).toEqual({ availableADA: '0.00', max: '1', hasMaxBtn: false, hasReachedMaxAmount: false, - allowFloat: true + allowFloat: true, + hasReachedMaxAvailableAmount: true, + lockedStakeRewards: '0.00', + totalADA: '0.00' }); }); test('returns 0 for max and true for hasReachedMaxAmount when spendable coins is 0', () => { - expect(getADACoinProperties('1000000', '0', '0', '0')).toEqual({ + expect(getADACoinProperties('1000000', '0', '0', '0', '0')).toEqual({ availableADA: '1.00', max: '0', hasMaxBtn: true, hasReachedMaxAmount: true, - allowFloat: true + allowFloat: true, + hasReachedMaxAvailableAmount: false, + lockedStakeRewards: '0.00', + totalADA: '1.00' }); }); test('returns formatted balance as availableADA, and the spendable coin in ADA as max when there is no spending', () => { - expect(getADACoinProperties('20000000', '10000000', '0', '0')).toEqual({ + expect(getADACoinProperties('20000000', '10000000', '0', '0', '0')).toEqual({ availableADA: '20.00', max: '10', hasMaxBtn: true, hasReachedMaxAmount: false, - allowFloat: true + allowFloat: true, + hasReachedMaxAvailableAmount: false, + lockedStakeRewards: '0.00', + totalADA: '20.00' }); }); test('returns the calculated max amount when there is less spent coin than spendable coin', () => { - expect(getADACoinProperties('20000000', '10000000', '5', '2')).toEqual({ + expect(getADACoinProperties('20000000', '10000000', '5', '2', '0')).toEqual({ availableADA: '20.00', max: '7', hasMaxBtn: true, hasReachedMaxAmount: false, - allowFloat: true + allowFloat: true, + hasReachedMaxAvailableAmount: false, + lockedStakeRewards: '0.00', + totalADA: '20.00' }); }); test('returns max amount as 0 and hasReachedMaxAmount as true when there is more spent coin than spendable coin', () => { - expect(getADACoinProperties('20000000', '10000000', '10', '0')).toEqual({ + expect(getADACoinProperties('20000000', '10000000', '10', '0', '0')).toEqual({ availableADA: '20.00', max: '0', hasMaxBtn: true, hasReachedMaxAmount: true, - allowFloat: true + allowFloat: true, + hasReachedMaxAvailableAmount: false, + lockedStakeRewards: '0.00', + totalADA: '20.00' }); }); }); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx index 501aa3f81..8271f8d93 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx @@ -24,6 +24,7 @@ import { APP_MODE_POPUP } from '@src/utils/constants'; import { useCallback } from 'react'; import { AssetInfo } from '../../../types'; import { MAX_NFT_TICKER_LENGTH, MAX_TOKEN_TICKER_LENGTH } from '../../../constants'; +import { useRewardAccountsData } from '@src/views/browser-view/features/staking/hooks'; interface InputFieldActionParams { id: string; @@ -38,6 +39,7 @@ export interface UseSelectedCoinsProps { /** Coin balance (ADA) in lovelace */ coinBalance: string; insufficientBalanceInputs?: Array; + insufficientAvailableBalanceInputs?: Array; openAssetPicker?: (id: string) => void; spendableCoin: bigint; } @@ -54,6 +56,7 @@ export const useSelectedCoins = ({ assets, coinBalance, insufficientBalanceInputs, + insufficientAvailableBalanceInputs, openAssetPicker, bundleId, spendableCoin @@ -76,6 +79,8 @@ export const useSelectedCoins = ({ // This is used to focus on the last asset after the changes in the bundle const [bundleIdOfLastAddedCoin] = useCurrentRow(); + const rewardAcountsData = useRewardAccountsData(); + /** * Displays the error with highest priority. Tx level error > Asset level error > Bundle level error */ @@ -88,6 +93,10 @@ export const useSelectedCoins = ({ if (assetInput?.value && assetInput.value !== '0' && !!insufficientBalanceInputs?.includes(inputId)) { return COIN_SELECTION_ERRORS.BALANCE_INSUFFICIENT_ERROR; } + // If the asset has an input value but it is less than the total balance, but more than the available balance, then display an insufficient available balance error. + if (assetInput?.value && assetInput.value !== '0' && !!insufficientAvailableBalanceInputs?.includes(inputId)) { + return COIN_SELECTION_ERRORS.AVAILABLE_BALANCE_INSUFFICIENT_ERROR; + } // If there is a valid address but all coins have 0 as value or it's missing, then display a bundle empty error if (address && isValidAddress(address) && assetInputList.every((item) => !(item.value && Number(item.value)))) { return COIN_SELECTION_ERRORS.BUNDLE_AMOUNT_IS_EMPTY; @@ -95,7 +104,7 @@ export const useSelectedCoins = ({ // eslint-disable-next-line consistent-return return undefined; }, - [address, assetInputList, builtTxError, insufficientBalanceInputs] + [address, assetInputList, builtTxError, insufficientBalanceInputs, insufficientAvailableBalanceInputs] ); const handleOnChangeCoin = useCallback( @@ -172,11 +181,19 @@ export const useSelectedCoins = ({ // Asset is cardano coin if (assetInputItem.id === cardanoCoin.id) { - const { availableADA, hasReachedMaxAmount, ...adaCoinProps } = getADACoinProperties( + const { + totalADA, + availableADA, + lockedStakeRewards, + hasReachedMaxAmount, + hasReachedMaxAvailableAmount, + ...adaCoinProps + } = getADACoinProperties( coinBalance, spendableCoin?.toString(), tokensUsed[cardanoCoin.id] || '0', - assetInputItem?.value || '0' + assetInputItem?.value || '0', + rewardAcountsData.lockedStakeRewards.toString() || '0' ); const fiatValue = Wallet.util.convertAdaToFiat({ ada: assetInputItem?.value || '0', @@ -186,10 +203,20 @@ export const useSelectedCoins = ({ ...commonCoinProps, ...adaCoinProps, hasReachedMaxAmount: hasReachedMaxAmount && error !== COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR, + hasReachedMaxAvailableAmount: + hasReachedMaxAvailableAmount && error !== COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR, coin: { id: cardanoCoin.id, ticker: cardanoCoin.symbol, - balance: t('send.balanceAmount', { amount: compactNumberWithUnit(availableADA) }) + balance: t('send.balanceAmount', { amount: compactNumberWithUnit(totalADA) }), + ...(rewardAcountsData.lockedStakeRewards && { + availableBalance: t('send.availableBalanceAmount', { + amount: compactNumberWithUnit(availableADA) + }), + lockedStakeRewards: t('send.lockedStakeRewardsAmount', { + amount: compactNumberWithUnit(lockedStakeRewards.toString()) + }) + }) }, formattedFiatValue: `= ${compactNumberWithUnit(fiatValue)} ${fiatCurrency?.code}`, fiatValue: `= ${fiatValue} ${fiatCurrency?.code}`, diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts index 563a7d363..e05894aeb 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts @@ -28,7 +28,10 @@ export const getMaxSpendableAmount = (totalSpendableBalance = '0', totalSpent = }; type ADARow = { + totalADA: string; availableADA: string; + lockedStakeRewards: string; + hasReachedMaxAvailableAmount: boolean; } & Pick; /** @@ -38,24 +41,30 @@ type ADARow = { * @param spendableCoin The amount of coins that can be spent in lovelaces. * @param spentCoins The total amount of coins spent in ADA (including current input). * @param currentSpendingAmount The amount entered in the current input in ADA. + * @param lockedStakeRewards Locked Stake Rewards that cannot be withdrawn. */ export const getADACoinProperties = ( balance: string, spendableCoin: string, spentCoins: string, - currentSpendingAmount: string + currentSpendingAmount: string, + lockedStakeRewards: string ): ADARow => { // Convert to ADA - const availableADA = Wallet.util.lovelacesToAdaString(balance); + const totalADA = Wallet.util.lovelacesToAdaString(balance); + const availableADA = Wallet.util.lovelacesToAdaString(new BigNumber(balance).minus(lockedStakeRewards).toString()); const spendableCoinInAda = Wallet.util.lovelacesToAdaString(spendableCoin, undefined, BigNumber.ROUND_DOWN); // Calculate max amount in ADA const max = getMaxSpendableAmount(spendableCoinInAda, spentCoins, currentSpendingAmount); return { + totalADA, availableADA, + lockedStakeRewards: Wallet.util.lovelacesToAdaString(lockedStakeRewards.toString()), max, allowFloat: true, - hasMaxBtn: Number(availableADA) > 0, - hasReachedMaxAmount: new BigNumber(spentCoins).gte(spendableCoinInAda) + hasMaxBtn: Number(totalADA) > 0, + hasReachedMaxAmount: new BigNumber(spentCoins).gte(spendableCoinInAda), + hasReachedMaxAvailableAmount: lockedStakeRewards && new BigNumber(spentCoins).gte(availableADA) }; }; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx index 23efa5a98..6857a8948 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx @@ -12,12 +12,13 @@ import { useMaxAda } from '@hooks/useMaxAda'; import BundleIcon from '../../../../../../assets/icons/bundle-icon.component.svg'; import { getFee } from '../SendTransactionSummary'; import { useBuiltTxState, useSpentBalances, useLastFocusedInput, useOutputs } from '../../store'; -import { getReachedMaxAmountList } from '../../helpers'; +import { getNextInsufficientBalanceInputs, getReachedMaxAmountList, hasReachedMaxAmountAda } from '../../helpers'; import { MetadataInput } from './MetadataInput'; import { BundlesList } from './BundlesList'; import { formatAdaAllocation, getNextBundleCoinId } from './util'; import { Box, Text, WarningIconCircleComponent } from '@input-output-hk/lace-ui-toolkit'; import styles from './Form.module.scss'; +import { useRewardAccountsData } from '../../../staking/hooks'; export interface Props { assets: Map; @@ -48,9 +49,11 @@ export const Form = ({ const tokensUsed = useSpentBalances(); const spendableCoin = useMaxAda(); const [insufficientBalanceInputs, setInsufficientBalanceInputs] = useState([]); // we save all the element input ids with insufficient balance error + const [insufficientAvailableBalanceInputs, setInsufficientAvailableBalanceInputs] = useState([]); const { lastFocusedInput } = useLastFocusedInput(); const { fiatCurrency } = useCurrencyStore(); const availableRewards = useObservable(inMemoryWallet?.balance?.rewardAccounts?.rewards$); + const { lockedStakeRewards } = useRewardAccountsData(); const { setNewOutput } = useOutputs(); @@ -75,25 +78,29 @@ export const Form = ({ [assets, availableRewards, balance, cardanoCoin, tokensUsed] ); + const reachedMaxAvailableAmount = + balance?.coins && + hasReachedMaxAmountAda({ + tokensUsed, + balance: BigInt(balance?.coins.toString()) - BigInt(lockedStakeRewards.toString()), + cardanoCoin, + availableRewards + }); + useEffect(() => { if (lastFocusedInput) { const id = lastFocusedInput.split('.')[1]; // we get the coin id (cardano id or asset id) to check if it is in the reachedMaxAmountList - setInsufficientBalanceInputs((prevInputsList) => { - const isInMaxAmountList = reachedMaxAmountList.has(id); // check the if the id exists in reachedMaxAmountList - const isInInsufficientBalanceList = prevInputsList.includes(lastFocusedInput); // check the if input element id exists in insufficient balance list - - // check if the last focused element has insufficient balance and doesn't exists in insufficient balance list - if (isInMaxAmountList && !isInInsufficientBalanceList) { - return [...prevInputsList, lastFocusedInput]; // add it to the insufficient balance list - // check if the last focused element has balance and exists in insufficient balance list - } else if (!isInMaxAmountList && isInInsufficientBalanceList) { - return prevInputsList.filter((inputId) => inputId.split('.')[1] !== id); // remove all items with same coin id (cardano id or asset id) - } - - return prevInputsList; - }); + setInsufficientBalanceInputs(getNextInsufficientBalanceInputs(lastFocusedInput, reachedMaxAmountList, id)); + + setInsufficientAvailableBalanceInputs( + getNextInsufficientBalanceInputs( + lastFocusedInput, + new Set(reachedMaxAvailableAmount ? [cardanoCoin.id] : []), + id + ) + ); } - }, [reachedMaxAmountList, lastFocusedInput]); + }, [reachedMaxAmountList, lastFocusedInput, cardanoCoin.id, reachedMaxAvailableAmount]); const fee = uiTx?.fee?.toString() ?? '0'; const totalCost = getFee(fee.toString(), prices?.cardano?.price, cardanoCoin, fiatCurrency); @@ -133,6 +140,7 @@ export const Form = ({ isBundle={isBundle} setIsBundle={setIsBundle} insufficientBalanceInputs={insufficientBalanceInputs} + insufficientAvailableBalanceInputs={insufficientAvailableBalanceInputs} reachedMaxAmountList={reachedMaxAmountList} assets={assets} assetBalances={assetBalances} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/LockedStakeRewardsBanner/LockedStakeRewardsBanner.module.scss b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/LockedStakeRewardsBanner/LockedStakeRewardsBanner.module.scss new file mode 100644 index 000000000..2ac2917f3 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/LockedStakeRewardsBanner/LockedStakeRewardsBanner.module.scss @@ -0,0 +1,18 @@ +@import '../../../../../../../../../../packages/common/src/ui/styles/theme.scss'; + +.banner { + margin-bottom: 0px !important; + margin-top: 0px !important; + &.popupView { + margin-bottom: 0; + } + + .bannerDescription { + max-width: 315px; + } +} + +.bannerPopup { + border-radius: 16px; + background-color: var(--dark-mode-dark-grey-plus, var(--color-magnolia, #fcf5e3)); +} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/LockedStakeRewardsBanner/LockedStakeRewardsBanner.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/LockedStakeRewardsBanner/LockedStakeRewardsBanner.tsx new file mode 100644 index 000000000..1489fa279 --- /dev/null +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/LockedStakeRewardsBanner/LockedStakeRewardsBanner.tsx @@ -0,0 +1,48 @@ +import cn from 'classnames'; +import { useHistory } from 'react-router-dom'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Banner } from '@lace/common'; +import { walletRoutePaths } from '@routes'; +import styles from './LockedStakeRewardsBanner.module.scss'; +import { Box, Button, Flex, Text } from '@input-output-hk/lace-ui-toolkit'; +import { useDrawer } from '@src/views/browser-view/stores'; +import ExclamationCircleOutline from '@src/assets/icons/red-exclamation-circle.component.svg'; + +export type LockedStakeRewardsBannerProps = { + isPopupView?: boolean; +}; + +export const LockedStakeRewardsBanner = ({ isPopupView }: LockedStakeRewardsBannerProps): React.ReactElement => { + const { t } = useTranslation(); + const history = useHistory(); + const [, setIsDrawerVisible] = useDrawer(); + + const onGoToStaking = () => { + const path = isPopupView ? walletRoutePaths.earn : walletRoutePaths.staking; + setIsDrawerVisible(); + history.push(path); + }; + + return isPopupView ? ( + + {t('general.errors.lockedStakeRewards.description')} + + + ) : ( + } + popupView={isPopupView} + className={cn(styles.banner, { [styles.popupView]: isPopupView })} + message={{t('general.errors.lockedStakeRewards.description')}} + buttonMessage={t('general.errors.lockedStakeRewards.cta')} + onButtonClick={onGoToStaking} + withIcon + /> + ); +}; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/__tests__/Form.test.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/__tests__/Form.test.tsx index c525ea612..63fd19208 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/__tests__/Form.test.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/__tests__/Form.test.tsx @@ -15,6 +15,7 @@ import * as CurrencyProvider from '@providers/currency'; const mockUseOutputs = jest.fn(); const mockGetBackgroundStorage = jest.fn(); const mockUseMaxAda = jest.fn().mockReturnValue(BigInt(100)); +const mockUseRewardAccountsData = jest.fn().mockReturnValue({ lockedStakeRewards: 0 }); const mockUseAddressState = jest.fn((_row: string) => ({ address: '', handle: '', @@ -95,6 +96,11 @@ jest.mock('@providers/currency', (): typeof CurrencyProvider => ({ useCurrencyStore: mockUseCurrencyStore })); +jest.mock('@src/views/browser-view/features/staking/hooks', () => ({ + ...jest.requireActual('@src/views/browser-view/features/staking/hooks'), + useRewardAccountsData: mockUseRewardAccountsData +})); + const setNewOutput = jest.fn(); mockUseOutputs.mockReturnValue({ setNewOutput, @@ -122,16 +128,15 @@ const backgroundService = { const getWrapper = ({ backgroundService }: { backgroundService?: BackgroundServiceAPIProviderProps['value'] }) => - ({ children }: { children: React.ReactNode }) => - ( - - - - {children} - - - - ); + ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); const mockProps: Props = { assets: new Map(), diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts index 6a1889ad8..6b5a5384d 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts @@ -61,6 +61,42 @@ export const getOutputValues = (assets: Array, cardanoCoin: Wallet.Co }; }; +export const getNextInsufficientBalanceInputs = + (lastFocusedInput: string, reachedMaxAmountList: Set, id: string) => + (prevInputsList: string[]): string[] => { + const isInMaxAmountList = reachedMaxAmountList.has(id); // check the if the id exists in reachedMaxAmountList + const isInInsufficientBalanceList = prevInputsList.includes(lastFocusedInput); // check the if input element id exists in insufficient balance list + + // check if the last focused element has insufficient balance and doesn't exists in insufficient balance list + if (isInMaxAmountList && !isInInsufficientBalanceList) { + return [...prevInputsList, lastFocusedInput]; // add it to the insufficient balance list + // check if the last focused element has balance and exists in insufficient balance list + } else if (!isInMaxAmountList && isInInsufficientBalanceList) { + return prevInputsList.filter((inputId) => inputId.split('.')[1] !== id); // remove all items with same coin id (cardano id or asset id) + } + + return prevInputsList; + }; + +export const hasReachedMaxAmountAda = ({ + tokensUsed, + balance, + exceed = false, + cardanoCoin, + availableRewards = BigInt(0) +}: { + tokensUsed: SpentBalances; + balance: Wallet.Cardano.Lovelace; + exceed?: boolean; + cardanoCoin: Wallet.CoinId; + availableRewards?: bigint; +}): boolean => + tokensUsed[cardanoCoin.id] && balance + ? new BigNumber(tokensUsed[cardanoCoin.id])[exceed ? 'gt' : 'gte']( + Wallet.util.lovelacesToAdaString((BigInt(balance) + BigInt(availableRewards)).toString()) + ) + : false; + export const getReachedMaxAmountList = ({ assets = new Map(), tokensUsed, @@ -76,12 +112,12 @@ export const getReachedMaxAmountList = ({ cardanoCoin: Wallet.CoinId; availableRewards?: bigint; }): (string | Wallet.Cardano.AssetId)[] => { - const reachedMaxAmountAda = - tokensUsed[cardanoCoin.id] && balance?.coins - ? new BigNumber(tokensUsed[cardanoCoin.id])[exceed ? 'gt' : 'gte']( - Wallet.util.lovelacesToAdaString((balance.coins + availableRewards).toString()) - ) - : false; + const reachedMaxAmountAda = hasReachedMaxAmountAda({ + tokensUsed, + balance: balance?.coins, + cardanoCoin, + availableRewards + }); const reachedMaxAmountAssets = balance?.assets?.size ? [...balance.assets] 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..59d711886 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,10 @@ -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'; export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Promise } => { const { password, clearSecrets } = useSecrets(); @@ -17,3 +19,55 @@ export const useDelegationTransaction = (): { signAndSubmitTransaction: () => Pr return { signAndSubmitTransaction }; }; + +export const useRewardAccountsData = (): { + accountsWithRegisteredStakeCredsWithoutVotingDelegation: Wallet.Cardano.RewardAccountInfo[]; + accountsWithRegisteredStakeCreds: Wallet.Cardano.RewardAccountInfo[]; + poolIdToRewardAccountMap: Map; + lockedStakeRewards: BigInt; +} => { + 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( + () => + new Map( + accountsWithRegisteredStakeCreds + ?.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) + ), + [accountsWithRegisteredStakeCreds] + ); + + return { + accountsWithRegisteredStakeCreds, + accountsWithRegisteredStakeCredsWithoutVotingDelegation, + lockedStakeRewards, + poolIdToRewardAccountMap + }; +}; diff --git a/packages/core/src/ui/components/AssetInput/AssetInput.module.scss b/packages/core/src/ui/components/AssetInput/AssetInput.module.scss index 6a89f36cc..d59d56f34 100644 --- a/packages/core/src/ui/components/AssetInput/AssetInput.module.scss +++ b/packages/core/src/ui/components/AssetInput/AssetInput.module.scss @@ -5,12 +5,35 @@ .assetInputContainer { display: flex; flex-direction: column; - justify-content: space-between; + gap: 5px; max-height: 66px; height: 66px; @media (max-width: $breakpoint-popup) { max-height: 60px; + gap: 0px; + } + + &.withLockedRewards { + gap: 10px; + max-height: none; + height: auto; + + @media (max-width: $breakpoint-popup) { + gap: 4px; + max-height: none; + } + .balanceText { + &.fiatValue { + max-height: size_unit(3); + } + } + + .invalidInput { + @media (max-width: 365px) { + margin-bottom: size_unit(2); + } + } } } @@ -110,7 +133,6 @@ } .invalidInput { - margin-top: size_unit(1); color: var(--data-pink); font-size: var(--bodyXS); font-weight: 500; diff --git a/packages/core/src/ui/components/AssetInput/AssetInput.tsx b/packages/core/src/ui/components/AssetInput/AssetInput.tsx index 773f09ca1..969893e80 100644 --- a/packages/core/src/ui/components/AssetInput/AssetInput.tsx +++ b/packages/core/src/ui/components/AssetInput/AssetInput.tsx @@ -10,6 +10,7 @@ import { sanitizeNumber } from '@ui/utils/sanitize-number'; import { useTranslation } from 'react-i18next'; import cn from 'classnames'; import { CoreTranslationKey } from '@lace/translation'; +import { Flex, Text } from '@input-output-hk/lace-ui-toolkit'; const isSameNumberFormat = (num1: string, num2: string) => { if (!num1 || !num2) return false; @@ -21,7 +22,15 @@ const defaultInputWidth = 18; export interface AssetInputProps { inputId: string; - coin: { id: string; balance: string; src?: string; ticker?: string; shortTicker?: string }; + coin: { + id: string; + balance: string; + availableBalance?: string; + lockedStakeRewards?: string; + src?: string; + ticker?: string; + shortTicker?: string; + }; onChange: (args: { value: string; prevValue?: string; id: string; element?: any; maxDecimals?: number }) => void; onBlur?: (args: { value: string; id: string; maxDecimals?: number }) => void; onFocus?: (args: { value: string; id: string; maxDecimals?: number }) => void; @@ -39,11 +48,13 @@ export interface AssetInputProps { hasMaxBtn?: boolean; displayMaxBtn?: boolean; hasReachedMaxAmount?: boolean; + hasReachedMaxAvailableAmount?: boolean; focused?: boolean; onBlurErrors?: Set; getErrorMessage: (message: string) => CoreTranslationKey; setFocusInput?: (input?: string) => void; setFocus?: (focus: boolean) => void; + isPopupView?: boolean; } const placeholderValue = '0'; @@ -71,9 +82,11 @@ export const AssetInput = ({ hasMaxBtn = true, displayMaxBtn = false, hasReachedMaxAmount, + hasReachedMaxAvailableAmount, focused, setFocusInput, - setFocus + setFocus, + isPopupView }: AssetInputProps): React.ReactElement => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const inputRef = useRef(null); @@ -179,8 +192,19 @@ export const AssetInput = ({ const onBlurError = isTouched ? getErrorMessage(error) : undefined; const errorMessage = onBlurErrors?.has(error) ? onBlurError : getErrorMessage(error); + const lockedRewards = ( + <> + {coin.availableBalance} + {coin.lockedStakeRewards} + + ); + return ( -
+
@@ -199,7 +223,7 @@ export const AssetInput = ({ size="small" color="secondary" className={cn(styles.maxBtn, { [styles.show]: displayMaxBtn })} - disabled={hasReachedMaxAmount} + disabled={hasReachedMaxAmount || hasReachedMaxAvailableAmount} > {t('core.assetInput.maxButton')} @@ -226,22 +250,34 @@ export const AssetInput = ({
-

- {coin.balance} -

-
-

+ + {coin.balance} + {!isPopupView && !!coin.lockedStakeRewards && lockedRewards} + + +

{focused ? fiatValue : formattedFiatValue}

-
-
-
- {isInvalid && errorMessage && ( - - {t(errorMessage)} - - )} + {isInvalid && errorMessage && ( + + {t(errorMessage)} + + )} +
+ {isPopupView && !!coin.lockedStakeRewards && ( +
+ + {lockedRewards} + +
+ )}
); }; diff --git a/packages/core/src/ui/components/AssetInput/AssetInputList.tsx b/packages/core/src/ui/components/AssetInput/AssetInputList.tsx index 2a920b17f..db3565da3 100644 --- a/packages/core/src/ui/components/AssetInput/AssetInputList.tsx +++ b/packages/core/src/ui/components/AssetInput/AssetInputList.tsx @@ -17,17 +17,25 @@ export interface AssetInputListProps { onAddAsset?: () => void; disabled?: boolean; translations: TranslationsFor<'addAsset'>; + isPopupView?: boolean; } export const AssetInputList = ({ rows, onAddAsset, disabled, - translations + translations, + isPopupView }: AssetInputListProps): React.ReactElement => (
{rows.map((row, idx) => ( - + ))}