Skip to content

Commit

Permalink
fix: [lw-11830]: handle not delegated stake keys scenarios in send flow
Browse files Browse the repository at this point in the history
  • Loading branch information
vetalcore committed Nov 19, 2024
1 parent 953b087 commit 18975ed
Show file tree
Hide file tree
Showing 15 changed files with 335 additions and 76 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
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
@import '../../../../../../../../../packages/common/src/ui/styles/theme.scss';
@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;
.banner {
margin-bottom: 0px !important;
margin-top: 0px !important;
&.popupView {
margin-bottom: 0;
}

.bannerDescription {
max-width: 315px;
}
}

.coinInputContainer {
flex: 1;
.bannerPopup {
border-radius: 16px;
background-color: var(--dark-mode-dark-grey-plus, var(--color-magnolia, #fcf5e3));
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { AssetInputList } from '@lace/core';
import cn from 'classnames';
import { useHistory } from 'react-router-dom';
import { useCoinStateSelector } from '../../../store';
import { Wallet } from '@lace/cardano';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getTemporaryTxDataFromStorage } from '../../../helpers';
import { UseSelectedCoinsProps, useSelectedCoins } from './useSelectedCoins';
import { Banner } from '@lace/common';
import { walletRoutePaths } from '@routes';
import styles from './CoinInput.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 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,24 +29,57 @@ export const CoinInput = ({
onAddAsset,
canAddMoreAssets,
spendableCoin,
isPopupView,
...selectedCoinsProps
}: CoinInputProps): React.ReactElement => {
const { t } = useTranslation();
const history = useHistory();
const { setCoinValues } = useCoinStateSelector(bundleId);
const { selectedCoins } = useSelectedCoins({ bundleId, assetBalances, spendableCoin, ...selectedCoinsProps });
const [, setIsDrawerVisible] = useDrawer();

useEffect(() => {
const { tempOutputs } = getTemporaryTxDataFromStorage();
if (!tempOutputs || tempOutputs.length === 0) return;
setCoinValues(bundleId, tempOutputs);
}, [bundleId, setCoinValues]);

const onGoToStaking = () => {
const path = isPopupView ? walletRoutePaths.earn : walletRoutePaths.staking;
setIsDrawerVisible();
history.push(path);
};

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}
/>
{isPopupView ? (
<Flex className={styles.bannerPopup} flexDirection="column" py="$16" px="$24" gap="$24">
<Text.Button>{t('general.errors.lockedStakeRewards.description')}</Text.Button>
<Button.CallToAction
w="$fill"
onClick={onGoToStaking}
data-testid="stats-register-as-drep-cta"
label={t('general.errors.lockedStakeRewards.cta')}
/>
</Flex>
) : (
<Banner
customIcon={<ExclamationCircleOutline />}
popupView={isPopupView}
className={cn(styles.banner, { [styles.popupView]: isPopupView })}
message={<Box className={styles.bannerDescription}>{t('general.errors.lockedStakeRewards.description')}</Box>}
buttonMessage={t('general.errors.lockedStakeRewards.cta')}
onButtonClick={onGoToStaking}
withIcon
/>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ 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,
Expand All @@ -39,7 +39,7 @@ describe('CoinInput util', () => {
});
});
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,
Expand All @@ -48,7 +48,7 @@ describe('CoinInput util', () => {
});
});
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,
Expand All @@ -57,7 +57,7 @@ describe('CoinInput util', () => {
});
});
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,
Expand All @@ -66,7 +66,7 @@ describe('CoinInput util', () => {
});
});
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,
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) }),
...(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 18975ed

Please sign in to comment.