diff --git a/src/app/common/utils/get-burn-address.ts b/src/app/common/utils/get-burn-address.ts new file mode 100644 index 00000000000..528abeee89a --- /dev/null +++ b/src/app/common/utils/get-burn-address.ts @@ -0,0 +1,13 @@ +import type { StacksNetwork } from '@stacks/network'; +import { ChainID } from '@stacks/transactions'; + +export function getBurnAddress(network: StacksNetwork): string { + switch (network.chainId) { + case ChainID.Mainnet: + return 'SP00000000000003SCNSJTCSE62ZF4MSE'; + case ChainID.Testnet: + return 'ST000000000000000000002AMW42H'; + default: + return 'ST000000000000000000002AMW42H'; + } +} diff --git a/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx b/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx index 0348253a62a..c4d642f4eff 100644 --- a/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx +++ b/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx @@ -18,10 +18,10 @@ import { isBitcoinTxInbound, } from '@app/common/transactions/bitcoin/utils'; import { openInNewTab } from '@app/common/utils/open-in-new-tab'; -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 { TransactionActionButton } from '../stacks-transaction-item/transaction-action-button'; import { TransactionItemLayout } from '../transaction-item/transaction-item.layout'; import { BitcoinTransactionIcon } from './bitcoin-transaction-icon'; import { InscriptionIcon } from './bitcoin-transaction-inscription-icon'; @@ -74,10 +74,11 @@ export function BitcoinTransactionItem({ transaction }: BitcoinTransactionItemPr const title = inscriptionData ? `Ordinal inscription #${inscriptionData.number}` : 'Bitcoin'; const increaseFeeButton = ( - ); diff --git a/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx b/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx index 4517b641879..c4e3edb90a4 100644 --- a/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx +++ b/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx @@ -1,6 +1,9 @@ -import { useLocation, useNavigate } from 'react-router-dom'; +import { useMatch, useNavigate } from 'react-router-dom'; + +import { HStack } from 'leather-styles/jsx'; import { StacksTx } from '@leather.io/models'; +import { ChevronsRightIcon, CloseIcon } from '@leather.io/ui'; import { RouteUrls } from '@shared/route-urls'; import { analytics } from '@shared/utils/analytics'; @@ -19,9 +22,21 @@ import { TransactionTitle } from '@app/components/transaction/transaction-title' import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.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'; +import { TransactionActionButton } from './transaction-action-button'; + +type TransactionAction = 'increaseFee' | 'cancel'; + +interface ActionRouteMap { + increaseFee: keyof typeof RouteUrls; + cancel: keyof typeof RouteUrls; +} + +const actionRouteMap: ActionRouteMap = { + increaseFee: 'IncreaseStxFee', + cancel: 'CancelStxTransaction', +}; interface StacksTransactionItemProps { caption?: string; @@ -42,10 +57,13 @@ export function StacksTransactionItem({ const { handleOpenStacksTxLink } = useStacksExplorerLink(); const currentAccount = useCurrentStacksAccount(); - const { pathname } = useLocation(); const navigate = useNavigate(); const { whenWallet } = useWalletType(); + const cancelTransactionMatch = useMatch('/cancel-transaction/stx/:txid'); + const increaseFeeMatch = useMatch('/increase-fee/stx/:txid'); + const isTransactionActionRoute = !!cancelTransactionMatch || !!increaseFeeMatch; + const hasTransferDetailsData = !!caption && !!title && !!value && !!link; if (!transaction && !hasTransferDetailsData) return null; @@ -56,10 +74,11 @@ export function StacksTransactionItem({ }); }; - const onIncreaseFee = () => { + const handleTransactionAction = (action: TransactionAction): void => { if (!transaction) return; - const routeUrl = RouteUrls.IncreaseStxFee.replace(':txid', transaction.tx_id); + const routeKey = actionRouteMap[action]; + const routeUrl = RouteUrls[routeKey].replace(':txid', transaction.tx_id); whenWallet({ ledger: () => @@ -78,22 +97,40 @@ export function StacksTransactionItem({ const txIcon = transaction ? : icon; const txTitle = transaction ? getTxTitle(transaction) : title || ''; const txValue = transaction ? getTxValue(transaction, isOriginator) : value; - const increaseFeeButton = ( - + + const actionButtonGroup = ( + + } + maxWidth="110px" + isEnabled={isOriginator && isPending} + isSelected={isTransactionActionRoute} + onButtonClick={() => handleTransactionAction('cancel')} + label="Cancel" + /> + } + maxWidth="110px" + isEnabled={isOriginator && isPending} + isSelected={isTransactionActionRoute} + onButtonClick={() => handleTransactionAction('increaseFee')} + label="Speed Up" + /> + ); + + const txIsPending = transaction && transaction.tx_status == 'pending'; const txStatus = transaction && ; return ( } txValue={txValue} /> diff --git a/src/app/components/stacks-transaction-item/increase-fee-button.tsx b/src/app/components/stacks-transaction-item/transaction-action-button.tsx similarity index 50% rename from src/app/components/stacks-transaction-item/increase-fee-button.tsx rename to src/app/components/stacks-transaction-item/transaction-action-button.tsx index 80c7caf9b33..429e6ddad1a 100644 --- a/src/app/components/stacks-transaction-item/increase-fee-button.tsx +++ b/src/app/components/stacks-transaction-item/transaction-action-button.tsx @@ -1,28 +1,31 @@ import { HStack, styled } from 'leather-styles/jsx'; -import { ChevronsRightIcon } from '@leather.io/ui'; - -interface IncreaseFeeButtonProps { +interface ActionButtonProps { + icon?: React.ReactNode; isEnabled?: boolean; isSelected: boolean; - onIncreaseFee(): void; + label: string; + maxWidth?: string; + onButtonClick(): void; + textColor?: string; } -export function IncreaseFeeButton(props: IncreaseFeeButtonProps) { - const { isEnabled, isSelected, onIncreaseFee } = props; + +export function TransactionActionButton(props: ActionButtonProps) { + const { isEnabled, isSelected, onButtonClick, label, icon, textColor, maxWidth } = props; const isActive = isEnabled && !isSelected; + if (!isActive) return null; + return ( { - onIncreaseFee(); + onButtonClick(); e.stopPropagation(); }} - opacity={!isActive ? 0 : 1} - pointerEvents={!isActive ? 'none' : 'all'} position="relative" px="space.02" py="space.01" @@ -30,8 +33,10 @@ export function IncreaseFeeButton(props: IncreaseFeeButtonProps) { zIndex={999} > - - Increase fee + {icon} + + {label} + ); diff --git a/src/app/features/dialogs/increase-fee-dialog/increase-stx-fee-dialog.tsx b/src/app/features/dialogs/increase-fee-dialog/increase-stx-fee-dialog.tsx deleted file mode 100644 index 38db71068ef..00000000000 --- a/src/app/features/dialogs/increase-fee-dialog/increase-stx-fee-dialog.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Suspense, useCallback, useEffect } from 'react'; -import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; - -import { type StacksTransaction } from '@stacks/transactions'; -import BigNumber from 'bignumber.js'; -import { Formik } from 'formik'; -import { Flex, Stack } from 'leather-styles/jsx'; -import * as yup from 'yup'; - -import { - useStacksRawTransaction, - useStxAvailableUnlockedBalance, - useTransactionById, -} from '@leather.io/query'; -import { Caption, Spinner } from '@leather.io/ui'; -import { microStxToStx, stxToMicroStx } from '@leather.io/utils'; - -import { RouteUrls } from '@shared/route-urls'; - -import { useRefreshAllAccountData } from '@app/common/hooks/account/use-refresh-all-account-data'; -import { stacksValue } from '@app/common/stacks-utils'; -import { safelyFormatHexTxid } from '@app/common/utils/safe-handle-txid'; -import { stxFeeValidator } from '@app/common/validation/forms/fee-validators'; -import { LoadingSpinner } from '@app/components/loading-spinner'; -import { StacksTransactionItem } from '@app/components/stacks-transaction-item/stacks-transaction-item'; -import { useStacksBroadcastTransaction } from '@app/features/stacks-transaction-request/hooks/use-stacks-broadcast-transaction'; -import { useToast } from '@app/features/toasts/use-toast'; -import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useSubmittedTransactionsActions } from '@app/store/submitted-transactions/submitted-transactions.hooks'; -import { Dialog } from '@app/ui/components/containers/dialog/dialog'; -import { Footer } from '@app/ui/components/containers/footers/footer'; -import { DialogHeader } from '@app/ui/components/containers/headers/dialog-header'; - -import { IncreaseFeeActions } from './components/increase-fee-actions'; -import { IncreaseFeeField } from './components/increase-fee-field'; - -export function IncreaseStxFeeDialog() { - const navigate = useNavigate(); - const location = useLocation(); - const { txid } = useParams(); - const toast = useToast(); - const refreshAccountData = useRefreshAllAccountData(); - const { data: tx, isLoading: isLoadingTx } = useTransactionById(txid || ''); - const stxAddress = useCurrentStacksAccountAddress(); - const availableUnlockedBalance = useStxAvailableUnlockedBalance(stxAddress); - const submittedTransactionsActions = useSubmittedTransactionsActions(); - const { isLoadingRawTx, rawTx } = useStacksRawTransaction(txid || ''); - const { stacksBroadcastTransaction } = useStacksBroadcastTransaction({ - token: 'STX', - isIncreaseFeeTransaction: true, - }); - - useEffect(() => { - if (tx && tx.tx_status !== 'pending') { - toast.info('Your transaction went through! No need to speed it up.'); - } - }, [toast, tx, tx?.tx_status]); - - const onSubmit = useCallback( - async (values: any, rawTx?: StacksTransaction) => { - if (!tx || !rawTx) return; - rawTx.setFee(stxToMicroStx(values.fee).toString()); - const txid = tx.tx_id || safelyFormatHexTxid(rawTx.txid()); - await refreshAccountData(); - submittedTransactionsActions.transactionReplacedByFee(txid); - await stacksBroadcastTransaction(rawTx); - }, - [tx, refreshAccountData, submittedTransactionsActions, stacksBroadcastTransaction] - ); - - if (isLoadingRawTx || isLoadingTx) return ; - if (!txid) return null; - - const fee = Number(rawTx?.auth.spendingCondition?.fee); - const validationSchema = yup.object({ fee: stxFeeValidator(availableUnlockedBalance) }); - - return ( - <> - onSubmit(values, rawTx)} - validateOnChange={false} - validateOnBlur={false} - validateOnMount={false} - validationSchema={validationSchema} - > - {props => ( - <> - navigate(RouteUrls.Home)} - header={} - footer={ -
- navigate(RouteUrls.Home)} - /> -
- } - > - - - - - } - > - - If your transaction is pending for a long time, its fee might not be high enough - to be included in a block. Update the fee for a higher value and try again. - - - {tx && } - - - {availableUnlockedBalance.amount && ( - - Balance:{' '} - {stacksValue({ - value: availableUnlockedBalance.amount, - fixedDecimals: true, - })} - - )} - - - - -
- - - )} -
- - ); -} diff --git a/src/app/features/dialogs/transaction-action-dialog/cancel-stx-transaction-dialog.tsx b/src/app/features/dialogs/transaction-action-dialog/cancel-stx-transaction-dialog.tsx new file mode 100644 index 00000000000..bf8ffada785 --- /dev/null +++ b/src/app/features/dialogs/transaction-action-dialog/cancel-stx-transaction-dialog.tsx @@ -0,0 +1,18 @@ +import { RouteUrls } from '@shared/route-urls'; + +import { IncreaseFeeField } from './components/increase-fee-field'; +import { useStxCancelTransaction } from './hooks/use-stx-cancel-transaction'; +import { StxTransactionActionDialog } from './stx-transaction-action-dialog'; + +export function CancelStxTransactionDialog() { + return ( + + ); +} diff --git a/src/app/features/dialogs/increase-fee-dialog/components/fee-multiplier-button.tsx b/src/app/features/dialogs/transaction-action-dialog/components/fee-multiplier-button.tsx similarity index 100% rename from src/app/features/dialogs/increase-fee-dialog/components/fee-multiplier-button.tsx rename to src/app/features/dialogs/transaction-action-dialog/components/fee-multiplier-button.tsx diff --git a/src/app/features/dialogs/increase-fee-dialog/components/fee-multiplier.tsx b/src/app/features/dialogs/transaction-action-dialog/components/fee-multiplier.tsx similarity index 100% rename from src/app/features/dialogs/increase-fee-dialog/components/fee-multiplier.tsx rename to src/app/features/dialogs/transaction-action-dialog/components/fee-multiplier.tsx diff --git a/src/app/features/dialogs/increase-fee-dialog/components/increase-fee-field.tsx b/src/app/features/dialogs/transaction-action-dialog/components/increase-fee-field.tsx similarity index 100% rename from src/app/features/dialogs/increase-fee-dialog/components/increase-fee-field.tsx rename to src/app/features/dialogs/transaction-action-dialog/components/increase-fee-field.tsx diff --git a/src/app/features/dialogs/increase-fee-dialog/components/increase-fee-actions.tsx b/src/app/features/dialogs/transaction-action-dialog/components/transaction-actions.tsx similarity index 83% rename from src/app/features/dialogs/increase-fee-dialog/components/increase-fee-actions.tsx rename to src/app/features/dialogs/transaction-action-dialog/components/transaction-actions.tsx index b51ae084d0f..f233a058357 100644 --- a/src/app/features/dialogs/increase-fee-dialog/components/increase-fee-actions.tsx +++ b/src/app/features/dialogs/transaction-action-dialog/components/transaction-actions.tsx @@ -4,16 +4,17 @@ import { Button } from '@leather.io/ui'; import { useWalletType } from '@app/common/use-wallet-type'; -interface IncreaseFeeActionsProps { +interface TransactionActionsProps { isBroadcasting?: boolean; isDisabled?: boolean; isLoading?: boolean; onCancel(): void; } -export function IncreaseFeeActions(props: IncreaseFeeActionsProps) { - const { isBroadcasting, isDisabled, isLoading, onCancel } = props; +export function TransactionActions(props: TransactionActionsProps) { + const { onCancel, isDisabled, isLoading, isBroadcasting } = props; const { handleSubmit } = useFormikContext(); + const { whenWallet } = useWalletType(); const actionText = whenWallet({ ledger: 'Confirm on Ledger', software: 'Submit' }); diff --git a/src/app/features/dialogs/increase-fee-dialog/hooks/use-btc-increase-fee.ts b/src/app/features/dialogs/transaction-action-dialog/hooks/use-btc-increase-fee.ts similarity index 100% rename from src/app/features/dialogs/increase-fee-dialog/hooks/use-btc-increase-fee.ts rename to src/app/features/dialogs/transaction-action-dialog/hooks/use-btc-increase-fee.ts diff --git a/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-cancel-transaction.ts b/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-cancel-transaction.ts new file mode 100644 index 00000000000..b29cb1f5570 --- /dev/null +++ b/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-cancel-transaction.ts @@ -0,0 +1,76 @@ +import { useCallback } from 'react'; + +import { TransactionTypes } from '@stacks/connect'; + +import { stxToMicroStx } from '@leather.io/utils'; + +import { + type GenerateUnsignedTransactionOptions, + generateUnsignedTransaction, +} from '@app/common/transactions/stacks/generate-unsigned-txs'; +import { getBurnAddress } from '@app/common/utils/get-burn-address'; +import { safelyFormatHexTxid } from '@app/common/utils/safe-handle-txid'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { useCurrentStacksNetworkState } from '@app/store/networks/networks.hooks'; + +import { useStxTransactionActions } from './use-stx-transaction-actions'; + +export function useStxCancelTransaction(txid: string | undefined) { + const { + tx, + rawTx, + isLoadingTx, + isLoadingRawTx, + refreshAccountData, + submittedTransactionsActions, + stacksBroadcastTransaction, + availableUnlockedBalance, + validationSchema, + } = useStxTransactionActions(txid, 'cancel'); + + const network = useCurrentStacksNetworkState(); + const account = useCurrentStacksAccount(); + + const onSubmit = useCallback( + async (values: { fee: number }) => { + if (!tx || !rawTx || !account) return; + + const options: GenerateUnsignedTransactionOptions = { + publicKey: account.stxPublicKey, + nonce: tx.nonce, + fee: stxToMicroStx(values?.fee || 0).toNumber(), + txData: { + txType: TransactionTypes.STXTransfer, + recipient: getBurnAddress(network), + amount: 1, + memo: '_cancel transaction', + network: network, + } as any, + }; + const newTx = await generateUnsignedTransaction(options); + const txId = tx.tx_id || safelyFormatHexTxid(rawTx.txid()); + await refreshAccountData(); + submittedTransactionsActions.transactionReplacedByFee(txId); + await stacksBroadcastTransaction(newTx); + }, + [ + tx, + rawTx, + account, + network, + refreshAccountData, + submittedTransactionsActions, + stacksBroadcastTransaction, + ] + ); + + return { + availableUnlockedBalance, + isLoadingRawTx, + isLoadingTx, + onSubmit, + rawTx, + tx, + validationSchema, + }; +} diff --git a/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-increase-fee.ts b/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-increase-fee.ts new file mode 100644 index 00000000000..1ee4d61eea6 --- /dev/null +++ b/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-increase-fee.ts @@ -0,0 +1,43 @@ +import { useCallback } from 'react'; + +import type { StacksTransaction } from '@stacks/transactions'; + +import { safelyFormatHexTxid, stxToMicroStx } from '@leather.io/utils'; + +import { useStxTransactionActions } from './use-stx-transaction-actions'; + +export function useStxIncreaseFee(txid: string | undefined) { + const { + tx, + rawTx, + isLoadingTx, + isLoadingRawTx, + refreshAccountData, + submittedTransactionsActions, + stacksBroadcastTransaction, + availableUnlockedBalance, + validationSchema, + } = useStxTransactionActions(txid, 'increaseFee'); + + const onSubmit = useCallback( + async (values: { fee: number }, rawTx?: StacksTransaction) => { + if (!tx || !rawTx) return; + rawTx.setFee(stxToMicroStx(values.fee).toString()); + const txid = tx.tx_id || safelyFormatHexTxid(rawTx.txid()); + await refreshAccountData(); + submittedTransactionsActions.transactionReplacedByFee(txid); + await stacksBroadcastTransaction(rawTx); + }, + [tx, refreshAccountData, submittedTransactionsActions, stacksBroadcastTransaction] + ); + + return { + availableUnlockedBalance, + isLoadingRawTx, + isLoadingTx, + onSubmit, + rawTx, + tx, + validationSchema, + }; +} diff --git a/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-transaction-actions.ts b/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-transaction-actions.ts new file mode 100644 index 00000000000..93f91594423 --- /dev/null +++ b/src/app/features/dialogs/transaction-action-dialog/hooks/use-stx-transaction-actions.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; + +import * as yup from 'yup'; + +import { + useStacksRawTransaction, + useStxAvailableUnlockedBalance, + useTransactionById, +} from '@leather.io/query'; + +import { useRefreshAllAccountData } from '@app/common/hooks/account/use-refresh-all-account-data'; +import { stxFeeValidator } from '@app/common/validation/forms/fee-validators'; +import { useStacksBroadcastTransaction } from '@app/features/stacks-transaction-request/hooks/use-stacks-broadcast-transaction'; +import { useToast } from '@app/features/toasts/use-toast'; +import { useCurrentStacksAccountAddress } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { useSubmittedTransactionsActions } from '@app/store/submitted-transactions/submitted-transactions.hooks'; + +export function useStxTransactionActions( + txid: string | undefined, + actionType: 'cancel' | 'increaseFee' +) { + const { data: tx, isLoading: isLoadingTx } = useTransactionById(txid || ''); + const toast = useToast(); + const refreshAccountData = useRefreshAllAccountData(); + const stxAddress = useCurrentStacksAccountAddress(); + const availableUnlockedBalance = useStxAvailableUnlockedBalance(stxAddress); + const submittedTransactionsActions = useSubmittedTransactionsActions(); + const { isLoadingRawTx, rawTx } = useStacksRawTransaction(txid || ''); + const { stacksBroadcastTransaction } = useStacksBroadcastTransaction({ + token: 'STX', + isCancelTransaction: actionType === 'cancel', + isIncreaseFeeTransaction: actionType === 'increaseFee', + }); + + useEffect(() => { + if (tx && tx.tx_status !== 'pending') { + toast.info('Your transaction went through!.'); + } + }, [toast, tx, tx?.tx_status]); + + const validationSchema = yup.object({ fee: stxFeeValidator(availableUnlockedBalance) }); + + return { + tx, + rawTx, + isLoadingTx, + isLoadingRawTx, + refreshAccountData, + submittedTransactionsActions, + stacksBroadcastTransaction, + availableUnlockedBalance, + validationSchema, + }; +} diff --git a/src/app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog.tsx b/src/app/features/dialogs/transaction-action-dialog/increase-btc-fee-dialog.tsx similarity index 97% rename from src/app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog.tsx rename to src/app/features/dialogs/transaction-action-dialog/increase-btc-fee-dialog.tsx index 9e5f195c45c..15e5bf922de 100644 --- a/src/app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog.tsx +++ b/src/app/features/dialogs/transaction-action-dialog/increase-btc-fee-dialog.tsx @@ -20,7 +20,7 @@ import { Dialog } from '@app/ui/components/containers/dialog/dialog'; import { Footer } from '@app/ui/components/containers/footers/footer'; import { DialogHeader } from '@app/ui/components/containers/headers/dialog-header'; -import { IncreaseFeeActions } from './components/increase-fee-actions'; +import { TransactionActions } from './components/transaction-actions'; import { useBtcIncreaseFee } from './hooks/use-btc-increase-fee'; export function IncreaseBtcFeeDialog() { @@ -69,7 +69,7 @@ export function IncreaseBtcFeeDialog() { header={} footer={