feat: add btc set fee choice

This commit is contained in:
alter-eggo
2023-04-13 17:53:36 +04:00
committed by Anastasios
parent afe15f4537
commit 485dbcddfa
30 changed files with 504 additions and 141 deletions

View File

@@ -1,8 +1,9 @@
import { Box, Button, Flex, FlexProps, Stack, Text } from '@stacks/ui';
import { Box, Button, Flex, FlexProps, Stack, StackProps, Text } from '@stacks/ui';
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
import { isString } from '@shared/utils';
import { whenPageMode } from '@app/common/utils';
import { figmaTheme } from '@app/common/utils/figma-theme';
import { SpaceBetween } from '../layout/space-between';
@@ -54,7 +55,7 @@ export function InfoCardSeparator() {
}
// InfoCardAssetValue
interface InfoCardAssetValueProps {
interface InfoCardAssetValueProps extends StackProps {
value: number;
fiatValue?: string;
fiatSymbol?: string;
@@ -68,35 +69,37 @@ export function InfoCardAssetValue({
fiatSymbol,
symbol,
icon,
...props
}: InfoCardAssetValueProps) {
return (
<Stack
mb="44px"
width="100%"
alignItems="center"
backgroundColor="#F9F9FA"
py="24px"
border="1px solid #EFEFF2"
borderRadius="10px"
>
{icon && <Box as={icon} size="32px" />}
<Box width="100%" {...props}>
<Stack
width="100%"
alignItems="center"
backgroundColor="#F9F9FA"
py="24px"
border="1px solid #EFEFF2"
borderRadius="10px"
>
{icon && <Box as={icon} size="32px" />}
<Flex flexDirection="column" alignItems="center">
<Text
fontSize="24px"
fontWeight="500"
lineHeight="36px"
data-testid={SharedComponentsSelectors.InfoCardAssetValue}
>
{value} {symbol}
</Text>
{fiatValue && (
<Text fontSize="12px" mt="4px">
~ {fiatValue} {fiatSymbol}
<Flex flexDirection="column" alignItems="center">
<Text
fontSize="24px"
fontWeight="500"
lineHeight="36px"
data-testid={SharedComponentsSelectors.InfoCardAssetValue}
>
{value} {symbol}
</Text>
)}
</Flex>
</Stack>
{fiatValue && (
<Text fontSize="12px" mt="4px">
~ {fiatValue} {fiatSymbol}
</Text>
)}
</Flex>
</Stack>
</Box>
);
}
@@ -124,3 +127,33 @@ export function InfoCardBtn({ icon, label, onClick }: InfoCardBtnProps) {
</Button>
);
}
// InfoCardFooter
interface InfoCardFooterProps {
children: React.ReactNode;
}
export function InfoCardFooter({ children }: InfoCardFooterProps) {
return (
<Flex
bottom="0"
width="100%"
bg={whenPageMode({
full: '',
popup: '#fff',
})}
borderTop="1px solid #EFEFF2"
alignItems="center"
justifyContent="center"
zIndex="999"
py="loose"
px="extra-loose"
position={whenPageMode({
full: 'unset',
popup: 'fixed',
})}
>
{children}
</Flex>
);
}

View File

@@ -68,13 +68,7 @@ export function ModalHeader({
</Title>
</Flex>
<Flex
alignItems="center"
flexBasis="20%"
isInline
justifyContent="flex-end"
position="relative"
>
<Flex alignItems="center" flexBasis="20%" justifyContent="flex-end" position="relative">
<NetworkModeBadge position="absolute" right="35px" />
{(onClose || defaultClose) && (
<IconButton

View File

@@ -14,7 +14,7 @@ import { InfoCard, InfoCardRow, InfoCardSeparator } from '@app/components/info-c
import { InscriptionPreview } from '@app/components/inscription-preview-card/components/inscription-preview';
import { PrimaryButton } from '@app/components/primary-button';
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/address.hooks';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { btcTxTimeMap } from '@app/query/bitcoin/bitcoin-client';
import { InscriptionPreviewCard } from '../../../components/inscription-preview-card/inscription-preview-card';
import { useBitcoinBroadcastTransaction } from '../../../query/bitcoin/transaction/use-bitcoin-broadcast-transaction';
@@ -25,7 +25,7 @@ function useSendInscriptionReviewState() {
return {
signedTx: get(location.state, 'tx') as string,
recipient: get(location.state, 'recipient', '') as string,
fee: get(location.state, 'fee'),
fee: get(location.state, 'fee') as number,
};
}
@@ -39,9 +39,7 @@ export function SendInscriptionReview() {
const { refetch } = useCurrentNativeSegwitUtxos();
const { broadcastTx, isBroadcasting } = useBitcoinBroadcastTransaction();
const { data: feeRate } = useBitcoinFeeRate();
const arrivesIn = feeRate ? `~${feeRate?.fastestFee} min` : '~10 20 min';
const arrivesIn = btcTxTimeMap.hourFee;
const summaryFee = formatMoney(createMoney(Number(fee), 'BTC'));
async function sendInscription() {

View File

@@ -0,0 +1,35 @@
import { useNavigate } from 'react-router-dom';
// import get from 'lodash.get';
// import { RouteUrls } from '@shared/route-urls';
import { BaseDrawer } from '@app/components/drawer/base-drawer';
// import { BitcoinSetFee } from '../send-crypto-asset-form/family/bitcoin/components/bitcoin-set-fee';
// import { useInscriptionSendState } from './send-inscription-container';
// function useSendInscriptionSetFeeState() {
// const location = useLocation();
// return {
// tx: get(location.state, 'tx') as string,
// recipient: get(location.state, 'recipient', '') as string,
// };
// }
export function SendInscriptionSetFee() {
const navigate = useNavigate();
// const { tx, recipient } = useSendInscriptionSetFeeState();
// const { inscription, utxo } = useInscriptionSendState();
// function previewTransaction(feeRate: number, feeValue: number, time: string) {
// feeRate;
// navigate(RouteUrls.SendOrdinalInscriptionReview, {
// state: { fee: feeValue, inscription, utxo, recipient, tx, arrivesIn: time },
// });
// }
return (
<BaseDrawer title="Choose fee" isShowing enableGoBack onClose={() => navigate(-1)}>
{/* <BitcoinSetFee onChooseFee={previewTransaction} recipient={recipient} amount={}/>; */}
</BaseDrawer>
);
}

View File

@@ -0,0 +1,36 @@
import { Box, Flex, Text, color, transition } from '@stacks/ui';
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
interface FeesCardProps {
feeType: string;
feeAmount: string;
feeFiatValue: string;
arrivesIn: string;
onClick: () => void;
}
export function FeesCard({ feeType, feeAmount, feeFiatValue, arrivesIn, ...props }: FeesCardProps) {
return (
<Box
as="button"
border="1px solid"
borderColor={color('border')}
borderRadius="16px"
boxShadow="0px 1px 2px rgba(0, 0, 0, 0.04)"
transition={transition}
padding="extra-loose"
width="100%"
_hover={{ background: '#F9F9FA' }}
data-testid={SharedComponentsSelectors.FeeCard}
{...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">
<Text>{arrivesIn}</Text>
<Text>{feeFiatValue}</Text>
</Flex>
</Box>
);
}

View File

@@ -17,7 +17,7 @@ export function FormFooter(props: { balance: Money }) {
bg={color('bg')}
borderTop="1px solid #DCDDE2"
bottom="0px"
height={['106px', '116px']}
height={['96px', '116px']}
position={whenPageMode({
full: 'unset',
popup: 'absolute',

View File

@@ -16,7 +16,7 @@ export function PreviewButton(props: ButtonProps) {
width="100%"
{...props}
>
Preview
Continue
</Button>
);
}

View File

@@ -0,0 +1,36 @@
import { Stack, Text } from '@stacks/ui';
import { FeesCard } from '../../../components/fees-card';
import { useBitcoinSetFees } from './use-bitcoin-set-fees';
interface BitcoinSetFeeProps {
onChooseFee(feeRate: number, feeValue: number, time: string): Promise<void> | void;
recipient: string;
amount: number;
}
export function BitcoinSetFee({ onChooseFee, recipient, amount }: BitcoinSetFeeProps) {
const { feesList } = useBitcoinSetFees({ recipient, amount });
return (
<Stack p="extra-loose" width="100%" spacing="extra-loose" alignItems="center">
<Stack width="100%" spacing="base">
{feesList.map(({ label, value, btcValue, fiatValue, time, feeRate }) => (
<FeesCard
key={label}
feeType={label}
feeAmount={btcValue}
feeFiatValue={fiatValue}
arrivesIn={time}
onClick={() => onChooseFee(feeRate, value, time)}
/>
))}
</Stack>
<Text fontSize="14px" color="#74777D" textAlign="center">
Fees are deducted from your balance,
<br /> it won't affect your sending amount.
</Text>
</Stack>
);
}

View File

@@ -0,0 +1,90 @@
import { useMemo } from 'react';
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 { useGetUtxosByAddressQuery } from '@app/query/bitcoin/address/utxos-by-address.query';
import { BtcFeeType, btcTxTimeMap } from '@app/query/bitcoin/bitcoin-client';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks';
import { useCurrentBtcNativeSegwitAccountAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
interface UseBitcoinSetFeesArgs {
recipient: string;
amount: number;
}
export function useBitcoinSetFees({ recipient, amount }: UseBitcoinSetFeesArgs) {
const currentAccountBtcAddress = useCurrentBtcNativeSegwitAccountAddressIndexZero();
const { data: utxos } = useGetUtxosByAddressQuery(currentAccountBtcAddress);
const btcMarketData = useCryptoCurrencyMarketData('BTC');
const { data: feeRate } = useBitcoinFeeRate();
const feesList = useMemo(() => {
function getFiatFeeValue(fee: number) {
return `~ ${i18nFormatCurrency(
baseCurrencyAmountInQuote(createMoney(Math.ceil(fee), 'BTC'), btcMarketData)
)}`;
}
if (!feeRate || !utxos) return [];
const satAmount = btcToSat(amount).toNumber();
const { fee: highFeeValue } = determineUtxosForSpend({
utxos,
recipient,
amount: satAmount,
feeRate: feeRate.fastestFee,
});
const { fee: standartFeeValue } = determineUtxosForSpend({
utxos,
recipient,
amount: satAmount,
feeRate: feeRate.halfHourFee,
});
const { fee: lowFeeValue } = determineUtxosForSpend({
utxos,
recipient,
amount: satAmount,
feeRate: feeRate.economyFee,
});
return [
{
label: BtcFeeType.High,
value: highFeeValue,
btcValue: formatMoneyPadded(createMoney(highFeeValue, 'BTC')),
time: btcTxTimeMap.fastestFee,
fiatValue: getFiatFeeValue(highFeeValue),
feeRate: feeRate.fastestFee,
},
{
label: BtcFeeType.Standard,
value: standartFeeValue,
btcValue: formatMoneyPadded(createMoney(standartFeeValue, 'BTC')),
time: btcTxTimeMap.halfHourFee,
fiatValue: getFiatFeeValue(standartFeeValue),
feeRate: feeRate.halfHourFee,
},
{
label: BtcFeeType.Low,
value: lowFeeValue,
btcValue: formatMoneyPadded(createMoney(lowFeeValue, 'BTC')),
time: btcTxTimeMap.economyFee,
fiatValue: getFiatFeeValue(lowFeeValue),
feeRate: feeRate.economyFee,
},
];
}, [feeRate, btcMarketData, utxos, recipient, amount]);
return {
feesList,
};
}

View File

@@ -7,7 +7,6 @@ import { BitcoinSendFormValues } from '@shared/models/form.model';
import { btcToSat } from '@app/common/money/unit-conversion';
import { determineUtxosForSpend } from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import { useGetUtxosByAddressQuery } from '@app/query/bitcoin/address/utxos-by-address.query';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useBitcoinLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
import {
useCurrentBitcoinNativeSegwitAddressIndexPublicKeychain,
@@ -21,10 +20,9 @@ export function useGenerateSignedBitcoinTx() {
const currentAddressIndexKeychain = useCurrentBitcoinNativeSegwitAddressIndexPublicKeychain();
const signTx = useSignBitcoinNativeSegwitTx();
const networkMode = useBitcoinLibNetworkConfig();
const { data: feeRate } = useBitcoinFeeRate();
return useCallback(
(values: BitcoinSendFormValues) => {
(values: BitcoinSendFormValues, feeRate: number) => {
if (!utxos) return;
if (!feeRate) return;
@@ -35,7 +33,7 @@ export function useGenerateSignedBitcoinTx() {
utxos,
recipient: values.recipient,
amount: btcToSat(values.amount).toNumber(),
feeRate: feeRate.fastestFee,
feeRate,
});
// eslint-disable-next-line no-console
@@ -77,13 +75,6 @@ export function useGenerateSignedBitcoinTx() {
return null;
}
},
[
currentAccountBtcAddress,
currentAddressIndexKeychain?.publicKey,
feeRate,
networkMode,
signTx,
utxos,
]
[currentAccountBtcAddress, currentAddressIndexKeychain?.publicKey, networkMode, signTx, utxos]
);
}

View File

@@ -15,7 +15,11 @@ import { useSignTransactionSoftwareWallet } from '@app/store/transactions/transa
import { useStacksTransactionSummary } from './use-stacks-transaction-summary';
export function useStacksBroadcastTransaction(unsignedTx: string, token: CryptoCurrencies) {
export function useStacksBroadcastTransaction(
unsignedTx: string,
token: CryptoCurrencies,
decimals?: number
) {
const signSoftwareWalletTx = useSignTransactionSoftwareWallet();
const [isBroadcasting, setIsBroadcasting] = useState(false);
const { formSentSummaryTxState } = useStacksTransactionSummary(token);
@@ -32,7 +36,7 @@ export function useStacksBroadcastTransaction(unsignedTx: string, token: CryptoC
':txId',
`${txId}`
),
formSentSummaryTxState(txId, signedTx)
formSentSummaryTxState(txId, signedTx, decimals)
);
}
@@ -85,5 +89,6 @@ export function useStacksBroadcastTransaction(unsignedTx: string, token: CryptoC
isBroadcasting,
token,
formSentSummaryTxState,
decimals,
]);
}

View File

@@ -25,7 +25,7 @@ export function useStacksTransactionSummary(token: CryptoCurrencies) {
const { isTestnet } = useCurrentNetworkState();
const { data: blockTime } = useStacksBlockTime();
function formSentSummaryTxState(txId: string, signedTx: StacksTransaction) {
function formSentSummaryTxState(txId: string, signedTx: StacksTransaction, decimals?: number) {
return {
state: {
hasHeaderTitle: true,
@@ -34,7 +34,7 @@ export function useStacksTransactionSummary(token: CryptoCurrencies) {
txid: txId || '',
},
txId,
...formReviewTxSummary(signedTx, token),
...formReviewTxSummary(signedTx, token, decimals),
},
};
}

View File

@@ -17,13 +17,13 @@ import { FormAddressDisplayer } from '@app/components/address-displayer/form-add
import {
InfoCard,
InfoCardAssetValue,
InfoCardFooter,
InfoCardRow,
InfoCardSeparator,
} from '@app/components/info-card/info-card';
import { ModalHeader } from '@app/components/modal-header';
import { PrimaryButton } from '@app/components/primary-button';
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/address.hooks';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useBitcoinBroadcastTransaction } from '@app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction';
import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks';
@@ -31,12 +31,19 @@ import { useSendFormNavigate } from '../../hooks/use-send-form-navigate';
const symbol = 'BTC';
export function BtcSendFormConfirmation() {
function useBtcSendFormConfirmationState() {
const location = useLocation();
return {
tx: get(location.state, 'tx') as string,
fee: get(location.state, 'fee') as string,
arrivesIn: get(location.state, 'time') as string,
recipient: get(location.state, 'recipient') as string,
};
}
export function BtcSendFormConfirmation() {
const navigate = useNavigate();
const tx = get(location.state, 'tx');
const recipient = get(location.state, 'recipient');
const fee = get(location.state, 'fee');
const { tx, recipient, fee, arrivesIn } = useBtcSendFormConfirmationState();
const { refetch } = useCurrentNativeSegwitUtxos();
const analytics = useAnalytics();
@@ -52,8 +59,6 @@ export function BtcSendFormConfirmation() {
baseCurrencyAmountInQuote(createMoneyFromDecimal(Number(transferAmount), symbol), btcMarketData)
);
const txFiatValueSymbol = btcMarketData.price.symbol;
const { data: feeRate } = useBitcoinFeeRate();
const arrivesIn = feeRate ? `~${feeRate.fastestFee} min` : '~10 20 min';
const feeInBtc = satToBtc(fee);
const totalSpend = formatMoney(
@@ -107,16 +112,19 @@ export function BtcSendFormConfirmation() {
useRouteHeader(<ModalHeader hideActions defaultClose defaultGoBack title="Review" />);
return (
<InfoCard padding="extra-loose" data-testid={SendCryptoAssetSelectors.ConfirmationDetails}>
<InfoCard data-testid={SendCryptoAssetSelectors.ConfirmationDetails}>
<InfoCardAssetValue
value={Number(transferAmount)}
fiatValue={txFiatValue}
fiatSymbol={txFiatValueSymbol}
symbol={symbol}
data-testid={SendCryptoAssetSelectors.ConfirmationDetailsAssetValue}
mt="loose"
mb="extra-loose"
px="loose"
/>
<Stack width="100%" mb="36px">
<Stack width="100%" px="extra-loose" pb="extra-loose">
<InfoCardRow
title="To"
value={<FormAddressDisplayer address={recipient} />}
@@ -133,9 +141,11 @@ export function BtcSendFormConfirmation() {
<InfoCardRow title="Estimated confirmation time" value={arrivesIn} />
</Stack>
<PrimaryButton isLoading={isBroadcasting} width="100%" onClick={initiateTransaction}>
Confirm and send transaction
</PrimaryButton>
<InfoCardFooter>
<PrimaryButton isLoading={isBroadcasting} width="100%" onClick={initiateTransaction}>
Confirm and send transaction
</PrimaryButton>
</InfoCardFooter>
</InfoCard>
);
}

View File

@@ -1,6 +1,7 @@
import { Outlet } from 'react-router-dom';
import { Box } from '@stacks/ui';
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
import { Form, Formik } from 'formik';
import { HIGH_FEE_WARNING_LEARN_MORE_URL_BTC } from '@shared/constants';
@@ -35,7 +36,7 @@ export function BtcSendForm() {
currentNetwork,
formRef,
onFormStateChange,
previewTransaction,
chooseTransactionFee,
validationSchema,
} = useBtcSendForm();
@@ -46,7 +47,7 @@ export function BtcSendForm() {
...routeState,
recipientBnsName: '',
})}
onSubmit={previewTransaction}
onSubmit={chooseTransactionFee}
validationSchema={validationSchema}
innerRef={formRef}
{...defaultSendFormFormikProps}
@@ -78,6 +79,11 @@ export function BtcSendForm() {
<FormFooter balance={btcBalance.balance} />
<HighFeeDrawer learnMoreUrl={HIGH_FEE_WARNING_LEARN_MORE_URL_BTC} />
<Outlet />
{/* This is for testing purposes only, to make sure the form is ready to be submitted */}
{calcMaxSpend(props.values.recipient).spendableBitcoin.toNumber() > 0 ? (
<Box data-testid={SendCryptoAssetSelectors.SendPageReady}></Box>
) : null}
</Form>
);
}}

View File

@@ -0,0 +1,57 @@
import { useLocation } from 'react-router-dom';
import get from 'lodash.get';
import { logger } from '@shared/logger';
import { BitcoinSendFormValues } from '@shared/models/form.model';
import { useRouteHeader } from '@app/common/hooks/use-route-header';
import { useWalletType } from '@app/common/use-wallet-type';
import { ModalHeader } from '@app/components/modal-header';
import { BitcoinSetFee } from '../../family/bitcoin/components/bitcoin-set-fee';
import { useGenerateSignedBitcoinTx } from '../../family/bitcoin/hooks/use-generate-bitcoin-tx';
import { useSendFormNavigate } from '../../hooks/use-send-form-navigate';
function useBtcSetFeeState() {
const location = useLocation();
return {
txValues: get(location.state, 'values') as BitcoinSendFormValues,
};
}
export function BtcSetFee() {
const { txValues } = useBtcSetFeeState();
const { whenWallet } = useWalletType();
const sendFormNavigate = useSendFormNavigate();
const generateTx = useGenerateSignedBitcoinTx();
async function previewTransaction(feeRate: number, feeValue: number, time: string) {
const resp = generateTx(txValues, feeRate);
if (!resp) return logger.error('Attempted to generate raw tx, but no tx exists');
const { hex } = resp;
whenWallet({
software: () =>
sendFormNavigate.toConfirmAndSignBtcTransaction({
tx: hex,
recipient: txValues.recipient,
fee: feeValue,
time,
}),
ledger: () => {},
})();
}
useRouteHeader(<ModalHeader hideActions defaultGoBack title="Choose fee" />);
return (
<BitcoinSetFee
onChooseFee={previewTransaction}
recipient={txValues.recipient}
amount={Number(txValues.amount)}
/>
);
}

View File

@@ -3,13 +3,10 @@ import { useRef } from 'react';
import { FormikHelpers, FormikProps } from 'formik';
import * as yup from 'yup';
import { HIGH_FEE_AMOUNT_BTC } from '@shared/constants';
import { logger } from '@shared/logger';
import { BitcoinSendFormValues } from '@shared/models/form.model';
import { isEmpty } from '@shared/utils';
import { formatPrecisionError } from '@app/common/error-formatters';
import { useDrawers } from '@app/common/hooks/use-drawers';
import { useWalletType } from '@app/common/use-wallet-type';
import {
btcAddressNetworkValidator,
@@ -27,7 +24,6 @@ import { useCurrentBtcNativeSegwitAccountAddressIndexZero } from '@app/store/acc
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
import { useCalculateMaxBitcoinSpend } from '../../family/bitcoin/hooks/use-calculate-max-spend';
import { useGenerateSignedBitcoinTx } from '../../family/bitcoin/hooks/use-generate-bitcoin-tx';
import { useSendFormNavigate } from '../../hooks/use-send-form-navigate';
export function useBtcSendForm() {
@@ -35,10 +31,8 @@ export function useBtcSendForm() {
const currentNetwork = useCurrentNetwork();
const currentAccountBtcAddress = useCurrentBtcNativeSegwitAccountAddressIndexZero();
const btcCryptoCurrencyAssetBalance = useNativeSegwitBalance(currentAccountBtcAddress);
const { isShowingHighFeeConfirmation, setIsShowingHighFeeConfirmation } = useDrawers();
const { whenWallet } = useWalletType();
const sendFormNavigate = useSendFormNavigate();
const generateTx = useGenerateSignedBitcoinTx();
const calcMaxSpend = useCalculateMaxBitcoinSpend();
const { onFormStateChange } = useUpdatePersistedSendFormValues();
@@ -70,29 +64,16 @@ export function useBtcSendForm() {
.concat(notCurrentAddressValidator(currentAccountBtcAddress || '')),
}),
async previewTransaction(
async chooseTransactionFee(
values: BitcoinSendFormValues,
formikHelpers: FormikHelpers<BitcoinSendFormValues>
) {
logger.debug('btc form values', values);
// Validate and check high fee warning first
const formErrors = await formikHelpers.validateForm();
if (
!isShowingHighFeeConfirmation &&
isEmpty(formErrors) &&
values.fee > HIGH_FEE_AMOUNT_BTC
) {
return setIsShowingHighFeeConfirmation(true);
}
const resp = generateTx(values);
if (!resp) return logger.error('Attempted to generate raw tx, but no tx exists');
const { hex, fee } = resp;
await formikHelpers.validateForm();
whenWallet({
software: () => sendFormNavigate.toConfirmAndSignBtcTransaction(hex, values.recipient, fee),
software: () => sendFormNavigate.toChooseTransactionFee(values),
ledger: () => {},
})();
},

View File

@@ -1,10 +1,12 @@
import { Stack } from '@stacks/ui';
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
import { whenPageMode } from '@app/common/utils';
import { FormAddressDisplayer } from '@app/components/address-displayer/form-address-displayer';
import {
InfoCard,
InfoCardAssetValue,
InfoCardFooter,
InfoCardRow,
InfoCardSeparator,
} from '@app/components/info-card/info-card';
@@ -43,16 +45,28 @@ export function SendFormConfirmation({
symbol,
}: SendFormConfirmationProps) {
return (
<InfoCard padding="extra-loose" data-testid={SendCryptoAssetSelectors.ConfirmationDetails}>
<InfoCard
data-testid={SendCryptoAssetSelectors.ConfirmationDetails}
pb={whenPageMode({
full: '0px',
popup: '80px',
})}
>
<InfoCardAssetValue
value={Number(txValue)}
fiatValue={txFiatValue}
fiatSymbol={txFiatValueSymbol}
symbol={symbol}
data-testid={SendCryptoAssetSelectors.ConfirmationDetailsAssetValue}
my="loose"
px="loose"
/>
<Stack width="100%">
<InfoLabel px="loose" mb="loose" title="Sending to an exchange?">
{`Make sure you include the memo so the exchange can credit the ${symbol} to your account`}
</InfoLabel>
<Stack width="100%" px="extra-loose" pb="extra-loose">
<InfoCardRow
title="To"
value={<FormAddressDisplayer address={recipient} />}
@@ -75,18 +89,16 @@ export function SendFormConfirmation({
<InfoCardRow title="Estimated confirmation time" value={arrivesIn} />
</Stack>
<InfoLabel my="extra-loose" title="Sending to an exchange?">
{`Make sure you include the memo so the exchange can credit the ${symbol} to your account`}
</InfoLabel>
<PrimaryButton
data-testid={SendCryptoAssetSelectors.ConfirmSendTxBtn}
width="100%"
isLoading={isLoading}
onClick={onBroadcastTransaction}
>
Confirm and send transaction
</PrimaryButton>
<InfoCardFooter>
<PrimaryButton
data-testid={SendCryptoAssetSelectors.ConfirmSendTxBtn}
width="100%"
isLoading={isLoading}
onClick={onBroadcastTransaction}
>
Confirm and send transaction
</PrimaryButton>
</InfoCardFooter>
</InfoCard>
);
}

View File

@@ -24,7 +24,7 @@ export function StacksSendFormConfirmation() {
const { symbol = 'STX' } = useParams();
const { stacksDeserializedTransaction, stacksBroadcastTransaction, isBroadcasting } =
useStacksBroadcastTransaction(tx, symbol.toUpperCase() as CryptoCurrencies);
useStacksBroadcastTransaction(tx, symbol.toUpperCase() as CryptoCurrencies, decimals);
const { formReviewTxSummary } = useStacksTransactionSummary(
symbol.toUpperCase() as CryptoCurrencies
@@ -32,6 +32,7 @@ export function StacksSendFormConfirmation() {
const {
txValue,
txFiatValue,
txFiatValueSymbol,
recipient,
fee,
totalSpend,
@@ -47,6 +48,7 @@ export function StacksSendFormConfirmation() {
<SendFormConfirmation
txValue={txValue}
txFiatValue={txFiatValue}
txFiatValueSymbol={txFiatValueSymbol}
recipient={recipient}
fee={fee}
totalSpend={totalSpend}

View File

@@ -4,6 +4,9 @@ import { useNavigate } from 'react-router-dom';
import { bytesToHex } from '@stacks/common';
import { StacksTransaction } from '@stacks/transactions';
import { BitcoinSendFormValues } from '@shared/models/form.model';
import { RouteUrls } from '@shared/route-urls';
interface ConfirmationRouteState {
decimals?: number;
token?: string;
@@ -17,6 +20,13 @@ interface ConfirmationRouteStacksSip10Args {
tx: StacksTransaction;
}
interface ConfirmationRouteBtcArgs {
tx: string;
recipient: string;
fee: number;
time: string;
}
export function useSendFormNavigate() {
const navigate = useNavigate();
@@ -25,13 +35,21 @@ export function useSendFormNavigate() {
backToSendForm(state: any) {
return navigate('../', { relative: 'path', replace: true, state });
},
toConfirmAndSignBtcTransaction(tx: string, recipient: string, fee: number) {
return navigate('confirm', {
replace: true,
toChooseTransactionFee(values: BitcoinSendFormValues) {
return navigate('set-fee', {
state: {
values,
hasHeaderTitle: true,
},
});
},
toConfirmAndSignBtcTransaction({ tx, recipient, fee, time }: ConfirmationRouteBtcArgs) {
return navigate(RouteUrls.SendBtcConfirmation, {
state: {
tx,
recipient,
fee,
time,
hasHeaderTitle: true,
} as ConfirmationRouteState,
});

View File

@@ -18,6 +18,7 @@ import { StxSentSummary } from '../sent-summary/stx-sent-summary';
import { RecipientAccountsDrawer } from './components/recipient-accounts-drawer/recipient-accounts-drawer';
import { BtcSendForm } from './form/btc/btc-send-form';
import { BtcSendFormConfirmation } from './form/btc/btc-send-form-confirmation';
import { BtcSetFee } from './form/btc/btc-set-fee';
import { Sip10TokenSendForm } from './form/stacks-sip10/sip10-token-send-form';
import { StacksSendFormConfirmation } from './form/stacks/stacks-send-form-confirmation';
import { StxSendForm } from './form/stx/stx-send-form';
@@ -53,7 +54,7 @@ export const sendCryptoAssetFormRoutes = (
<Route path="/send/btc/confirm" element={<BtcSendFormConfirmation />} />
<Route path="/send/btc/disabled" element={<SendBtcDisabled />} />
<Route path="/send/btc/error" element={<BroadcastError />} />
<Route path={RouteUrls.SendBtcSetFee} element={<BtcSetFee />}></Route>
<Route path={RouteUrls.SendCryptoAssetForm.replace(':symbol', 'stx')} element={<StxSendForm />}>
{broadcastErrorDrawerRoute}
{editNonceDrawerRoute}

View File

@@ -12,6 +12,7 @@ import {
InfoCard,
InfoCardAssetValue,
InfoCardBtn,
InfoCardFooter,
InfoCardRow,
InfoCardSeparator,
} from '@app/components/info-card/info-card';
@@ -50,16 +51,18 @@ export function BtcSentSummary() {
useRouteHeader(<ModalHeader hideActions defaultClose title="Sent" />);
return (
<InfoCard pt="extra-loose" pb="base-loose" px="extra-loose">
<InfoCard>
<InfoCardAssetValue
value={txValue}
fiatValue={txFiatValue}
fiatSymbol={txFiatValueSymbol}
symbol={symbol}
icon={FiCheck}
my="loose"
px="loose"
/>
<Stack width="100%" mb="44px">
<Stack width="100%" px="extra-loose" pb="extra-loose">
<InfoCardRow title="To" value={<FormAddressDisplayer address={recipient} />} />
<InfoCardSeparator />
<InfoCardRow title="Total spend" value={totalSpend} />
@@ -69,10 +72,12 @@ export function BtcSentSummary() {
<InfoCardRow title="Arrives in" value={arrivesIn} />
</Stack>
<Stack spacing="base" isInline width="100%">
<InfoCardBtn onClick={onClickLink} icon={FiExternalLink} label="View Details" />
<InfoCardBtn onClick={onClickCopy} icon={FiCopy} label="Copy ID" />
</Stack>
<InfoCardFooter>
<Stack spacing="base" isInline width="100%">
<InfoCardBtn onClick={onClickLink} icon={FiExternalLink} label="View Details" />
<InfoCardBtn onClick={onClickCopy} icon={FiCopy} label="Copy ID" />
</Stack>
</InfoCardFooter>
</InfoCard>
);
}

View File

@@ -12,6 +12,7 @@ import {
InfoCard,
InfoCardAssetValue,
InfoCardBtn,
InfoCardFooter,
InfoCardRow,
InfoCardSeparator,
} from '@app/components/info-card/info-card';
@@ -50,16 +51,18 @@ export function StxSentSummary() {
useRouteHeader(<ModalHeader hideActions defaultClose title="Sent" />);
return (
<InfoCard pt="extra-loose" pb="extra-loose" px="extra-loose">
<InfoCard>
<InfoCardAssetValue
value={txValue}
fiatValue={txFiatValue}
fiatSymbol={txFiatValueSymbol}
symbol={symbol}
icon={FiCheck}
></InfoCardAssetValue>
my="loose"
px="loose"
/>
<Stack width="100%" mb="44px">
<Stack width="100%" px="extra-loose" pb="extra-loose">
<InfoCardRow title="To" value={<FormAddressDisplayer address={recipient} />} />
<InfoCardSeparator />
<InfoCardRow title="Total spend" value={totalSpend} />
@@ -69,10 +72,12 @@ export function StxSentSummary() {
<InfoCardRow title="Estimated confirmation time" value={arrivesIn} />
</Stack>
<Stack spacing="base" isInline width="100%">
<InfoCardBtn onClick={onClickLink} icon={FiExternalLink} label="View Details" />
<InfoCardBtn onClick={onClickCopy} icon={FiCopy} label="Copy ID" />
</Stack>
<InfoCardFooter>
<Stack spacing="base" isInline width="100%">
<InfoCardBtn onClick={onClickLink} icon={FiExternalLink} label="View Details" />
<InfoCardBtn onClick={onClickCopy} icon={FiCopy} label="Copy ID" />
</Stack>
</InfoCardFooter>
</InfoCard>
);
}

View File

@@ -85,6 +85,20 @@ class TransactionsApi {
}
}
export const btcTxTimeMap: Record<keyof FeeEstimateMempoolSpaceApi, string> = {
fastestFee: '~10 20min',
halfHourFee: '~30 min',
economyFee: '~1 hour+',
hourFee: '~1 hour+',
minimumFee: '~1 hour+',
};
export enum BtcFeeType {
High = 'High',
Standard = 'Standard',
Low = 'Low',
}
export class BitcoinClient {
configuration: Configuration;
addressApi: AddressApi;

View File

@@ -38,6 +38,7 @@ import { BroadcastError } from '@app/pages/send/broadcast-error/broadcast-error'
import { SendInscription } from '@app/pages/send/ordinal-inscription/send-inscription-container';
import { SendInscriptionForm } from '@app/pages/send/ordinal-inscription/send-inscription-form';
import { SendInscriptionReview } from '@app/pages/send/ordinal-inscription/send-inscription-review';
import { SendInscriptionSetFee } from '@app/pages/send/ordinal-inscription/send-inscription-set-fee';
import { SendInscriptionSummary } from '@app/pages/send/ordinal-inscription/sent-inscription-summary';
import { sendCryptoAssetFormRoutes } from '@app/pages/send/send-crypto-asset-form/send-crypto-asset-form.routes';
import { SignOutConfirmDrawer } from '@app/pages/sign-out-confirm/sign-out-confirm';
@@ -102,6 +103,10 @@ function AppRoutesAfterUserHasConsented() {
path={RouteUrls.SendOrdinalInscriptionReview}
element={<SendInscriptionReview />}
/>
<Route
path={RouteUrls.SendOrdinalInscriptionSetFee}
element={<SendInscriptionSetFee />}
/>
<Route
path={RouteUrls.SendOrdinalInscriptionSent}
element={<SendInscriptionSummary />}

View File

@@ -8,7 +8,6 @@ export const gaiaUrl = 'https://hub.blockstack.org';
export const POPUP_CENTER_WIDTH = 442;
export const POPUP_CENTER_HEIGHT = 646;
export const HIGH_FEE_AMOUNT_BTC = 0.001;
export const HIGH_FEE_AMOUNT_STX = 5;
export const HIGH_FEE_WARNING_LEARN_MORE_URL_BTC = 'https://bitcoinfees.earn.com/';
export const HIGH_FEE_WARNING_LEARN_MORE_URL_STX = 'https://hiro.so/questions/fee-estimates';

View File

@@ -69,6 +69,7 @@ export enum RouteUrls {
SendStacksSip10Confirmation = '/send/:symbol/confirm',
SentBtcTxSummary = '/sent/btc/:txId',
SentStxTxSummary = '/sent/stx/:txId',
SendBtcSetFee = '/send/btc/set-fee',
// Send ordinal inscriptions
SendOrdinalInscription = '/send/ordinal-inscription',
@@ -77,6 +78,7 @@ export enum RouteUrls {
SendOrdinalInscriptionSummary = '/send/ordinal-inscription/',
SendOrdinalInscriptionSent = '/send/ordinal-inscription/sent',
SendOrdinalInscriptionError = '/send/ordinal-inscription/error',
SendOrdinalInscriptionSetFee = '/send/ordinal-inscription/set-fee',
// Request routes
RpcGetAddresses = '/get-addresses',

View File

@@ -1,6 +1,8 @@
import { Locator, Page } from '@playwright/test';
import { CryptoAssetSelectors } from '@tests/selectors/crypto-asset.selectors';
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
import { createTestSelector } from '@tests/utils';
import { RouteUrls } from '@shared/route-urls';
@@ -21,6 +23,7 @@ export class SendPage {
readonly sendMaxButton: Locator;
readonly feesRow: Locator;
readonly memoRow: Locator;
readonly feesCard: Locator;
constructor(page: Page) {
this.page = page;
@@ -52,6 +55,7 @@ export class SendPage {
this.memoRow = page.getByTestId(SendCryptoAssetSelectors.ConfirmationDetailsMemo);
this.sendMaxButton = page.getByTestId(SendCryptoAssetSelectors.SendMaxBtn);
this.feesCard = page.getByTestId(SharedComponentsSelectors.FeeCard);
}
async selectBtcAndGoToSendForm() {
@@ -71,4 +75,10 @@ export class SendPage {
await this.page.waitForURL('**' + `${RouteUrls.SendCryptoAsset}/stx`);
await this.page.getByTestId(SendCryptoAssetSelectors.SendForm).waitFor();
}
async waitForSendPageReady() {
await this.page.waitForSelector(createTestSelector(SendCryptoAssetSelectors.SendPageReady), {
state: 'attached',
});
}
}

View File

@@ -20,4 +20,6 @@ export enum SendCryptoAssetSelectors {
RecipientBnsAddressCopyToClipboard = 'recipient-bns-address-copy-to-clipboard',
SendForm = 'send-form',
SendMaxBtn = 'send-max-btn',
SendPageReady = 'send-page-ready',
}

View File

@@ -15,4 +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',
}

View File

@@ -1,8 +1,9 @@
import { TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS } from '@tests/mocks/constants';
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
import { getDisplayerAddress } from '@tests/utils';
import { FormErrorMessages } from '@app/common/error-messages';
import { BtcFeeType } from '@app/query/bitcoin/bitcoin-client';
import { test } from '../../fixtures/fixtures';
@@ -13,27 +14,17 @@ test.describe('send btc', () => {
await homePage.enableTestMode();
await homePage.sendButton.click();
await sendPage.selectBtcAndGoToSendForm();
await sendPage.waitForSendPageReady();
});
test.describe('btc send form', () => {
test('that it shows preview of tx details to be confirmed', async ({ sendPage }) => {
await sendPage.amountInput.fill('0.0001');
await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS);
await sendPage.previewSendTxButton.click();
const details = await sendPage.confirmationDetails.allInnerTexts();
test.expect(details).toBeTruthy();
});
test('that it shows preview of tx details after validation error is resolved', async ({
sendPage,
}) => {
await sendPage.amountInput.fill('0.00006');
await sendPage.amountInput.blur();
const errorMsg = await sendPage.amountInputErrorLabel.innerText();
test.expect(errorMsg).toEqual(FormErrorMessages.InsufficientFunds);
await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS);
await sendPage.amountInput.fill('0.0001');
await sendPage.previewSendTxButton.click();
await sendPage.feesCard.filter({ hasText: BtcFeeType.High }).click();
const details = await sendPage.confirmationDetails.allInnerTexts();
test.expect(details).toBeTruthy();
});
@@ -48,6 +39,7 @@ test.describe('send btc', () => {
await sendPage.page.waitForTimeout(1000);
await sendPage.previewSendTxButton.click();
await sendPage.feesCard.filter({ hasText: BtcFeeType.High }).click();
const displayerAddress = await getDisplayerAddress(sendPage.confirmationDetailsRecipient);
test.expect(displayerAddress).toEqual(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS);
@@ -57,5 +49,27 @@ test.describe('send btc', () => {
.innerText();
test.expect(confirmationAssetValue).toEqual(`${amount} ${amountSymbol}`);
});
test('that fee value on preview match chosen one', async ({ sendPage }) => {
await sendPage.amountInput.fill('0.00006');
await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS);
await sendPage.previewSendTxButton.click();
const feeType = BtcFeeType.Standard;
const fee = await sendPage.feesCard
.filter({ hasText: feeType })
.getByTestId(SharedComponentsSelectors.FeeCardFeeValue)
.innerText();
await sendPage.feesCard.filter({ hasText: feeType }).click();
const confirmationFee = await sendPage.confirmationDetails
.getByTestId(SendCryptoAssetSelectors.ConfirmationDetailsFee)
.getByTestId(SharedComponentsSelectors.InfoCardRowValue)
.innerText();
test.expect(confirmationFee).toEqual(fee);
});
});
});