mirror of
https://github.com/zhigang1992/wallet.git
synced 2026-01-12 22:53:27 +08:00
@@ -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');
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -66,6 +66,7 @@ export function RpcSendTransferChooseFee() {
|
||||
amount={amountAsMoney}
|
||||
feesList={feesList}
|
||||
isLoading={isLoading}
|
||||
isSendingMax={false}
|
||||
onChooseFee={previewTransfer}
|
||||
onSetSelectedFeeType={(value: BtcFeeType) => setSelectedFeeType(value)}
|
||||
selectedFeeType={selectedFeeType}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export function BrcChooseFee() {
|
||||
amount={amountAsMoney}
|
||||
feesList={feesList}
|
||||
isLoading={isLoading}
|
||||
isSendingMax={false}
|
||||
onChooseFee={previewTransaction}
|
||||
onSetSelectedFeeType={(value: BtcFeeType) => setSelectedFeeType(value)}
|
||||
selectedFeeType={selectedFeeType}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
})();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user