Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [lw-11830]: handle not delegated stake keys scenarios in send flow #1543

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/browser-extension-wallet/src/hooks/useInitializeTx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@ 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<COIN_SELECTION_ERRORS, TranslationKey>([
[COIN_SELECTION_ERRORS.BALANCE_INSUFFICIENT_ERROR, 'general.errors.insufficientBalance'],
[COIN_SELECTION_ERRORS.NOT_FRAGMENTED_ENOUGH_ERROR, 'general.errors.utxoNotFragmentedEnough'],
[COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR, 'general.errors.utxoFullyDepleted'],
[COIN_SELECTION_ERRORS.MAXIMUM_INPUT_COUNT_EXCEEDED_ERROR, 'general.errors.maximumInputCountExceeded'],
[COIN_SELECTION_ERRORS.BUNDLE_AMOUNT_IS_EMPTY, 'general.errors.bundleAmountIsEmpty']
[COIN_SELECTION_ERRORS.BUNDLE_AMOUNT_IS_EMPTY, 'general.errors.bundleAmountIsEmpty'],
[COIN_SELECTION_ERRORS.AVAILABLE_BALANCE_INSUFFICIENT_ERROR, 'general.errors.insufficientAvailableBalance']
]);

export const getErrorMessage =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface Props {
coinBalance: string;
isBundle: boolean;
insufficientBalanceInputs: string[];
insufficientAvailableBalanceInputs: string[];
reachedMaxAmountList: Set<string | Wallet.Cardano.AssetId>;
assets: Map<Wallet.Cardano.AssetId, Wallet.Asset.AssetInfo>;
setIsBundle: (value: boolean) => void;
Expand All @@ -31,6 +32,7 @@ export const BundlesList = ({
isBundle,
setIsBundle,
insufficientBalanceInputs,
insufficientAvailableBalanceInputs,
reachedMaxAmountList,
assets,
assetBalances,
Expand Down Expand Up @@ -83,11 +85,13 @@ export const BundlesList = ({
)}
<AddressInput row={bundleId} currentNetwork={currentChain.networkId} isPopupView={isPopupView} />
<CoinInput
isPopupView={isPopupView}
bundleId={bundleId}
assets={assets}
assetBalances={assetBalances}
coinBalance={coinBalance}
insufficientBalanceInputs={insufficientBalanceInputs}
insufficientAvailableBalanceInputs={insufficientAvailableBalanceInputs}
onAddAsset={() => handleAssetPicker(bundleId)}
openAssetPicker={(coinId) => handleAssetPicker(bundleId, coinId)}
canAddMoreAssets={canAddMoreAssets(bundleId)}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ 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;
assetBalances: Wallet.Cardano.Value['assets'];
canAddMoreAssets?: boolean;
onAddAsset?: () => void;
spendableCoin: bigint;
isPopupView?: boolean;
} & Omit<UseSelectedCoinsProps, 'bundleId' | 'assetBalances'>;

export const CoinInput = ({
Expand All @@ -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();
Expand All @@ -33,11 +38,15 @@ export const CoinInput = ({
}, [bundleId, setCoinValues]);

return (
<AssetInputList
disabled={(!!assetBalances && assetBalances?.size === 0) || !canAddMoreAssets}
rows={selectedCoins}
onAddAsset={onAddAsset}
translations={{ addAsset: t('browserView.transaction.send.advanced.asset') }}
/>
<>
<AssetInputList
disabled={(!!assetBalances && assetBalances?.size === 0) || !canAddMoreAssets}
rows={selectedCoins}
onAddAsset={onAddAsset}
translations={{ addAsset: t('browserView.transaction.send.advanced.asset') }}
isPopupView={isPopupView}
/>
{!!lockedStakeRewards && <LockedStakeRewardsBanner isPopupView={isPopupView} />}
</>
);
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-magic-numbers */
const mockCoinStateSelector = {
uiOutputs: [],
Expand All @@ -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 });
Expand Down Expand Up @@ -41,6 +43,12 @@ jest.mock('@stores', (): typeof Stores => ({
...jest.requireActual<typeof Stores>('@stores'),
useWalletStore: mockUseWalletStore
}));

jest.mock('@src/views/browser-view/features/staking/hooks', () => ({
...jest.requireActual<any>('@src/views/browser-view/features/staking/hooks'),
useRewardAccountsData: mockUseRewardAccountsData
}));

jest.mock('../../../../store', (): typeof SendTransactionStore => ({
...jest.requireActual<typeof SendTransactionStore>('../../../../store'),
useCoinStateSelector: mockUseCoinStateSelector,
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +39,7 @@ export interface UseSelectedCoinsProps {
/** Coin balance (ADA) in lovelace */
coinBalance: string;
insufficientBalanceInputs?: Array<string>;
insufficientAvailableBalanceInputs?: Array<string>;
openAssetPicker?: (id: string) => void;
spendableCoin: bigint;
}
Expand All @@ -54,6 +56,7 @@ export const useSelectedCoins = ({
assets,
coinBalance,
insufficientBalanceInputs,
insufficientAvailableBalanceInputs,
openAssetPicker,
bundleId,
spendableCoin
Expand All @@ -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
*/
Expand All @@ -88,14 +93,18 @@ 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;
}
// eslint-disable-next-line consistent-return
return undefined;
},
[address, assetInputList, builtTxError, insufficientBalanceInputs]
[address, assetInputList, builtTxError, insufficientBalanceInputs, insufficientAvailableBalanceInputs]
);

const handleOnChangeCoin = useCallback(
Expand Down Expand Up @@ -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',
Expand All @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export const getMaxSpendableAmount = (totalSpendableBalance = '0', totalSpent =
};

type ADARow = {
totalADA: string;
availableADA: string;
lockedStakeRewards: string;
hasReachedMaxAvailableAmount: boolean;
} & Pick<AssetInputProps, 'max' | 'allowFloat' | 'hasMaxBtn' | 'hasReachedMaxAmount'>;

/**
Expand All @@ -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)
};
};

Expand Down
Loading
Loading