Skip to content

Commit

Permalink
fix: [lw-11830]: handle not delegated stake keys scenarios in send fl…
Browse files Browse the repository at this point in the history
…ow (#1543)

---------

Co-authored-by: Przemysław Włodek <przem.wlodek.github@gmail.com>
  • Loading branch information
vetalcore and przemyslaw-wlodek authored Dec 11, 2024
1 parent 995014a commit 6806594
Show file tree
Hide file tree
Showing 20 changed files with 369 additions and 91 deletions.
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

0 comments on commit 6806594

Please sign in to comment.