refactor: send max, closes #3136, closes #3576

This commit is contained in:
fbwoolf
2023-05-24 11:38:53 -05:00
committed by kyranjamie
parent 588580abaf
commit 5b7d99a890
23 changed files with 377 additions and 111 deletions

View File

@@ -6,17 +6,49 @@ import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';
import { BtcSizeFeeEstimator } from '../fees/btc-size-fee-estimator';
interface DetermineUtxosForSpendArgs {
utxos: UtxoResponseItem[];
export interface DetermineUtxosForSpendArgs {
amount: number;
feeRate: number;
recipient: string;
utxos: UtxoResponseItem[];
}
export function determineUtxosForSpend({
utxos,
export function determineUtxosForSpendAll({
amount,
feeRate,
recipient,
utxos,
}: DetermineUtxosForSpendArgs) {
if (!validate(recipient)) throw new Error('Cannot calculate spend of invalid address type');
const addressInfo = getAddressInfo(recipient);
const txSizer = new BtcSizeFeeEstimator();
const sizeInfo = txSizer.calcTxSize({
input_script: 'p2wpkh',
input_count: utxos.length,
[addressInfo.type + '_output_count']: 1,
});
const outputs = [{ value: BigInt(amount), address: recipient }];
const fee = Math.ceil(sizeInfo.txVBytes * feeRate);
return {
utxos,
inputs: utxos,
outputs,
size: sizeInfo.txVBytes,
fee,
};
}
export function determineUtxosForSpend({
amount,
feeRate,
recipient,
utxos,
}: DetermineUtxosForSpendArgs) {
if (!validate(recipient)) throw new Error('Cannot calculate spend of invalid address type');

View File

@@ -5,7 +5,10 @@ import * as btc from '@scure/btc-signer';
import { logger } from '@shared/logger';
import { Money } from '@shared/models/money.model';
import { determineUtxosForSpend } from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import {
determineUtxosForSpend,
determineUtxosForSpendAll,
} from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import { useSpendableCurrentNativeSegwitAccountUtxos } from '@app/query/bitcoin/address/use-spendable-native-segwit-utxos';
import { useIsStampedTx } from '@app/query/bitcoin/stamps/use-is-stamped-tx';
import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
@@ -29,7 +32,7 @@ export function useGenerateSignedNativeSegwitTx() {
const networkMode = useBitcoinScureLibNetworkConfig();
return useCallback(
(values: GenerateNativeSegwitTxValues, feeRate: number) => {
(values: GenerateNativeSegwitTxValues, feeRate: number, isSendingMax?: boolean) => {
if (!utxos) return;
if (!feeRate) return;
if (!createSigner) return;
@@ -38,13 +41,19 @@ export function useGenerateSignedNativeSegwitTx() {
const signer = createSigner(0);
const tx = new btc.Transaction();
const filteredUtxos = utxos.filter(utxo => !isStamped(utxo.txid));
const amountAsNumber = values.amount.amount.toNumber();
const { inputs, outputs, fee } = determineUtxosForSpend({
utxos: utxos.filter(utxo => !isStamped(utxo.txid)),
recipient: values.recipient,
amount: values.amount.amount.toNumber(),
const determineUtxosArgs = {
amount: amountAsNumber,
feeRate,
});
recipient: values.recipient,
utxos: filteredUtxos,
};
const { inputs, outputs, fee } = isSendingMax
? determineUtxosForSpendAll(determineUtxosArgs)
: determineUtxosForSpend(determineUtxosArgs);
logger.info('coinselect', { inputs, outputs, fee });

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { Box, Stack, Text, color } from '@stacks/ui';
import { Stack, Text, color } from '@stacks/ui';
import { BtcFeeType } from '@shared/models/fees/bitcoin-fees.model';
import { Money, createMoney } from '@shared/models/money.model';
@@ -10,7 +10,10 @@ import { formatMoney } from '@app/common/money/format-money';
import { useCurrentNativeSegwitAddressBalance } from '@app/query/bitcoin/balance/bitcoin-balances.query';
import { LoadingSpinner } from '../loading-spinner';
import { FeesCard } from './components/fees-card';
import { FeesListError } from './components/fees-list-error';
import { FeesListItem } from './components/fees-list-item';
import { FeesListSubtitle } from './components/fees-list-subtitle';
import { InsufficientBalanceError } from './components/insufficient-balance-error';
export interface FeesListItem {
label: BtcFeeType;
@@ -31,6 +34,7 @@ interface BitcoinFeesListProps {
amount: Money;
feesList: FeesListItem[];
isLoading: boolean;
isSendingMax: boolean;
onChooseFee({ feeRate, feeValue, time }: OnChooseFeeArgs): Promise<void>;
onSetSelectedFeeType(value: BtcFeeType): void;
selectedFeeType: BtcFeeType;
@@ -39,6 +43,7 @@ export function BitcoinFeesList({
amount,
feesList,
isLoading,
isSendingMax,
onChooseFee,
onSetSelectedFeeType,
selectedFeeType,
@@ -75,15 +80,9 @@ export function BitcoinFeesList({
if (isLoading) return <LoadingSpinner />;
if (!feesList.length) {
return (
<Text color={color('text-caption')} fontSize={1} lineHeight="20px" textAlign="center">
Unable to calculate fees.
<br />
Check balance and try again.
</Text>
);
}
// TODO: This should be changed when custom fees are implemented. We can simply
// force custom fee setting when api requests fail and we can't calculate fees.
if (!feesList.length) return <FeesListError />;
return (
<Stack alignItems="center" spacing="base" width="100%">
@@ -97,23 +96,17 @@ export function BitcoinFeesList({
</Text>
) : null}
{showInsufficientBalanceError ? (
<Box display="flex" alignItems="center" minHeight="40px">
<Text color={color('feedback-error')} fontSize={1} textAlign="center">
Fee is too expensive for available balance
</Text>
</Box>
<InsufficientBalanceError />
) : (
<Text color={color('text-caption')} fontSize={1} lineHeight="20px" textAlign="center">
Fees are deducted from your balance,
<br /> it won't affect your sending amount.
</Text>
<FeesListSubtitle isSendingMax={isSendingMax} />
)}
<Stack mt="tight" spacing="base" width="100%">
{feesList.map(({ label, value, btcValue, fiatValue, time, feeRate }) => (
<FeesCard
<FeesListItem
arrivesIn={time}
feeAmount={btcValue}
feeFiatValue={fiatValue}
feeRate={feeRate}
feeType={label}
key={label}
isSelected={label === selectedFeeType}

View File

@@ -0,0 +1,30 @@
import { useNavigate } from 'react-router-dom';
import { Box, Text } from '@stacks/ui';
import { RouteUrls } from '@shared/route-urls';
import { GenericError } from '@app/components/generic-error/generic-error';
const body = 'Check balance and try again';
const helpTextList = [
<Box as="li" mt="base" key={1}>
<Text>Possibly caused by api issues</Text>
</Box>,
];
const title = 'Unable to calculate fees';
export function FeesListError() {
const navigate = useNavigate();
return (
<Box textAlign="center" px={['unset', 'loose']} py="base" width="100%">
<GenericError
body={body}
helpTextList={helpTextList}
onClose={() => navigate(RouteUrls.SendCryptoAsset)}
title={title}
/>
</Box>
);
}

View File

@@ -3,22 +3,24 @@ import { SharedComponentsSelectors } from '@tests/selectors/shared-component.sel
import { figmaTheme } from '@app/common/utils/figma-theme';
interface FeesCardProps {
interface FeesListItemProps {
arrivesIn: string;
feeAmount: string;
feeFiatValue: string;
feeRate: number;
feeType: string;
isSelected?: boolean;
onClick: () => void;
}
export function FeesCard({
export function FeesListItem({
arrivesIn,
feeAmount,
feeFiatValue,
feeRate,
feeType,
isSelected,
...props
}: FeesCardProps) {
}: FeesListItemProps) {
return (
<Box
_hover={{ background: '#F9F9FA' }}
@@ -27,19 +29,21 @@ export function FeesCard({
borderColor={isSelected ? figmaTheme.borderFocused : color('border')}
borderRadius="16px"
boxShadow="0px 1px 2px rgba(0, 0, 0, 0.04)"
data-testid={SharedComponentsSelectors.FeeCard}
padding="extra-loose"
data-testid={SharedComponentsSelectors.FeesListItem}
px="base"
py="extra-loose"
transition={transition}
width="100%"
{...props}
>
<Flex justifyContent="space-between" mb="tight" fontWeight={500}>
<Text>{feeType}</Text>
<Text data-testid={SharedComponentsSelectors.FeeCardFeeValue}>{feeAmount}</Text>
</Flex>
<Flex justifyContent="space-between" color="#74777D">
<Flex justifyContent="center" mb="base-tight" fontWeight={500}>
<Text mr="tight">{feeType}</Text>
<Text>{arrivesIn}</Text>
<Text>{feeFiatValue}</Text>
</Flex>
<Flex justifyContent="center" color={color('text-caption')}>
<Text
data-testid={SharedComponentsSelectors.FeesListItemFeeValue}
>{`${feeFiatValue} | ${feeRate} sats/vB | ${feeAmount}`}</Text>
</Flex>
</Box>
);

View File

@@ -0,0 +1,21 @@
import { Text, color } from '@stacks/ui';
export function FeesListSubtitle(props: { isSendingMax: boolean }) {
const { isSendingMax } = props;
const subtitle = isSendingMax ? (
'Chosen fee will affect your sending amount'
) : (
<>
Fees are deducted from your balance,
<br />
it won't affect your sending amount.
</>
);
return (
<Text color={color('text-caption')} fontSize={1} lineHeight="20px" textAlign="center">
{subtitle}
</Text>
);
}

View File

@@ -0,0 +1,11 @@
import { Box, Text, color } from '@stacks/ui';
export function InsufficientBalanceError() {
return (
<Box display="flex" alignItems="center" minHeight="40px">
<Text color={color('feedback-error')} fontSize={1} textAlign="center">
Fee is too expensive for available balance
</Text>
</Box>
);
}

View File

@@ -6,7 +6,11 @@ import { createMoney } from '@shared/models/money.model';
import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money';
import { formatMoneyPadded, i18nFormatCurrency } from '@app/common/money/format-money';
import { btcToSat } from '@app/common/money/unit-conversion';
import { determineUtxosForSpend } from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import {
DetermineUtxosForSpendArgs,
determineUtxosForSpend,
determineUtxosForSpendAll,
} from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import { useSpendableNativeSegwitUtxos } from '@app/query/bitcoin/address/use-spendable-native-segwit-utxos';
import { useAverageBitcoinFeeRates } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks';
@@ -14,16 +18,27 @@ import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accoun
import { FeesListItem } from './bitcoin-fees-list';
function getFeeForList(
determineUtxosForFeeArgs: DetermineUtxosForSpendArgs,
isSendingMax?: boolean
) {
const { fee } = isSendingMax
? determineUtxosForSpendAll(determineUtxosForFeeArgs)
: determineUtxosForSpend(determineUtxosForFeeArgs);
return fee;
}
interface UseBitcoinFeesListArgs {
amount: number;
isSendingMax?: boolean;
recipient: string;
}
export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs) {
export function useBitcoinFeesList({ amount, isSendingMax, recipient }: UseBitcoinFeesListArgs) {
const currentAccountBtcAddress = useCurrentAccountNativeSegwitAddressIndexZero();
const { data: utxos } = useSpendableNativeSegwitUtxos(currentAccountBtcAddress);
const btcMarketData = useCryptoCurrencyMarketData('BTC');
const { avgApiFeeRates: feeRate, isLoading } = useAverageBitcoinFeeRates();
const { avgApiFeeRates: feeRates, isLoading } = useAverageBitcoinFeeRates();
const feesList: FeesListItem[] = useMemo(() => {
function getFiatFeeValue(fee: number) {
@@ -32,29 +47,34 @@ export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs
)}`;
}
if (!feeRate || !utxos || !utxos.length) return [];
if (!feeRates || !utxos || !utxos.length) return [];
const satAmount = btcToSat(amount).toNumber();
const { fee: highFeeValue } = determineUtxosForSpend({
utxos,
recipient,
amount: satAmount,
feeRate: feeRate.fastestFee.toNumber(),
});
const { fee: standardFeeValue } = determineUtxosForSpend({
utxos,
recipient,
const determineUtxosDefaultArgs = {
amount: satAmount,
feeRate: feeRate.halfHourFee.toNumber(),
});
recipient,
utxos,
};
const { fee: lowFeeValue } = determineUtxosForSpend({
utxos,
recipient,
amount: satAmount,
feeRate: feeRate.hourFee.toNumber(),
});
const determineUtxosForHighFeeArgs = {
...determineUtxosDefaultArgs,
feeRate: feeRates.fastestFee.toNumber(),
};
const determineUtxosForStandardFeeArgs = {
...determineUtxosDefaultArgs,
feeRate: feeRates.halfHourFee.toNumber(),
};
const determineUtxosForLowFeeArgs = {
...determineUtxosDefaultArgs,
feeRate: feeRates.hourFee.toNumber(),
};
const highFeeValue = getFeeForList(determineUtxosForHighFeeArgs, isSendingMax);
const standardFeeValue = getFeeForList(determineUtxosForStandardFeeArgs, isSendingMax);
const lowFeeValue = getFeeForList(determineUtxosForLowFeeArgs, isSendingMax);
return [
{
@@ -63,7 +83,7 @@ export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs
btcValue: formatMoneyPadded(createMoney(highFeeValue, 'BTC')),
time: btcTxTimeMap.fastestFee,
fiatValue: getFiatFeeValue(highFeeValue),
feeRate: feeRate.fastestFee.toNumber(),
feeRate: feeRates.fastestFee.toNumber(),
},
{
label: BtcFeeType.Standard,
@@ -71,7 +91,7 @@ export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs
btcValue: formatMoneyPadded(createMoney(standardFeeValue, 'BTC')),
time: btcTxTimeMap.halfHourFee,
fiatValue: getFiatFeeValue(standardFeeValue),
feeRate: feeRate.halfHourFee.toNumber(),
feeRate: feeRates.halfHourFee.toNumber(),
},
{
label: BtcFeeType.Low,
@@ -79,10 +99,10 @@ export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs
btcValue: formatMoneyPadded(createMoney(lowFeeValue, 'BTC')),
time: btcTxTimeMap.hourFee,
fiatValue: getFiatFeeValue(lowFeeValue),
feeRate: feeRate.hourFee.toNumber(),
feeRate: feeRates.hourFee.toNumber(),
},
];
}, [feeRate, btcMarketData, utxos, recipient, amount]);
}, [feeRates, utxos, amount, recipient, isSendingMax, btcMarketData]);
return {
feesList,

View File

@@ -66,6 +66,7 @@ export function RpcSendTransferChooseFee() {
amount={amountAsMoney}
feesList={feesList}
isLoading={isLoading}
isSendingMax={false}
onChooseFee={previewTransfer}
onSetSelectedFeeType={(value: BtcFeeType) => setSelectedFeeType(value)}
selectedFeeType={selectedFeeType}

View File

@@ -41,6 +41,7 @@ export function SendInscriptionChooseFee() {
amount={createMoney(0, 'BTC')}
feesList={feesList}
isLoading={isLoading}
isSendingMax={false}
onChooseFee={previewTransaction}
onSetSelectedFeeType={(value: BtcFeeType) => setSelectedFeeType(value)}
selectedFeeType={selectedFeeType}

View File

@@ -31,19 +31,21 @@ function getAmountModifiedFontSize(props: GetAmountModifiedFontSize) {
}
interface AmountFieldProps {
balance: Money;
switchableAmount?: JSX.Element;
bottomInputOverlay?: JSX.Element;
autofocus?: boolean;
autoComplete?: 'on' | 'off';
autofocus?: boolean;
balance: Money;
bottomInputOverlay?: React.JSX.Element;
isSendingMax?: boolean;
switchableAmount?: React.JSX.Element;
tokenSymbol?: string;
}
export function AmountField({
balance,
switchableAmount,
bottomInputOverlay,
autofocus = false,
autoComplete = 'on',
autofocus = false,
balance,
bottomInputOverlay,
isSendingMax,
switchableAmount,
tokenSymbol,
}: AmountFieldProps) {
const [field, meta] = useField('amount');
@@ -54,6 +56,7 @@ export function AmountField({
const symbol = tokenSymbol || balance.symbol;
const maxLength = decimals === 0 ? maxLengthDefault : decimals + 2;
const fontSizeModifier = (maxFontSize - minFontSize) / maxLength;
const subtractedLengthToPositionPrefix = 0.5;
useEffect(() => {
// case, when e.g token doesn't have symbol
@@ -66,7 +69,7 @@ export function AmountField({
fontSize < maxFontSize && setFontSize(textSize);
} else if (field.value.length > symbol.length && previousTextLength < field.value.length) {
const textSize = Math.ceil(fontSize - fontSizeModifier);
fontSize > 22 && setFontSize(textSize);
fontSize > minFontSize && setFontSize(textSize);
}
// Copy/paste
if (field.value.length > symbol.length && field.value.length > previousTextLength + 2) {
@@ -79,8 +82,10 @@ export function AmountField({
});
setFontSize(modifiedFontSize < minFontSize ? minFontSize : modifiedFontSize);
}
setPreviousTextLength(field.value.length);
}, [field.value, fontSize, fontSizeModifier, previousTextLength, symbol]);
setPreviousTextLength(
isSendingMax ? field.value.length - subtractedLengthToPositionPrefix : field.value.length
);
}, [field.value, fontSize, fontSizeModifier, isSendingMax, previousTextLength, symbol]);
// TODO: could be implemented with html using padded label element
const onClickFocusInput = useCallback(() => {
@@ -103,7 +108,9 @@ export function AmountField({
fontWeight={500}
color={figmaTheme.text}
>
{isSendingMax ? <Text fontSize={fontSize + 'px'}>~</Text> : null}
<Input
_disabled={{ bg: color('bg') }}
_focus={{ border: 'none' }}
border="none"
caretColor={color('accent')}
@@ -111,6 +118,7 @@ export function AmountField({
fontSize={fontSize + 'px'}
height="100%"
id={amountInputId}
isDisabled={isSendingMax}
maxLength={maxLength}
placeholder="0"
px="none"

View File

@@ -25,8 +25,8 @@ export function SendMaxButton({ balance, sendMaxBalance, ...props }: SendMaxButt
return amountFieldHelpers.setValue(sendMaxBalance);
}, [amountFieldHelpers, analytics, balance.amount, sendMaxBalance]);
// Hide send max button if using lowest fee to perform the calc
// is greater than available balance and will show zero
// Hide send max button if lowest fee calc is greater
// than available balance which will default to zero
if (sendMaxBalance === '0') return <Box height="32px" />;
return (

View File

@@ -0,0 +1,65 @@
import { Box, Button, color } from '@stacks/ui';
import { ButtonProps } from '@stacks/ui';
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
import { Money } from '@shared/models/money.model';
import { Tooltip } from '@app/components/tooltip';
import { useSendMax } from '../hooks/use-send-max';
const sendMaxTooltipLabel = 'This amount is affected by the fee you choose';
interface BitcoinSendMaxButtonProps extends ButtonProps {
balance: Money;
isSendingMax?: boolean;
onSetIsSendingMax(value: boolean): void;
sendMaxBalance: string;
sendMaxFee: string;
}
export function BitcoinSendMaxButton({
balance,
isSendingMax,
onSetIsSendingMax,
sendMaxBalance,
sendMaxFee,
...props
}: BitcoinSendMaxButtonProps) {
const onSendMax = useSendMax({
balance,
isSendingMax,
onSetIsSendingMax,
sendMaxBalance,
sendMaxFee,
});
// Hide send max button if lowest fee calc is greater
// than available balance which will default to zero
if (sendMaxBalance === '0') return <Box height="32px" />;
return (
<Tooltip
label={sendMaxTooltipLabel}
labelProps={{ padding: 'tight', textAlign: 'center' }}
maxWidth="220px"
placement="bottom"
>
<Button
_hover={{ bg: isSendingMax ? color('border') : color('bg') }}
bg={isSendingMax ? color('border') : color('bg')}
borderRadius="10px"
data-testid={SendCryptoAssetSelectors.SendMaxBtn}
fontSize={0}
height="32px"
onClick={onSendMax}
mode="tertiary"
px="base-tight"
type="button"
width="fit-content"
{...props}
>
Sending max
</Button>
</Tooltip>
);
}

View File

@@ -19,7 +19,7 @@ export function useCalculateMaxBitcoinSpend() {
const { avgApiFeeRates: feeRates } = useAverageBitcoinFeeRates();
return useCallback(
(address = '') => {
(address = '', feeRate?: number) => {
if (!utxos || !feeRates)
return {
spendAllFee: 0,
@@ -35,7 +35,7 @@ export function useCalculateMaxBitcoinSpend() {
input_count: utxos.length,
[`${addressTypeWithFallback}_output_count`]: 2,
});
const fee = Math.ceil(size.txVBytes * feeRates.hourFee.toNumber());
const fee = Math.ceil(size.txVBytes * (feeRate ?? feeRates.hourFee.toNumber()));
const spendableAmount = BigNumber.max(0, balance.amount.minus(fee));

View File

@@ -0,0 +1,46 @@
import { useCallback } from 'react';
import toast from 'react-hot-toast';
import { useField } from 'formik';
import { Money } from '@shared/models/money.model';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
interface UseSendMaxArgs {
balance: Money;
isSendingMax?: boolean;
onSetIsSendingMax(value: boolean): void;
sendMaxBalance: string;
sendMaxFee: string;
}
export function useSendMax({
balance,
isSendingMax,
onSetIsSendingMax,
sendMaxBalance,
sendMaxFee,
}: UseSendMaxArgs) {
const [, _, amountFieldHelpers] = useField('amount');
const [, __, feeFieldHelpers] = useField('fee');
const analytics = useAnalytics();
return useCallback(() => {
void analytics.track('select_maximum_amount_for_send');
if (balance.amount.isLessThanOrEqualTo(0)) return toast.error(`Zero balance`);
onSetIsSendingMax(!isSendingMax);
amountFieldHelpers.setError('');
feeFieldHelpers.setValue(sendMaxFee);
return amountFieldHelpers.setValue(sendMaxBalance);
}, [
amountFieldHelpers,
analytics,
balance.amount,
feeFieldHelpers,
isSendingMax,
onSetIsSendingMax,
sendMaxBalance,
sendMaxFee,
]);
}

View File

@@ -106,6 +106,7 @@ export function BrcChooseFee() {
amount={amountAsMoney}
feesList={feesList}
isLoading={isLoading}
isSendingMax={false}
onChooseFee={previewTransaction}
onSetSelectedFeeType={(value: BtcFeeType) => setSelectedFeeType(value)}
selectedFeeType={selectedFeeType}

View File

@@ -1,4 +1,4 @@
import { useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import get from 'lodash.get';
@@ -21,23 +21,28 @@ import { useBitcoinFeesList } from '@app/components/bitcoin-fees-list/use-bitcoi
import { ModalHeader } from '@app/components/modal-header';
import { useSendBtcState } from '../../family/bitcoin/components/send-btc-container';
import { useCalculateMaxBitcoinSpend } from '../../family/bitcoin/hooks/use-calculate-max-spend';
import { useSendFormNavigate } from '../../hooks/use-send-form-navigate';
function useBtcChooseFeeState() {
const location = useLocation();
return {
isSendingMax: get(location.state, 'isSendingMax') as boolean,
txValues: get(location.state, 'values') as BitcoinSendFormValues,
};
}
export function BtcChooseFee() {
const { txValues } = useBtcChooseFeeState();
const { isSendingMax, txValues } = useBtcChooseFeeState();
const navigate = useNavigate();
const { whenWallet } = useWalletType();
const sendFormNavigate = useSendFormNavigate();
const generateTx = useGenerateSignedNativeSegwitTx();
const { selectedFeeType, setSelectedFeeType } = useSendBtcState();
const calcMaxSpend = useCalculateMaxBitcoinSpend();
const { feesList, isLoading } = useBitcoinFeesList({
amount: Number(txValues.amount),
isSendingMax,
recipient: txValues.recipient,
});
@@ -46,10 +51,13 @@ export function BtcChooseFee() {
async function previewTransaction({ feeRate, feeValue, time }: OnChooseFeeArgs) {
const resp = generateTx(
{
amount: amountAsMoney,
amount: isSendingMax
? createMoney(btcToSat(calcMaxSpend(txValues.recipient, feeRate).spendableBitcoin), 'BTC')
: amountAsMoney,
recipient: txValues.recipient,
},
feeRate
feeRate,
isSendingMax
);
if (!resp) return logger.error('Attempted to generate raw tx, but no tx exists');
@@ -68,7 +76,12 @@ export function BtcChooseFee() {
})();
}
useRouteHeader(<ModalHeader defaultGoBack hideActions title="Choose fee" />);
function onGoBack() {
setSelectedFeeType(BtcFeeType.Low);
navigate(-1);
}
useRouteHeader(<ModalHeader hideActions onGoBack={onGoBack} title="Choose fee" />);
return (
<BitcoinFeesListLayout>
@@ -76,6 +89,7 @@ export function BtcChooseFee() {
amount={amountAsMoney}
feesList={feesList}
isLoading={isLoading}
isSendingMax={isSendingMax}
onChooseFee={previewTransaction}
onSetSelectedFeeType={(value: BtcFeeType) => setSelectedFeeType(value)}
selectedFeeType={selectedFeeType}

View File

@@ -17,8 +17,8 @@ import { FormFooter } from '../../components/form-footer';
import { SelectedAssetField } from '../../components/selected-asset-field';
import { SendCryptoAssetFormLayout } from '../../components/send-crypto-asset-form.layout';
import { SendFiatValue } from '../../components/send-fiat-value';
import { SendMaxButton } from '../../components/send-max-button';
import { BitcoinRecipientField } from '../../family/bitcoin/components/bitcoin-recipient-field';
import { BitcoinSendMaxButton } from '../../family/bitcoin/components/bitcoin-send-max-button';
import { TestnetBtcMessage } from '../../family/bitcoin/components/testnet-btc-message';
import { useSendFormRouteState } from '../../hooks/use-send-form-route-state';
import { createDefaultInitialFormValues, defaultSendFormFormikProps } from '../../send-form.utils';
@@ -36,7 +36,9 @@ export function BtcSendForm() {
chooseTransactionFee,
currentNetwork,
formRef,
isSendingMax,
onFormStateChange,
onSetIsSendingMax,
validationSchema,
} = useBtcSendForm();
@@ -54,23 +56,27 @@ export function BtcSendForm() {
>
{props => {
onFormStateChange(props.values);
const sendMaxCalculation = calcMaxSpend(props.values.recipient);
return (
<Form>
<SendCryptoAssetFormLayout>
<AmountField
autoComplete="off"
balance={btcBalance.balance}
bottomInputOverlay={
<BitcoinSendMaxButton
balance={btcBalance.balance}
isSendingMax={isSendingMax}
onSetIsSendingMax={onSetIsSendingMax}
sendMaxBalance={sendMaxCalculation.spendableBitcoin.toString()}
sendMaxFee={sendMaxCalculation.spendAllFee.toString()}
/>
}
isSendingMax={isSendingMax}
switchableAmount={
<SendFiatValue marketData={btcMarketData} assetSymbol={'BTC'} />
}
bottomInputOverlay={
<SendMaxButton
balance={btcBalance.balance}
sendMaxBalance={calcMaxSpend(
props.values.recipient
).spendableBitcoin.toString()}
/>
}
autoComplete="off"
/>
<SelectedAssetField icon={<BtcIcon />} name={btcBalance.asset.name} symbol="BTC" />
<BitcoinRecipientField />

View File

@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import { FormikHelpers, FormikProps } from 'formik';
import * as yup from 'yup';
@@ -31,6 +31,7 @@ import { useCalculateMaxBitcoinSpend } from '../../family/bitcoin/hooks/use-calc
import { useSendFormNavigate } from '../../hooks/use-send-form-navigate';
export function useBtcSendForm() {
const [isSendingMax, setIsSendingMax] = useState(false);
const formRef = useRef<FormikProps<BitcoinSendFormValues>>(null);
const currentNetwork = useCurrentNetwork();
const currentAccountBtcAddress = useCurrentAccountNativeSegwitAddressIndexZero();
@@ -44,7 +45,9 @@ export function useBtcSendForm() {
calcMaxSpend,
currentNetwork,
formRef,
isSendingMax,
onFormStateChange,
onSetIsSendingMax: (value: boolean) => setIsSendingMax(value),
validationSchema: yup.object({
amount: yup
@@ -78,7 +81,7 @@ export function useBtcSendForm() {
await formikHelpers.validateForm();
whenWallet({
software: () => sendFormNavigate.toChooseTransactionFee(values),
software: () => sendFormNavigate.toChooseTransactionFee(isSendingMax, values),
ledger: noop,
})();
},

View File

@@ -35,9 +35,10 @@ export function useSendFormNavigate() {
backToSendForm(state: any) {
return navigate('../', { relative: 'path', replace: true, state });
},
toChooseTransactionFee(values: BitcoinSendFormValues) {
toChooseTransactionFee(isSendingMax: boolean, values: BitcoinSendFormValues) {
return navigate('choose-fee', {
state: {
isSendingMax,
values,
hasHeaderTitle: true,
},

View File

@@ -23,7 +23,7 @@ export class SendPage {
readonly sendMaxButton: Locator;
readonly feesRow: Locator;
readonly memoRow: Locator;
readonly feesCard: Locator;
readonly feesListItem: Locator;
constructor(page: Page) {
this.page = page;
@@ -55,7 +55,7 @@ export class SendPage {
this.memoRow = page.getByTestId(SendCryptoAssetSelectors.ConfirmationDetailsMemo);
this.sendMaxButton = page.getByTestId(SendCryptoAssetSelectors.SendMaxBtn);
this.feesCard = page.getByTestId(SharedComponentsSelectors.FeeCard);
this.feesListItem = page.getByTestId(SharedComponentsSelectors.FeesListItem);
}
async selectBtcAndGoToSendForm() {

View File

@@ -15,6 +15,6 @@ export enum SharedComponentsSelectors {
FeeToBePaidLabel = 'fee-to-be-paid-label',
LowFeeEstimateItem = 'low-fee',
MiddleFeeEstimateItem = 'standard-fee',
FeeCard = 'fee-card',
FeeCardFeeValue = 'fee-card-fee-value',
FeesListItem = 'fee-list-item',
FeesListItemFeeValue = 'fee-list-item-fee-value',
}

View File

@@ -23,7 +23,7 @@ test.describe('send btc', () => {
await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS);
await sendPage.previewSendTxButton.click();
await sendPage.feesCard.filter({ hasText: BtcFeeType.High }).click();
await sendPage.feesListItem.filter({ hasText: BtcFeeType.High }).click();
const details = await sendPage.confirmationDetails.allInnerTexts();
test.expect(details).toBeTruthy();
@@ -39,7 +39,7 @@ test.describe('send btc', () => {
await sendPage.page.waitForTimeout(1000);
await sendPage.previewSendTxButton.click();
await sendPage.feesCard.filter({ hasText: BtcFeeType.High }).click();
await sendPage.feesListItem.filter({ hasText: BtcFeeType.High }).click();
const displayerAddress = await getDisplayerAddress(sendPage.confirmationDetailsRecipient);
test.expect(displayerAddress).toEqual(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS);
@@ -57,19 +57,19 @@ test.describe('send btc', () => {
await sendPage.previewSendTxButton.click();
const feeType = BtcFeeType.Standard;
const fee = await sendPage.feesCard
const fee = await sendPage.feesListItem
.filter({ hasText: feeType })
.getByTestId(SharedComponentsSelectors.FeeCardFeeValue)
.getByTestId(SharedComponentsSelectors.FeesListItemFeeValue)
.innerText();
await sendPage.feesCard.filter({ hasText: feeType }).click();
await sendPage.feesListItem.filter({ hasText: feeType }).click();
const confirmationFee = await sendPage.confirmationDetails
.getByTestId(SendCryptoAssetSelectors.ConfirmationDetailsFee)
.getByTestId(SharedComponentsSelectors.InfoCardRowValue)
.innerText();
test.expect(confirmationFee).toEqual(fee);
test.expect(fee).toContain(confirmationFee);
});
});
});