mirror of
https://github.com/zhigang1992/wallet.git
synced 2026-01-12 22:53:27 +08:00
feat: implement increase btc pending tx fee, closes #3416
This commit is contained in:
@@ -61,6 +61,7 @@ export function useGenerateSignedNativeSegwitTx() {
|
||||
tx.addInput({
|
||||
txid: input.txid,
|
||||
index: input.vout,
|
||||
sequence: 0,
|
||||
witnessUtxo: {
|
||||
// script = 0014 + pubKeyHash
|
||||
script: p2wpkh.script,
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
import { truncateMiddle } from '@stacks/ui-utils';
|
||||
import { getAddressInfo } from 'bitcoin-address-validation';
|
||||
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTransactionVectorOutput } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import { sumNumbers } from '@app/common/math/helpers';
|
||||
import { satToBtc } from '@app/common/money/unit-conversion';
|
||||
|
||||
export const getBitcoinTxCaption = (transaction?: BitcoinTransaction) =>
|
||||
import { BtcSizeFeeEstimator } from './fees/btc-size-fee-estimator';
|
||||
|
||||
export function getSizeInfo(payload: {
|
||||
inputLength: number;
|
||||
recipient: string;
|
||||
outputLength: number;
|
||||
}) {
|
||||
const { inputLength, recipient, outputLength } = payload;
|
||||
const addressInfo = getAddressInfo(recipient);
|
||||
|
||||
const txSizer = new BtcSizeFeeEstimator();
|
||||
const sizeInfo = txSizer.calcTxSize({
|
||||
// Only p2wpkh is supported by the wallet
|
||||
input_script: 'p2wpkh',
|
||||
input_count: inputLength,
|
||||
// From the address of the recipient, we infer the output type
|
||||
[addressInfo.type + '_output_count']: outputLength,
|
||||
});
|
||||
|
||||
return sizeInfo;
|
||||
}
|
||||
|
||||
export function getRecipientAddressFromOutput(
|
||||
vout: BitcoinTransactionVectorOutput[],
|
||||
currentBitcoinAddress: string
|
||||
) {
|
||||
return vout.find(output => output.scriptpubkey_address !== currentBitcoinAddress)
|
||||
?.scriptpubkey_address;
|
||||
}
|
||||
|
||||
export const getBitcoinTxCaption = (transaction?: BitcoinTx) =>
|
||||
transaction ? truncateMiddle(transaction.txid, 4) : '';
|
||||
|
||||
// If vin array contains a prevout with a scriptpubkey_address equal to
|
||||
// the address, then that is the current address making a `Sent` tx (-)
|
||||
// and the value of the prevout is the tx amount
|
||||
const transactionsSentByAddress = (address: string, transaction: BitcoinTransaction) =>
|
||||
const transactionsSentByAddress = (address: string, transaction: BitcoinTx) =>
|
||||
transaction.vin.filter(input => input.prevout.scriptpubkey_address === address);
|
||||
|
||||
// If vout array contains a scriptpubkey_address equal to the address,
|
||||
// then that is a `Receive` tx (+) and the value is the tx amount
|
||||
const transactionsReceivedByAddress = (address: string, transaction: BitcoinTransaction) =>
|
||||
const transactionsReceivedByAddress = (address: string, transaction: BitcoinTx) =>
|
||||
transaction.vout.filter(output => output.scriptpubkey_address === address);
|
||||
|
||||
export function isBitcoinTxInbound(address: string, transaction: BitcoinTransaction) {
|
||||
export function isBitcoinTxInbound(address: string, transaction: BitcoinTx) {
|
||||
const inputs = transactionsSentByAddress(address, transaction);
|
||||
const outputs = transactionsReceivedByAddress(address, transaction);
|
||||
|
||||
@@ -28,7 +60,7 @@ export function isBitcoinTxInbound(address: string, transaction: BitcoinTransact
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getBitcoinTxValue(address: string, transaction?: BitcoinTransaction) {
|
||||
export function getBitcoinTxValue(address: string, transaction?: BitcoinTx) {
|
||||
if (!transaction) return '';
|
||||
const inputs = transactionsSentByAddress(address, transaction);
|
||||
const outputs = transactionsReceivedByAddress(address, transaction);
|
||||
@@ -28,6 +28,9 @@ export function BitcoinCustomFeeFiat({
|
||||
return { fiatFeeValue, feeInBtc };
|
||||
}, [getCustomFeeValues, field.value]);
|
||||
|
||||
const canShow = !feeData.feeInBtc.includes('e') && Number(field.value) > 0;
|
||||
if (!canShow) return null;
|
||||
|
||||
return (
|
||||
<Flex justifyContent="space-between" color="#74777D" fontSize="14px">
|
||||
<Text>{feeData.fiatFeeValue}</Text>
|
||||
@@ -10,8 +10,8 @@ import { openInNewTab } from '@app/common/utils/open-in-new-tab';
|
||||
import { Link } from '@app/components/link';
|
||||
import { PreviewButton } from '@app/components/preview-button';
|
||||
|
||||
import { OnChooseFeeArgs } from '../../../components/bitcoin-fees-list/bitcoin-fees-list';
|
||||
import { TextInputField } from '../../../components/text-input-field';
|
||||
import { OnChooseFeeArgs } from '../bitcoin-fees-list/bitcoin-fees-list';
|
||||
import { TextInputField } from '../text-input-field';
|
||||
import { BitcoinCustomFeeFiat } from './bitcoin-custom-fee-fiat';
|
||||
import { useBitcoinCustomFee } from './hooks/use-bitcoin-custom-fee';
|
||||
|
||||
@@ -27,6 +27,7 @@ interface BitcoinCustomFeeProps {
|
||||
onValidateBitcoinSpend(value: number): boolean;
|
||||
recipient: string;
|
||||
setCustomFeeInitialValue: Dispatch<SetStateAction<string>>;
|
||||
maxCustomFeeRate: number;
|
||||
}
|
||||
export function BitcoinCustomFee({
|
||||
amount,
|
||||
@@ -38,6 +39,7 @@ export function BitcoinCustomFee({
|
||||
onValidateBitcoinSpend,
|
||||
recipient,
|
||||
setCustomFeeInitialValue,
|
||||
maxCustomFeeRate,
|
||||
}: BitcoinCustomFeeProps) {
|
||||
const feeInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const getCustomFeeValues = useBitcoinCustomFee({ amount, isSendingMax, recipient });
|
||||
@@ -54,7 +56,16 @@ export function BitcoinCustomFee({
|
||||
);
|
||||
|
||||
const validationSchema = yup.object({
|
||||
feeRate: yup.string().required('Fee is required'),
|
||||
feeRate: yup
|
||||
.number()
|
||||
.required('Fee is required')
|
||||
.integer('Fee must be a whole number')
|
||||
.test({
|
||||
message: 'Fee is too high',
|
||||
test: value => {
|
||||
return value <= maxCustomFeeRate;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -12,6 +12,8 @@ import { useSpendableCurrentNativeSegwitAccountUtxos } from '@app/query/bitcoin/
|
||||
import { useCurrentNativeSegwitAddressBalance } from '@app/query/bitcoin/balance/bitcoin-balances.query';
|
||||
import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks';
|
||||
|
||||
export const MAX_FEE_RATE_MULTIPLIER = 50;
|
||||
|
||||
interface UseBitcoinCustomFeeArgs {
|
||||
amount: number;
|
||||
isSendingMax: boolean;
|
||||
@@ -2,26 +2,25 @@ import { FiArrowDown as IconArrowDown, FiArrowUp as IconArrowUp } from 'react-ic
|
||||
|
||||
import { Box, BoxProps, Circle, ColorsStringLiteral, Flex, color } from '@stacks/ui';
|
||||
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import { isBitcoinTxInbound } from '@app/common/transactions/bitcoin/utils';
|
||||
import { BtcIcon } from '@app/components/icons/btc-icon';
|
||||
|
||||
import { isBitcoinTxInbound } from './bitcoin-transaction.utils';
|
||||
|
||||
interface TransactionIconProps extends BoxProps {
|
||||
transaction: BitcoinTransaction;
|
||||
transaction: BitcoinTx;
|
||||
btcAddress: string;
|
||||
}
|
||||
|
||||
type BtcTxStatus = 'pending' | 'success';
|
||||
type BtcStatusColorMap = Record<BtcTxStatus, ColorsStringLiteral>;
|
||||
|
||||
const statusFromTx = (tx: BitcoinTransaction): BtcTxStatus => {
|
||||
const statusFromTx = (tx: BitcoinTx): BtcTxStatus => {
|
||||
if (tx.status.confirmed) return 'success';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
const colorFromTx = (tx: BitcoinTransaction): ColorsStringLiteral => {
|
||||
const colorFromTx = (tx: BitcoinTx): ColorsStringLiteral => {
|
||||
const colorMap: BtcStatusColorMap = {
|
||||
pending: 'feedback-alert',
|
||||
success: 'brand',
|
||||
@@ -30,7 +29,7 @@ const colorFromTx = (tx: BitcoinTransaction): ColorsStringLiteral => {
|
||||
return colorMap[statusFromTx(tx)] ?? 'feedback-error';
|
||||
};
|
||||
|
||||
function IconForTx(address: string, tx: BitcoinTransaction) {
|
||||
function IconForTx(address: string, tx: BitcoinTx) {
|
||||
if (isBitcoinTxInbound(address, tx)) return IconArrowDown;
|
||||
return IconArrowUp;
|
||||
}
|
||||
@@ -1,25 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { BoxProps } from '@stacks/ui';
|
||||
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
|
||||
import { useExplorerLink } from '@app/common/hooks/use-explorer-link';
|
||||
import {
|
||||
getBitcoinTxCaption,
|
||||
getBitcoinTxValue,
|
||||
isBitcoinTxInbound,
|
||||
} from '@app/common/transactions/bitcoin/utils';
|
||||
import { useWalletType } from '@app/common/use-wallet-type';
|
||||
import { usePressable } from '@app/components/item-hover';
|
||||
import { IncreaseFeeButton } from '@app/components/stacks-transaction-item/increase-fee-button';
|
||||
import { TransactionTitle } from '@app/components/transaction/transaction-title';
|
||||
import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
|
||||
|
||||
import { TransactionItemLayout } from '../transaction-item/transaction-item.layout';
|
||||
import { BitcoinTransactionCaption } from './bitcoin-transaction-caption';
|
||||
import { BitcoinTransactionIcon } from './bitcoin-transaction-icon';
|
||||
import { BitcoinTransactionItemLayout } from './bitcoin-transaction-item.layout';
|
||||
import { BitcoinTransactionStatus } from './bitcoin-transaction-status';
|
||||
import { BitcoinTransactionValue } from './bitcoin-transaction-value';
|
||||
import { getBitcoinTxCaption, getBitcoinTxValue } from './bitcoin-transaction.utils';
|
||||
|
||||
interface BitcoinTransactionItemProps extends BoxProps {
|
||||
transaction?: BitcoinTransaction;
|
||||
transaction?: BitcoinTx;
|
||||
}
|
||||
export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransactionItemProps) {
|
||||
const [component, bind, { isHovered }] = usePressable(true);
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { whenWallet } = useWalletType();
|
||||
|
||||
const bitcoinAddress = useCurrentAccountNativeSegwitAddressIndexZero();
|
||||
const { handleOpenTxLink } = useExplorerLink();
|
||||
const analytics = useAnalytics();
|
||||
@@ -31,6 +45,15 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact
|
||||
|
||||
if (!transaction) return null;
|
||||
|
||||
const onIncreaseFee = () => {
|
||||
whenWallet({
|
||||
ledger: () => {
|
||||
// TO-DO when implement BTC in Ledger
|
||||
},
|
||||
software: () => navigate(RouteUrls.IncreaseBtcFee, { state: { btcTx: transaction } }),
|
||||
})();
|
||||
};
|
||||
|
||||
const openTxLink = () => {
|
||||
void analytics.track('view_bitcoin_transaction');
|
||||
handleOpenTxLink({
|
||||
@@ -39,18 +62,33 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact
|
||||
});
|
||||
};
|
||||
|
||||
const isOriginator = !isBitcoinTxInbound(bitcoinAddress, transaction);
|
||||
const isEnabled = isOriginator && !transaction.status.confirmed;
|
||||
|
||||
const txCaption = <BitcoinTransactionCaption>{caption}</BitcoinTransactionCaption>;
|
||||
const txValue = <BitcoinTransactionValue>{value}</BitcoinTransactionValue>;
|
||||
|
||||
const increaseFeeButton = (
|
||||
<IncreaseFeeButton
|
||||
isEnabled={isEnabled}
|
||||
isHovered={isHovered}
|
||||
isSelected={pathname === RouteUrls.IncreaseBtcFee}
|
||||
onIncreaseFee={onIncreaseFee}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<BitcoinTransactionItemLayout
|
||||
<TransactionItemLayout
|
||||
openTxLink={openTxLink}
|
||||
txCaption={txCaption}
|
||||
txIcon={<BitcoinTransactionIcon transaction={transaction} btcAddress={bitcoinAddress} />}
|
||||
txStatus={<BitcoinTransactionStatus transaction={transaction} />}
|
||||
txTitle={<TransactionTitle title="Bitcoin" />}
|
||||
txValue={txValue}
|
||||
belowCaptionEl={increaseFeeButton}
|
||||
{...bind}
|
||||
{...rest}
|
||||
/>
|
||||
>
|
||||
{component}
|
||||
</TransactionItemLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import { PendingLabel } from '@app/components/transaction/pending-label';
|
||||
|
||||
interface BitcoinTransactionStatusProps {
|
||||
transaction: BitcoinTransaction;
|
||||
transaction: BitcoinTx;
|
||||
}
|
||||
const pendingWaitingMessage =
|
||||
'This transaction is waiting to be confirmed. The average (median) confirmation time on Bitcoin is 5-10 minutes';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSearchParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { MempoolTransaction } from '@stacks/stacks-blockchain-api-types';
|
||||
import { Box, BoxProps, Flex, Stack, Text, color, useMediaQuery } from '@stacks/ui';
|
||||
import { BoxProps, Text, color } from '@stacks/ui';
|
||||
import { isPendingTx } from '@stacks/ui-utils';
|
||||
|
||||
import { StacksTx, TxTransferDetails } from '@shared/models/transactions/stacks-transaction.model';
|
||||
@@ -18,12 +18,12 @@ import { useWalletType } from '@app/common/use-wallet-type';
|
||||
import { whenPageMode } from '@app/common/utils';
|
||||
import { openIndexPageInNewTab } from '@app/common/utils/open-in-new-tab';
|
||||
import { usePressable } from '@app/components/item-hover';
|
||||
import { SpaceBetween } from '@app/components/layout/space-between';
|
||||
import { TransactionTitle } from '@app/components/transaction/transaction-title';
|
||||
import { Title } from '@app/components/typography';
|
||||
import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';
|
||||
import { useRawTxIdState } from '@app/store/transactions/raw.hooks';
|
||||
|
||||
import { TransactionItemLayout } from '../transaction-item/transaction-item.layout';
|
||||
import { IncreaseFeeButton } from './increase-fee-button';
|
||||
import { StacksTransactionIcon } from './stacks-transaction-icon';
|
||||
import { StacksTransactionStatus } from './stacks-transaction-status';
|
||||
@@ -46,8 +46,6 @@ export function StacksTransactionItem({
|
||||
const navigate = useNavigate();
|
||||
const { whenWallet } = useWalletType();
|
||||
|
||||
const [hideIncreaseFeeButton] = useMediaQuery('(max-width: 355px)');
|
||||
|
||||
if (!transaction && !transferDetails) return null;
|
||||
|
||||
const openTxLink = () => {
|
||||
@@ -67,10 +65,10 @@ export function StacksTransactionItem({
|
||||
whenWallet({
|
||||
ledger: () =>
|
||||
whenPageMode({
|
||||
full: () => navigate(RouteUrls.IncreaseFee),
|
||||
popup: () => openIndexPageInNewTab(RouteUrls.IncreaseFee, urlSearchParams),
|
||||
full: () => navigate(RouteUrls.IncreaseStxFee),
|
||||
popup: () => openIndexPageInNewTab(RouteUrls.IncreaseStxFee, urlSearchParams),
|
||||
})(),
|
||||
software: () => navigate(RouteUrls.IncreaseFee),
|
||||
software: () => navigate(RouteUrls.IncreaseStxFee),
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -78,42 +76,42 @@ export function StacksTransactionItem({
|
||||
const isPending = transaction && isPendingTx(transaction as MempoolTransaction);
|
||||
|
||||
const caption = transaction ? getTxCaption(transaction) : transferDetails?.caption || '';
|
||||
const icon = transaction ? (
|
||||
const txIcon = transaction ? (
|
||||
<StacksTransactionIcon transaction={transaction} />
|
||||
) : (
|
||||
transferDetails?.icon
|
||||
);
|
||||
const title = transaction ? getTxTitle(transaction) : transferDetails?.title || '';
|
||||
const value = transaction ? getTxValue(transaction, isOriginator) : transferDetails?.value;
|
||||
const increaseFeeButton = (
|
||||
<IncreaseFeeButton
|
||||
isEnabled={isOriginator && isPending}
|
||||
isHovered={isHovered}
|
||||
isSelected={pathname === RouteUrls.IncreaseStxFee}
|
||||
onIncreaseFee={onIncreaseFee}
|
||||
/>
|
||||
);
|
||||
const txStatus = transaction && <StacksTransactionStatus transaction={transaction} />;
|
||||
const txCaption = (
|
||||
<Text color={color('text-caption')} fontSize={0} whiteSpace="nowrap">
|
||||
{caption}
|
||||
</Text>
|
||||
);
|
||||
const txValue = <Title fontWeight="normal">{value}</Title>;
|
||||
|
||||
return (
|
||||
<Box position="relative" cursor="pointer" onClick={openTxLink} {...bind} {...rest}>
|
||||
<Stack alignItems="center" isInline position="relative" spacing="base-loose" zIndex={2}>
|
||||
{icon}
|
||||
<Flex flexDirection="column" flexGrow={1} minWidth="0px">
|
||||
<SpaceBetween spacing="extra-loose">
|
||||
<TransactionTitle title={title} />
|
||||
{value && <Title fontWeight="normal">{value}</Title>}
|
||||
</SpaceBetween>
|
||||
<SpaceBetween minHeight="loose" minWidth="0px" mt="extra-tight">
|
||||
<Stack alignItems="center" isInline>
|
||||
<Text color={color('text-caption')} fontSize={0} whiteSpace="nowrap">
|
||||
{caption}
|
||||
</Text>
|
||||
{transaction ? <StacksTransactionStatus transaction={transaction} /> : null}
|
||||
</Stack>
|
||||
{!hideIncreaseFeeButton ? (
|
||||
<IncreaseFeeButton
|
||||
isEnabled={isOriginator && isPending}
|
||||
isHovered={isHovered}
|
||||
isSelected={pathname === RouteUrls.IncreaseFee}
|
||||
onIncreaseFee={onIncreaseFee}
|
||||
/>
|
||||
) : null}
|
||||
</SpaceBetween>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<TransactionItemLayout
|
||||
openTxLink={openTxLink}
|
||||
txCaption={txCaption}
|
||||
txIcon={txIcon}
|
||||
txStatus={txStatus}
|
||||
txTitle={<TransactionTitle title={title} />}
|
||||
txValue={txValue}
|
||||
belowCaptionEl={increaseFeeButton}
|
||||
{...bind}
|
||||
{...rest}
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</TransactionItemLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Flex, Stack } from '@stacks/ui';
|
||||
|
||||
import { SpaceBetween } from '@app/components/layout/space-between';
|
||||
|
||||
interface TransactionItemLayoutProps {
|
||||
openTxLink(): void;
|
||||
txCaption: JSX.Element;
|
||||
txTitle: JSX.Element;
|
||||
txValue: JSX.Element;
|
||||
txIcon?: JSX.Element;
|
||||
txStatus?: JSX.Element;
|
||||
belowCaptionEl?: JSX.Element;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
export function TransactionItemLayout({
|
||||
openTxLink,
|
||||
txCaption,
|
||||
txIcon,
|
||||
txStatus,
|
||||
txTitle,
|
||||
txValue,
|
||||
belowCaptionEl,
|
||||
children,
|
||||
...rest
|
||||
}: TransactionItemLayoutProps) {
|
||||
return (
|
||||
<Box position="relative" cursor="pointer" {...rest}>
|
||||
<Stack
|
||||
alignItems="center"
|
||||
isInline
|
||||
onClick={openTxLink}
|
||||
position="relative"
|
||||
spacing="base-loose"
|
||||
zIndex={2}
|
||||
>
|
||||
{txIcon && txIcon}
|
||||
<Flex flexDirection="column" justifyContent="space-between" flexGrow={1} minWidth="0px">
|
||||
<SpaceBetween spacing="extra-loose">
|
||||
{txTitle} {txValue}
|
||||
</SpaceBetween>
|
||||
<Stack alignItems="center" isInline mt="4px">
|
||||
{txCaption} {txStatus && txStatus}
|
||||
{belowCaptionEl ? belowCaptionEl : null}
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { AddressTransactionWithTransfers } from '@stacks/stacks-blockchain-api-types';
|
||||
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import {
|
||||
TransactionListBitcoinTx,
|
||||
TransactionListStacksTx,
|
||||
} from './components/transaction-list/transaction-list.model';
|
||||
|
||||
function createBitcoinTxTypeWrapper(tx: BitcoinTransaction): TransactionListBitcoinTx {
|
||||
function createBitcoinTxTypeWrapper(tx: BitcoinTx): TransactionListBitcoinTx {
|
||||
return {
|
||||
blockchain: 'bitcoin',
|
||||
transaction: tx,
|
||||
@@ -21,7 +21,7 @@ function createStacksTxTypeWrapper(tx: AddressTransactionWithTransfers): Transac
|
||||
};
|
||||
}
|
||||
|
||||
export function convertBitcoinTxsToListType(txs?: BitcoinTransaction[]) {
|
||||
export function convertBitcoinTxsToListType(txs?: BitcoinTx[]) {
|
||||
if (!txs) return [];
|
||||
const confirmedTxs = txs.filter(tx => tx.status.confirmed);
|
||||
return confirmedTxs.map(tx => createBitcoinTxTypeWrapper(tx));
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { MempoolTransaction } from '@stacks/stacks-blockchain-api-types';
|
||||
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import { StacksTransactionItem } from '@app/components/stacks-transaction-item/stacks-transaction-item';
|
||||
|
||||
import { BitcoinTransactionItem } from '../transaction-list/bitcoin-transaction/bitcoin-transaction-item';
|
||||
import { BitcoinTransactionItem } from '../../../../components/bitcoin-transaction-item/bitcoin-transaction-item';
|
||||
import { PendingTransactionListLayout } from './pending-transaction-list.layout';
|
||||
|
||||
interface PendingTransactionListProps {
|
||||
bitcoinTxs: BitcoinTransaction[];
|
||||
bitcoinTxs: BitcoinTx[];
|
||||
stacksTxs: MempoolTransaction[];
|
||||
}
|
||||
export function PendingTransactionList({ bitcoinTxs, stacksTxs }: PendingTransactionListProps) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Box, Flex, Stack } from '@stacks/ui';
|
||||
|
||||
import { usePressable } from '@app/components/item-hover';
|
||||
import { SpaceBetween } from '@app/components/layout/space-between';
|
||||
|
||||
interface BitcoinTransactionItemLayoutProps {
|
||||
openTxLink(): void;
|
||||
txCaption: React.JSX.Element;
|
||||
txIcon: React.JSX.Element;
|
||||
txStatus: React.JSX.Element;
|
||||
txTitle: React.JSX.Element;
|
||||
txValue: React.JSX.Element;
|
||||
}
|
||||
export function BitcoinTransactionItemLayout({
|
||||
openTxLink,
|
||||
txCaption,
|
||||
txIcon,
|
||||
txStatus,
|
||||
txTitle,
|
||||
txValue,
|
||||
...rest
|
||||
}: BitcoinTransactionItemLayoutProps) {
|
||||
const [component, bind] = usePressable(true);
|
||||
|
||||
return (
|
||||
<Box position="relative" cursor="pointer" onClick={openTxLink} {...bind} {...rest}>
|
||||
<Stack alignItems="center" isInline position="relative" spacing="base-loose" zIndex={2}>
|
||||
{txIcon}
|
||||
<Flex flexDirection="column" flexGrow={1} minWidth="0px">
|
||||
<SpaceBetween spacing="extra-loose">
|
||||
{txTitle} {txValue}
|
||||
</SpaceBetween>
|
||||
<Stack alignItems="center" isInline>
|
||||
{txCaption} {txStatus}
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Stack>
|
||||
{component}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { BitcoinTransactionItem } from '@app/components/bitcoin-transaction-item/bitcoin-transaction-item';
|
||||
|
||||
export const BitcoinTransaction = BitcoinTransactionItem;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BitcoinTransactionItem } from '@app/features/activity-list/components/transaction-list/bitcoin-transaction/bitcoin-transaction-item';
|
||||
|
||||
import { BitcoinTransaction } from './bitcoin-transaction/bitcoin-transaction';
|
||||
import { StacksTransaction } from './stacks-transaction/stacks-transaction';
|
||||
import { TransactionListTxs } from './transaction-list.model';
|
||||
|
||||
@@ -9,7 +8,7 @@ interface TransactionListItemProps {
|
||||
export function TransactionListItem({ tx }: TransactionListItemProps) {
|
||||
switch (tx.blockchain) {
|
||||
case 'bitcoin':
|
||||
return <BitcoinTransactionItem transaction={tx.transaction} />;
|
||||
return <BitcoinTransaction transaction={tx.transaction} />;
|
||||
case 'stacks':
|
||||
return <StacksTransaction transaction={tx.transaction} />;
|
||||
default:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AddressTransactionWithTransfers } from '@stacks/stacks-blockchain-api-types';
|
||||
|
||||
import type { Blockchains } from '@shared/models/blockchain.model';
|
||||
import type { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import type { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
export interface TransactionListBitcoinTx {
|
||||
blockchain: Extract<Blockchains, 'bitcoin'>;
|
||||
transaction: BitcoinTransaction;
|
||||
transaction: BitcoinTx;
|
||||
}
|
||||
|
||||
export interface TransactionListStacksTx {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Money } from '@shared/models/money.model';
|
||||
|
||||
import { formatMoney } from '@app/common/money/format-money';
|
||||
import { AvailableBalance } from '@app/components/available-balance';
|
||||
import { BitcoinCustomFee } from '@app/components/bitcoin-custom-fee/bitcoin-custom-fee';
|
||||
import { MAX_FEE_RATE_MULTIPLIER } from '@app/components/bitcoin-custom-fee/hooks/use-bitcoin-custom-fee';
|
||||
import { OnChooseFeeArgs } from '@app/components/bitcoin-fees-list/bitcoin-fees-list';
|
||||
import { BitcoinCustomFee } from '@app/features/bitcoin-choose-fee/bitcoin-custom-fee/bitcoin-custom-fee';
|
||||
import { useNativeSegwitBalance } from '@app/query/bitcoin/balance/bitcoin-balances.query';
|
||||
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
|
||||
|
||||
@@ -28,6 +29,7 @@ interface BitcoinChooseFeeProps {
|
||||
recipient: string;
|
||||
recommendedFeeRate: string;
|
||||
showError: boolean;
|
||||
maxRecommendedFeeRate?: number;
|
||||
}
|
||||
export function BitcoinChooseFee({
|
||||
amount,
|
||||
@@ -40,6 +42,7 @@ export function BitcoinChooseFee({
|
||||
recipient,
|
||||
recommendedFeeRate,
|
||||
showError,
|
||||
maxRecommendedFeeRate = 0,
|
||||
}: BitcoinChooseFeeProps) {
|
||||
const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner();
|
||||
const btcBalance = useNativeSegwitBalance(nativeSegwitSigner.address);
|
||||
@@ -71,6 +74,7 @@ export function BitcoinChooseFee({
|
||||
onValidateBitcoinSpend={onValidateBitcoinSpend}
|
||||
recipient={recipient}
|
||||
setCustomFeeInitialValue={setCustomFeeInitialValue}
|
||||
maxCustomFeeRate={maxRecommendedFeeRate * MAX_FEE_RATE_MULTIPLIER}
|
||||
/>
|
||||
}
|
||||
feesList={feesList}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Stack } from '@stacks/ui';
|
||||
import { Formik } from 'formik';
|
||||
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { useBtcAssetBalance } from '@app/common/hooks/balance/btc/use-btc-balance';
|
||||
import { formatMoney } from '@app/common/money/format-money';
|
||||
import { btcToSat } from '@app/common/money/unit-conversion';
|
||||
import { getBitcoinTxValue } from '@app/common/transactions/bitcoin/utils';
|
||||
import { BitcoinCustomFeeFiat } from '@app/components/bitcoin-custom-fee/bitcoin-custom-fee-fiat';
|
||||
import { BitcoinTransactionItem } from '@app/components/bitcoin-transaction-item/bitcoin-transaction-item';
|
||||
import { LoadingSpinner } from '@app/components/loading-spinner';
|
||||
import { TextInputField } from '@app/components/text-input-field';
|
||||
import { Caption } from '@app/components/typography';
|
||||
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
|
||||
|
||||
import { useBtcIncreaseFee } from '../hooks/use-btc-increase-fee';
|
||||
import { IncreaseFeeActions } from './increase-fee-actions';
|
||||
|
||||
const feeInputLabel = 'sats/vB';
|
||||
|
||||
interface IncreaseBtcFeeFormProps {
|
||||
btcTx: BitcoinTx;
|
||||
}
|
||||
|
||||
export function IncreaseBtcFeeForm({ btcTx }: IncreaseBtcFeeFormProps) {
|
||||
const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner();
|
||||
const navigate = useNavigate();
|
||||
const currentBitcoinAddress = nativeSegwitSigner.address;
|
||||
const { btcAvailableAssetBalance } = useBtcAssetBalance(currentBitcoinAddress);
|
||||
const { isBroadcasting, sizeInfo, onSubmit, validationSchema, recipient } =
|
||||
useBtcIncreaseFee(btcTx);
|
||||
|
||||
const balance = formatMoney(btcAvailableAssetBalance.balance);
|
||||
|
||||
if (isBroadcasting) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
const initialFeeRate = `${(btcTx.fee / sizeInfo.txVBytes).toFixed(0)}`;
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{ feeRate: initialFeeRate }}
|
||||
onSubmit={onSubmit}
|
||||
validateOnChange={false}
|
||||
validateOnBlur={false}
|
||||
validateOnMount={false}
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
<Stack spacing="extra-loose">
|
||||
{btcTx && <BitcoinTransactionItem position="relative" transaction={btcTx} zIndex={99} />}
|
||||
<Stack spacing="base">
|
||||
<Stack spacing="extra-tight">
|
||||
<TextInputField label={feeInputLabel} name="feeRate" placeholder={feeInputLabel} />
|
||||
<BitcoinCustomFeeFiat
|
||||
recipient={recipient}
|
||||
isSendingMax={false}
|
||||
amount={Math.abs(
|
||||
btcToSat(getBitcoinTxValue(currentBitcoinAddress, btcTx)).toNumber()
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{btcAvailableAssetBalance && <Caption>Balance: {balance}</Caption>}
|
||||
</Stack>
|
||||
<IncreaseFeeActions
|
||||
isDisabled={false}
|
||||
onCancel={() => {
|
||||
navigate(RouteUrls.Home);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,22 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button, Stack } from '@stacks/ui';
|
||||
import { useField, useFormikContext } from 'formik';
|
||||
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading';
|
||||
import { stxToMicroStx } from '@app/common/money/unit-conversion';
|
||||
import { useWalletType } from '@app/common/use-wallet-type';
|
||||
import { useRawTxIdState } from '@app/store/transactions/raw.hooks';
|
||||
|
||||
interface IncreaseFeeActionsProps {
|
||||
currentFee: number;
|
||||
isDisabled: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
export function IncreaseFeeActions(props: IncreaseFeeActionsProps) {
|
||||
const { currentFee } = props;
|
||||
const [field] = useField('fee');
|
||||
const { onCancel, isDisabled } = props;
|
||||
|
||||
const { handleSubmit } = useFormikContext();
|
||||
const { isLoading } = useLoading(LoadingKeys.INCREASE_FEE_DRAWER);
|
||||
const [, setRawTxId] = useRawTxIdState();
|
||||
const { whenWallet } = useWalletType();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const newFee = field.value;
|
||||
const isSame = currentFee === stxToMicroStx(newFee).toNumber();
|
||||
const actionText = whenWallet({ ledger: 'Confirm on Ledger', software: 'Submit' });
|
||||
|
||||
const onCancel = () => {
|
||||
setRawTxId(null);
|
||||
navigate(RouteUrls.Home);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack isInline>
|
||||
<Button onClick={onCancel} flexGrow={1} borderRadius="10px" mode="tertiary">
|
||||
@@ -42,7 +28,7 @@ export function IncreaseFeeActions(props: IncreaseFeeActionsProps) {
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
borderRadius="10px"
|
||||
isDisabled={isSame}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Stack } from '@stacks/ui';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import { Formik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { useRefreshAllAccountData } from '@app/common/hooks/account/use-refresh-all-account-data';
|
||||
import { useStxBalance } from '@app/common/hooks/balance/stx/use-stx-balance';
|
||||
import { microStxToStx, stxToMicroStx } from '@app/common/money/unit-conversion';
|
||||
@@ -26,9 +29,10 @@ import { useSelectedTx } from '../hooks/use-selected-tx';
|
||||
import { IncreaseFeeActions } from './increase-fee-actions';
|
||||
import { IncreaseFeeField } from './increase-fee-field';
|
||||
|
||||
export function IncreaseFeeForm() {
|
||||
export function IncreaseStxFeeForm() {
|
||||
const refreshAccountData = useRefreshAllAccountData();
|
||||
const tx = useSelectedTx();
|
||||
const navigate = useNavigate();
|
||||
const [, setTxId] = useRawTxIdState();
|
||||
const replaceByFee = useReplaceByFeeSoftwareWalletSubmitCallBack();
|
||||
const { data: balances } = useCurrentStacksAccountAnchoredBalances();
|
||||
@@ -87,7 +91,7 @@ export function IncreaseFeeForm() {
|
||||
validateOnMount={false}
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
{() => (
|
||||
{props => (
|
||||
<Stack spacing="extra-loose">
|
||||
{tx && <StacksTransactionItem position="relative" transaction={tx} zIndex={99} />}
|
||||
<Stack spacing="base">
|
||||
@@ -98,7 +102,13 @@ export function IncreaseFeeForm() {
|
||||
</Caption>
|
||||
)}
|
||||
</Stack>
|
||||
<IncreaseFeeActions currentFee={fee} />
|
||||
<IncreaseFeeActions
|
||||
onCancel={() => {
|
||||
setTxId(null);
|
||||
navigate(RouteUrls.Home);
|
||||
}}
|
||||
isDisabled={stxToMicroStx(props.values.fee).isEqualTo(fee)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Formik>
|
||||
@@ -0,0 +1,172 @@
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import * as btc from '@scure/btc-signer';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
|
||||
import { useBtcAssetBalance } from '@app/common/hooks/balance/btc/use-btc-balance';
|
||||
import { queryClient } from '@app/common/persistence';
|
||||
import {
|
||||
getBitcoinTxValue,
|
||||
getRecipientAddressFromOutput,
|
||||
getSizeInfo,
|
||||
} from '@app/common/transactions/bitcoin/utils';
|
||||
import { MAX_FEE_RATE_MULTIPLIER } from '@app/components/bitcoin-custom-fee/hooks/use-bitcoin-custom-fee';
|
||||
import { useBitcoinFeesList } from '@app/components/bitcoin-fees-list/use-bitcoin-fees-list';
|
||||
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks';
|
||||
import { useBitcoinBroadcastTransaction } from '@app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction';
|
||||
import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
|
||||
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
|
||||
|
||||
export function useBtcIncreaseFee(btcTx: BitcoinTx) {
|
||||
const navigate = useNavigate();
|
||||
const networkMode = useBitcoinScureLibNetworkConfig();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
address: currentBitcoinAddress,
|
||||
sign,
|
||||
publicKeychain: currentAddressIndexKeychain,
|
||||
} = useCurrentAccountNativeSegwitIndexZeroSigner();
|
||||
const { data: utxos = [], refetch } = useCurrentNativeSegwitUtxos();
|
||||
const { broadcastTx, isBroadcasting } = useBitcoinBroadcastTransaction();
|
||||
const recipient = getRecipientAddressFromOutput(btcTx.vout, currentBitcoinAddress) || '';
|
||||
const sizeInfo = getSizeInfo({
|
||||
inputLength: btcTx.vin.length,
|
||||
recipient,
|
||||
outputLength: btcTx.vout.length,
|
||||
});
|
||||
|
||||
const { btcAvailableAssetBalance } = useBtcAssetBalance(currentBitcoinAddress);
|
||||
const sendingAmount = getBitcoinTxValue(currentBitcoinAddress, btcTx);
|
||||
const { feesList } = useBitcoinFeesList({
|
||||
amount: Number(sendingAmount),
|
||||
isSendingMax: false,
|
||||
recipient,
|
||||
utxos,
|
||||
});
|
||||
|
||||
function generateTx(payload: { feeRate: string; tx: BitcoinTx }) {
|
||||
const newTx = new btc.Transaction();
|
||||
const { vin, vout, fee: prevFee } = payload.tx;
|
||||
const p2wpkh = btc.p2wpkh(currentAddressIndexKeychain.publicKey!, networkMode);
|
||||
|
||||
vin.forEach(input => {
|
||||
newTx.addInput({
|
||||
txid: input.txid,
|
||||
index: input.vout,
|
||||
sequence: input.sequence + 1,
|
||||
witnessUtxo: {
|
||||
// script = 0014 + pubKeyHash
|
||||
script: p2wpkh.script,
|
||||
amount: BigInt(input.prevout.value),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const newFee = Math.ceil(sizeInfo.txVBytes * Number(payload.feeRate));
|
||||
const feeDiff = newFee - prevFee;
|
||||
|
||||
vout.forEach(output => {
|
||||
if (output.scriptpubkey_address === currentBitcoinAddress) {
|
||||
const outputDiff = output.value - feeDiff;
|
||||
|
||||
if (outputDiff < 0) {
|
||||
void analytics.track('bitcoin_rbf_fee_increase_error', {
|
||||
outputDiff,
|
||||
});
|
||||
throw new Error('Previous tx inputs cannot cover new fee');
|
||||
}
|
||||
|
||||
newTx.addOutputAddress(currentBitcoinAddress, BigInt(outputDiff), networkMode);
|
||||
return;
|
||||
}
|
||||
newTx.addOutputAddress(recipient, BigInt(output.value), networkMode);
|
||||
});
|
||||
|
||||
sign(newTx);
|
||||
newTx.finalize();
|
||||
|
||||
return { hex: newTx.hex };
|
||||
}
|
||||
|
||||
async function initiateTransaction(tx: string) {
|
||||
await broadcastTx({
|
||||
tx,
|
||||
async onSuccess(txid) {
|
||||
navigate(RouteUrls.IncreaseFeeSent);
|
||||
void analytics.track('increase_fee_transaction', {
|
||||
symbol: 'btc',
|
||||
txid,
|
||||
});
|
||||
await refetch();
|
||||
void queryClient.invalidateQueries({ queryKey: ['btc-txs-by-address'] });
|
||||
},
|
||||
onError,
|
||||
delayTime: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(values: { feeRate: string }) {
|
||||
try {
|
||||
const { hex } = generateTx({ feeRate: values.feeRate, tx: btcTx });
|
||||
await initiateTransaction(hex);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
toast.error(message);
|
||||
navigate(RouteUrls.Home);
|
||||
}
|
||||
|
||||
const validationSchema = yup.object({
|
||||
feeRate: yup
|
||||
.number()
|
||||
.integer('Fee must be a whole number')
|
||||
.required('Fee is required')
|
||||
.test({
|
||||
message: 'Fee rate cannot be less or equal to previous',
|
||||
test(value) {
|
||||
const bnValue = new BigNumber(value);
|
||||
const prevFee = new BigNumber(btcTx.fee);
|
||||
|
||||
return prevFee.isLessThan(bnValue.multipliedBy(sizeInfo.txVBytes));
|
||||
},
|
||||
})
|
||||
.test({
|
||||
message: 'Fee is too high',
|
||||
test(value) {
|
||||
const bnValue = new BigNumber(value);
|
||||
|
||||
// check if fee is higher than 50 times the highest fee
|
||||
if (
|
||||
feesList.length > 0 &&
|
||||
bnValue.isGreaterThan(feesList[0].feeRate * MAX_FEE_RATE_MULTIPLIER)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if fee is higher than the available balance
|
||||
return bnValue.isLessThanOrEqualTo(btcAvailableAssetBalance.balance.amount);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
generateTx,
|
||||
initiateTransaction,
|
||||
isBroadcasting,
|
||||
sizeInfo,
|
||||
onSubmit,
|
||||
validationSchema,
|
||||
recipient,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import get from 'lodash.get';
|
||||
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { IncreaseBtcFeeForm } from './components/increase-btc-fee-form';
|
||||
import { IncreaseFeeDrawer } from './increase-fee-drawer';
|
||||
|
||||
function useIncreaseBtcFeeDrawerState() {
|
||||
const location = useLocation();
|
||||
return {
|
||||
tx: get(location.state, 'btcTx') as BitcoinTx,
|
||||
};
|
||||
}
|
||||
|
||||
export function IncreaseBtcFeeDrawer() {
|
||||
const { tx } = useIncreaseBtcFeeDrawerState();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const onClose = () => {
|
||||
navigate(RouteUrls.Home);
|
||||
};
|
||||
|
||||
return (
|
||||
tx && (
|
||||
<IncreaseFeeDrawer
|
||||
feeForm={<IncreaseBtcFeeForm btcTx={tx} />}
|
||||
onClose={onClose}
|
||||
isShowing={location.pathname === RouteUrls.IncreaseBtcFee}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,21 @@
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Suspense } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { Flex, Spinner, Stack } from '@stacks/ui';
|
||||
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading';
|
||||
import { BaseDrawer } from '@app/components/drawer/base-drawer';
|
||||
import { Caption } from '@app/components/typography';
|
||||
import { useRawTxIdState } from '@app/store/transactions/raw.hooks';
|
||||
|
||||
import { IncreaseFeeForm } from './components/increase-fee-form';
|
||||
interface IncreaseFeeDrawerProps {
|
||||
feeForm: JSX.Element;
|
||||
onClose: () => void;
|
||||
isShowing: boolean;
|
||||
}
|
||||
|
||||
export function IncreaseFeeDrawer() {
|
||||
const [rawTxId, setRawTxId] = useRawTxIdState();
|
||||
const { isLoading, setIsIdle } = useLoading(LoadingKeys.INCREASE_FEE_DRAWER);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const txIdFromParams = searchParams.get('txId');
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawTxId && txIdFromParams) {
|
||||
setRawTxId(txIdFromParams);
|
||||
}
|
||||
if (isLoading && !rawTxId) {
|
||||
setIsIdle();
|
||||
}
|
||||
}, [isLoading, rawTxId, setIsIdle, setRawTxId, txIdFromParams]);
|
||||
|
||||
const onClose = () => {
|
||||
setRawTxId(null);
|
||||
navigate(RouteUrls.Home);
|
||||
};
|
||||
|
||||
return rawTxId ? (
|
||||
export function IncreaseFeeDrawer({ feeForm, onClose, isShowing }: IncreaseFeeDrawerProps) {
|
||||
return (
|
||||
<>
|
||||
<BaseDrawer
|
||||
isShowing={location.pathname === RouteUrls.IncreaseFee}
|
||||
onClose={onClose}
|
||||
title="Increase transaction fee"
|
||||
>
|
||||
<BaseDrawer isShowing={isShowing} onClose={onClose} title="Increase transaction fee">
|
||||
<Stack px="loose" spacing="loose" pb="extra-loose">
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -50,14 +25,14 @@ export function IncreaseFeeDrawer() {
|
||||
}
|
||||
>
|
||||
<Caption>
|
||||
If your transaction has been pending for a long time, its fee might not be high enough
|
||||
to be included in a block. Increase the fee and try again.
|
||||
If your transaction is pending for a long time, its fee might not be high enough to be
|
||||
included in a clock. Update the fee for a higher value and try again.
|
||||
</Caption>
|
||||
<IncreaseFeeForm />
|
||||
{feeForm && feeForm}
|
||||
</Suspense>
|
||||
</Stack>
|
||||
</BaseDrawer>
|
||||
<Outlet />
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FiCheck } from 'react-icons/fi';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Box, Flex } from '@stacks/ui';
|
||||
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { BaseDrawer } from '@app/components/drawer/base-drawer';
|
||||
|
||||
export function IncreaseFeeSentDrawer() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isShowing = location.pathname === RouteUrls.IncreaseFeeSent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseDrawer isShowing={isShowing} onClose={() => navigate(RouteUrls.Home)} title="Confirmed">
|
||||
<Flex px="loose" pb="extra-loose" justifyContent="center">
|
||||
<Box size="32px" as={FiCheck} mt="2px" />
|
||||
</Flex>
|
||||
</BaseDrawer>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { RouteUrls } from '@shared/route-urls';
|
||||
|
||||
import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading';
|
||||
import { useRawTxIdState } from '@app/store/transactions/raw.hooks';
|
||||
|
||||
import { IncreaseStxFeeForm } from './components/increase-stx-fee-form';
|
||||
import { IncreaseFeeDrawer } from './increase-fee-drawer';
|
||||
|
||||
export function IncreaseStxFeeDrawer() {
|
||||
const [rawTxId, setRawTxId] = useRawTxIdState();
|
||||
const { isLoading, setIsIdle } = useLoading(LoadingKeys.INCREASE_FEE_DRAWER);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const txIdFromParams = searchParams.get('txId');
|
||||
|
||||
const onClose = () => {
|
||||
setRawTxId(null);
|
||||
navigate(RouteUrls.Home);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawTxId && txIdFromParams) {
|
||||
setRawTxId(txIdFromParams);
|
||||
}
|
||||
if (isLoading && !rawTxId) {
|
||||
setIsIdle();
|
||||
}
|
||||
}, [isLoading, rawTxId, setIsIdle, setRawTxId, txIdFromParams]);
|
||||
|
||||
return (
|
||||
<IncreaseFeeDrawer
|
||||
feeForm={<IncreaseStxFeeForm />}
|
||||
onClose={onClose}
|
||||
isShowing={location.pathname === RouteUrls.IncreaseStxFee}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export function RpcSendTransferChooseFee() {
|
||||
recipient={address}
|
||||
recommendedFeeRate={recommendedFeeRate}
|
||||
showError={showInsufficientBalanceError}
|
||||
maxRecommendedFeeRate={feesList[0]?.feeRate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useRpcSendTransferState() {
|
||||
}
|
||||
|
||||
export function RpcSendTransferContainer() {
|
||||
const [selectedFeeType, setSelectedFeeType] = useState<BtcFeeType | null>(BtcFeeType.Standard);
|
||||
const [selectedFeeType, setSelectedFeeType] = useState<BtcFeeType | null>(null);
|
||||
const { origin } = useRpcSendTransfer();
|
||||
|
||||
useRouteHeader(<PopupHeader displayAddresssBalanceOf="all" />);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useSendInscriptionState() {
|
||||
}
|
||||
|
||||
export function SendInscriptionContainer() {
|
||||
const [selectedFeeType, setSelectedFeeType] = useState<BtcFeeType | null>(BtcFeeType.Standard);
|
||||
const [selectedFeeType, setSelectedFeeType] = useState<BtcFeeType | null>(null);
|
||||
|
||||
return (
|
||||
<SendInscriptionLoader>
|
||||
|
||||
@@ -59,6 +59,7 @@ export function SendInscriptionChooseFee() {
|
||||
recipient={recipient}
|
||||
recommendedFeeRate={recommendedFeeRate}
|
||||
showError={showInsufficientBalanceError}
|
||||
maxRecommendedFeeRate={feesList[0]?.feeRate}
|
||||
/>
|
||||
</BaseDrawer>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,6 @@ export function useSendBitcoinAssetContextState() {
|
||||
}
|
||||
|
||||
export function SendBitcoinAssetContainer() {
|
||||
const [selectedFeeType, setSelectedFeeType] = useState<BtcFeeType | null>(BtcFeeType.Standard);
|
||||
const [selectedFeeType, setSelectedFeeType] = useState<BtcFeeType | null>(null);
|
||||
return <Outlet context={{ selectedFeeType, setSelectedFeeType }} />;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ export function BrcChooseFee() {
|
||||
recommendedFeeRate={recommendedFeeRate}
|
||||
recipient={recipient}
|
||||
showError={showInsufficientBalanceError}
|
||||
maxRecommendedFeeRate={feesList[0]?.feeRate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export function BtcChooseFee() {
|
||||
recipient={txValues.recipient}
|
||||
recommendedFeeRate={recommendedFeeRate}
|
||||
showError={showInsufficientBalanceError}
|
||||
maxRecommendedFeeRate={feesList[0]?.feeRate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useRouteHeader } from '@app/common/hooks/use-route-header';
|
||||
import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money';
|
||||
import { formatMoneyPadded, i18nFormatCurrency } from '@app/common/money/format-money';
|
||||
import { satToBtc } from '@app/common/money/unit-conversion';
|
||||
import { queryClient } from '@app/common/persistence';
|
||||
import { FormAddressDisplayer } from '@app/components/address-displayer/form-address-displayer';
|
||||
import {
|
||||
InfoCard,
|
||||
@@ -82,6 +83,11 @@ export function BtcSendFormConfirmation() {
|
||||
navigate(RouteUrls.SentBtcTxSummary.replace(':txId', `${txid}`), {
|
||||
state: formBtcTxSummaryState(txid),
|
||||
});
|
||||
|
||||
// invalidate txs query after some time to ensure that the new tx will be shown in the list
|
||||
setTimeout(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['btc-txs-by-address'] });
|
||||
}, 2000);
|
||||
},
|
||||
onError(e) {
|
||||
nav.toErrorPage(e);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
export const mockAddress = 'tb1qxy5r9rlmpcxgwp92x2594q3gg026y4kdv2rsl8';
|
||||
|
||||
// multiple inputs and outputs
|
||||
export const mockPendingTxs1: BitcoinTransaction[] = [
|
||||
export const mockPendingTxs1: BitcoinTx[] = [
|
||||
{
|
||||
txid: '7438bd24579108a85fbf77756e7b9c87238b947dd0f858f6e30bad4f4d6d557a',
|
||||
version: 2,
|
||||
@@ -74,7 +74,7 @@ export const mockPendingTxs1: BitcoinTransaction[] = [
|
||||
];
|
||||
|
||||
// multiple transactions
|
||||
export const mockPendingTxs2: BitcoinTransaction[] = [
|
||||
export const mockPendingTxs2: BitcoinTx[] = [
|
||||
{
|
||||
txid: '7438bd24579108a85fbf77756e7b9c87238b947dd0f858f6e30bad4f4d6d557a',
|
||||
version: 2,
|
||||
@@ -212,7 +212,7 @@ export const mockPendingTxs2: BitcoinTransaction[] = [
|
||||
];
|
||||
|
||||
// one input and many outputs
|
||||
export const mockPendingTxs3: BitcoinTransaction[] = [
|
||||
export const mockPendingTxs3: BitcoinTx[] = [
|
||||
{
|
||||
txid: '7438bd24579108a85fbf77756e7b9c87238b947dd0f858f6e30bad4f4d6d557a',
|
||||
version: 2,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { createMoney } from '@shared/models/money.model';
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import { sumNumbers } from '@app/common/math/helpers';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useGetBitcoinTransactionsByAddressQuery } from './transactions-by-addre
|
||||
import { useAllSpendableNativeSegwitUtxos } from './utxos-by-address.hooks';
|
||||
|
||||
function useFilterAddressPendingTransactions() {
|
||||
return useCallback((txs: BitcoinTransaction[]) => {
|
||||
return useCallback((txs: BitcoinTx[]) => {
|
||||
return txs.filter(tx => !tx.status.confirmed);
|
||||
}, []);
|
||||
}
|
||||
@@ -35,10 +35,7 @@ export function useBitcoinPendingTransactionsInputs(address: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateOutboundPendingTxsValue(
|
||||
pendingTxs: BitcoinTransaction[],
|
||||
address: string
|
||||
) {
|
||||
export function calculateOutboundPendingTxsValue(pendingTxs: BitcoinTx[], address: string) {
|
||||
// sum all inputs
|
||||
const sumInputs = sumNumbers(pendingTxs.flatMap(tx => tx.vin.map(input => input.prevout.value)));
|
||||
|
||||
@@ -56,7 +53,7 @@ export function calculateOutboundPendingTxsValue(
|
||||
|
||||
// filter out pending txs that have inputs that are not in the utxos list to prevent double extraction
|
||||
function filterMissingUtxosPendingTxs(
|
||||
pendingTxs: BitcoinTransaction[],
|
||||
pendingTxs: BitcoinTx[],
|
||||
utxos: UtxoResponseItem[],
|
||||
address: string
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import { AppUseQueryConfig } from '@app/query/query-config';
|
||||
import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
|
||||
@@ -9,9 +9,9 @@ const staleTime = 5 * 1000;
|
||||
|
||||
const queryOptions = { staleTime, refetchInterval: staleTime };
|
||||
|
||||
export function useGetBitcoinTransactionsByAddressQuery<T extends unknown = BitcoinTransaction[]>(
|
||||
export function useGetBitcoinTransactionsByAddressQuery<T extends unknown = BitcoinTx[]>(
|
||||
address: string,
|
||||
options?: AppUseQueryConfig<BitcoinTransaction[], T>
|
||||
options?: AppUseQueryConfig<BitcoinTx[], T>
|
||||
) {
|
||||
const client = useBitcoinClient();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as btc from '@scure/btc-signer';
|
||||
import { bytesToHex } from '@stacks/common';
|
||||
import { UseQueryResult, useQueries, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { BitcoinTransaction } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
|
||||
|
||||
import { AppUseQueryConfig } from '@app/query/query-config';
|
||||
import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
|
||||
@@ -42,7 +42,7 @@ const queryOptions = {
|
||||
|
||||
export function useGetBitcoinTransactionQueries(
|
||||
inputs: btc.TransactionInputRequired[]
|
||||
): UseQueryResult<BitcoinTransaction>[] {
|
||||
): UseQueryResult<BitcoinTx>[] {
|
||||
const client = useBitcoinClient();
|
||||
|
||||
return useQueries({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
|
||||
|
||||
interface BroadcastCallbackArgs {
|
||||
tx: string;
|
||||
delayTime?: number;
|
||||
onSuccess?(txid: string): void;
|
||||
onError?(error: Error): void;
|
||||
onFinally?(): void;
|
||||
@@ -17,12 +18,12 @@ export function useBitcoinBroadcastTransaction() {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const broadcastTx = useCallback(
|
||||
async ({ tx, onSuccess, onError, onFinally }: BroadcastCallbackArgs) => {
|
||||
async ({ tx, onSuccess, onError, onFinally, delayTime = 700 }: BroadcastCallbackArgs) => {
|
||||
try {
|
||||
setIsBroadcasting(true);
|
||||
const resp = await client.transactionsApi.broadcastTransaction(tx);
|
||||
// simulate slower broadcast time to allow mempool refresh
|
||||
await delay(700);
|
||||
await delay(delayTime);
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const txid = await resp.text();
|
||||
onSuccess?.(txid);
|
||||
|
||||
@@ -15,7 +15,9 @@ import { ActivityList } from '@app/features/activity-list/activity-list';
|
||||
import { BalancesList } from '@app/features/balances-list/balances-list';
|
||||
import { Container } from '@app/features/container/container';
|
||||
import { EditNonceDrawer } from '@app/features/edit-nonce-drawer/edit-nonce-drawer';
|
||||
import { IncreaseFeeDrawer } from '@app/features/increase-fee-drawer/increase-fee-drawer';
|
||||
import { IncreaseBtcFeeDrawer } from '@app/features/increase-fee-drawer/increase-btc-fee-drawer';
|
||||
import { IncreaseFeeSentDrawer } from '@app/features/increase-fee-drawer/increase-fee-sent-drawer';
|
||||
import { IncreaseStxFeeDrawer } from '@app/features/increase-fee-drawer/increase-stx-fee-drawer';
|
||||
import { ledgerJwtSigningRoutes } from '@app/features/ledger/flows/jwt-signing/ledger-sign-jwt.routes';
|
||||
import { requestBitcoinKeysRoutes } from '@app/features/ledger/flows/request-bitcoin-keys/ledger-request-bitcoin-keys';
|
||||
import { requestStacksKeysRoutes } from '@app/features/ledger/flows/request-stacks-keys/ledger-request-stacks-keys';
|
||||
@@ -108,9 +110,12 @@ function useAppRoutes() {
|
||||
{requestStacksKeysRoutes}
|
||||
<Route path={RouteUrls.RetriveTaprootFunds} element={<RetrieveTaprootToNativeSegwit />} />
|
||||
|
||||
<Route path={RouteUrls.IncreaseFee} element={<IncreaseFeeDrawer />}>
|
||||
<Route path={RouteUrls.IncreaseStxFee} element={<IncreaseStxFeeDrawer />}>
|
||||
{ledgerStacksTxSigningRoutes}
|
||||
</Route>
|
||||
<Route path={RouteUrls.IncreaseBtcFee} element={<IncreaseBtcFeeDrawer />} />
|
||||
<Route path={RouteUrls.IncreaseFeeSent} element={<IncreaseFeeSentDrawer />} />
|
||||
|
||||
<Route path={RouteUrls.Receive} element={<ReceiveModal />} />
|
||||
<Route path={RouteUrls.ReceiveCollectible} element={<ReceiveCollectibleModal />} />
|
||||
<Route
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useReplaceByFeeSoftwareWalletSubmitCallBack = () => {
|
||||
await submitTransaction({
|
||||
onSuccess() {
|
||||
setTxId(null);
|
||||
navigate(RouteUrls.Home);
|
||||
navigate(RouteUrls.IncreaseFeeSent);
|
||||
},
|
||||
onError() {
|
||||
logger.error('Error submitting transaction');
|
||||
|
||||
@@ -52,7 +52,7 @@ interface BitcoinTransactionVectorInput {
|
||||
witness: string[];
|
||||
}
|
||||
|
||||
export interface BitcoinTransaction {
|
||||
export interface BitcoinTx {
|
||||
fee: number;
|
||||
locktime: number;
|
||||
size: number;
|
||||
|
||||
@@ -33,7 +33,9 @@ export enum RouteUrls {
|
||||
FundReceive = '/fund/receive',
|
||||
FundReceiveStx = '/fund/receive/stx',
|
||||
FundReceiveBtc = '/fund/receive/btc',
|
||||
IncreaseFee = '/increase-fee',
|
||||
IncreaseStxFee = '/increase-fee/stx',
|
||||
IncreaseBtcFee = '/increase-fee/btc',
|
||||
IncreaseFeeSent = '/increase-fee/sent',
|
||||
Receive = '/receive',
|
||||
ReceiveCollectible = '/receive/collectible',
|
||||
ReceiveCollectibleOrdinal = '/receive/collectible/ordinal',
|
||||
|
||||
Reference in New Issue
Block a user