diff --git a/src/app/common/hooks/use-loading.ts b/src/app/common/hooks/use-loading.ts index 3d48ef662f6..9b8e9217a53 100644 --- a/src/app/common/hooks/use-loading.ts +++ b/src/app/common/hooks/use-loading.ts @@ -2,6 +2,7 @@ import { useLoadingState } from '@app/store/ui/ui.hooks'; export enum LoadingKeys { INCREASE_FEE_DRAWER = 'loading/INCREASE_FEE_DRAWER', + CANCEL_TRANSACTION_DRAWER = 'loading/CANCEL_TRANSACTION_DRAWER', SUBMIT_SEND_FORM_TRANSACTION = 'loading/SUBMIT_SEND_FORM_TRANSACTION', SUBMIT_SWAP_TRANSACTION = 'loading/SUBMIT_SWAP_TRANSACTION', SUBMIT_TRANSACTION_REQUEST = 'loading/SUBMIT_TRANSACTION_REQUEST', 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 1a5d0024ccd..6551b6c8bcc 100644 --- a/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx +++ b/src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx @@ -86,7 +86,7 @@ export function BitcoinTransactionItem({ transaction }: BitcoinTransactionItemPr return ( { + onCancelTransaction(); + e.stopPropagation(); + }} + pointerEvents={!isActive ? 'none' : 'all'} + position="relative" + px="space.02" + py="space.01" + rounded="xs" + zIndex={999} + > + + + Cancel transaction + + + + ) + ); +} diff --git a/src/app/components/stacks-transaction-item/increase-fee-button.tsx b/src/app/components/stacks-transaction-item/increase-fee-button.tsx index 846a1259f9d..204b12a9579 100644 --- a/src/app/components/stacks-transaction-item/increase-fee-button.tsx +++ b/src/app/components/stacks-transaction-item/increase-fee-button.tsx @@ -12,27 +12,28 @@ export function IncreaseFeeButton(props: IncreaseFeeButtonProps) { const isActive = isEnabled && !isSelected; return ( - { - onIncreaseFee(); - e.stopPropagation(); - }} - opacity={!isActive ? 0 : 1} - pointerEvents={!isActive ? 'none' : 'all'} - position="relative" - px="space.02" - py="space.01" - rounded="xs" - zIndex={999} - > - - - Increase fee - - + isActive && ( + { + onIncreaseFee(); + e.stopPropagation(); + }} + pointerEvents={!isActive ? 'none' : 'all'} + position="relative" + px="space.02" + py="space.01" + rounded="xs" + zIndex={999} + > + + + Increase fee + + + ) ); } 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 b7d86bb5416..3171f267332 100644 --- a/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx +++ b/src/app/components/stacks-transaction-item/stacks-transaction-item.tsx @@ -1,5 +1,7 @@ import { createSearchParams, useLocation, useNavigate } from 'react-router-dom'; +import { HStack } from 'leather-styles/jsx'; + import { StacksTx, TxTransferDetails } from '@shared/models/transactions/stacks-transaction.model'; import { RouteUrls } from '@shared/route-urls'; @@ -19,6 +21,7 @@ import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/s import { useRawTxIdState } from '@app/store/transactions/raw.hooks'; import { TransactionItemLayout } from '../transaction-item/transaction-item.layout'; +import { CancelTransactionButton } from './cancel-transaction-button'; import { IncreaseFeeButton } from './increase-fee-button'; import { StacksTransactionIcon } from './stacks-transaction-icon'; import { StacksTransactionStatus } from './stacks-transaction-status'; @@ -64,6 +67,22 @@ export function StacksTransactionItem({ })(); }; + const onCancelTransaction = () => { + if (!transaction) return; + setRawTxId(transaction.tx_id); + + const urlSearchParams = `?${createSearchParams({ txId: transaction.tx_id })}`; + + whenWallet({ + ledger: () => + whenPageMode({ + full: () => navigate(RouteUrls.CancelStxTransaction), + popup: () => openIndexPageInNewTab(RouteUrls.CancelStxTransaction, urlSearchParams), + })(), + software: () => navigate(RouteUrls.CancelStxTransaction), + })(); + }; + const isOriginator = transaction?.sender_address === currentAccount?.address; const isPending = transaction && isPendingTx(transaction); @@ -75,19 +94,30 @@ export function StacksTransactionItem({ ); const title = transaction ? getTxTitle(transaction) : transferDetails?.title || ''; const value = transaction ? getTxValue(transaction, isOriginator) : transferDetails?.value; - const increaseFeeButton = ( - + const actionButtonGroup = ( + + + + ); const txStatus = transaction && ; return ( - - {txCaption} - - {txStatus && txStatus} - + + + + {txCaption} + + {txStatus && txStatus} + + {actionButtonGroupElement && actionButtonGroupElement} + } titleRight={ - rightElement ? rightElement : {txValue} + !actionButtonGroupElement && {txValue} } /> diff --git a/src/app/features/dialogs/cancel-transaction-dialog/cancel-stx-transaction-dialog.tsx b/src/app/features/dialogs/cancel-transaction-dialog/cancel-stx-transaction-dialog.tsx new file mode 100644 index 00000000000..3b04b23ba9b --- /dev/null +++ b/src/app/features/dialogs/cancel-transaction-dialog/cancel-stx-transaction-dialog.tsx @@ -0,0 +1,134 @@ +import { Suspense, useEffect } from 'react'; +import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; + +import { microStxToStx, stxToMicroStx } from '@leather-wallet/utils'; +import BigNumber from 'bignumber.js'; +import { Formik } from 'formik'; +import { Flex, Stack } from 'leather-styles/jsx'; + +import { RouteUrls } from '@shared/route-urls'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { stacksValue } from '@app/common/stacks-utils'; +import { FeesRow } from '@app/components/fees-row/fees-row'; +import { LoadingSpinner } from '@app/components/loading-spinner'; +import { StacksTransactionItem } from '@app/components/stacks-transaction-item/stacks-transaction-item'; +import { useToast } from '@app/features/toasts/use-toast'; +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 { Spinner } from '@app/ui/components/spinner'; +import { Caption } from '@app/ui/components/typography/caption'; + +import { CancelTransactionActions } from './components/cancel-transaction-actions'; +import { useStxCancelTransaction } from './hooks/use-stx-cancel-transaction'; + +export function CancelStxTransactionDialog() { + const { + rawTx, + rawTxId, + setRawTxId, + tx, + setTxId, + onSubmit, + validationSchema, + availableUnlockedBalance, + stxFees, + } = useStxCancelTransaction(); + const { isLoading, setIsIdle } = useLoading(LoadingKeys.CANCEL_TRANSACTION_DRAWER); + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const txIdFromParams = searchParams.get('txId'); + const toast = useToast(); + + const fee = Number(rawTx?.auth.spendingCondition?.fee); + + useEffect(() => { + if (tx?.tx_status !== 'pending' && rawTx) { + setTxId(null); + toast.info('Your transaction went through! Cancellation not possible.'); + } + }, [rawTx, tx?.tx_status, setTxId, toast]); + + useEffect(() => { + if (!rawTxId && txIdFromParams) { + setRawTxId(txIdFromParams); + } + if (isLoading && !rawTxId) { + setIsIdle(); + } + }, [isLoading, rawTxId, setIsIdle, setRawTxId, txIdFromParams]); + + if (!tx || !fee) return ; + + const onClose = () => { + setRawTxId(null); + navigate(RouteUrls.Home); + }; + + return ( + <> + + {props => ( + <> + } + footer={ +
+ { + setTxId(null); + navigate(RouteUrls.Home); + }} + isDisabled={stxToMicroStx(props.values.fee).isEqualTo(fee)} + /> +
+ } + > + + + + + } + > + + Canceling a transaction isn't guaranteed to work. A higher fee can help replace + the old transaction + + + {tx && } + + + {availableUnlockedBalance?.amount && ( + + Balance: + {stacksValue({ + value: availableUnlockedBalance.amount, + fixedDecimals: true, + })} + + )} + + + + +
+ + + )} +
+ + ); +} diff --git a/src/app/features/dialogs/cancel-transaction-dialog/components/cancel-transaction-actions.tsx b/src/app/features/dialogs/cancel-transaction-dialog/components/cancel-transaction-actions.tsx new file mode 100644 index 00000000000..c8c483e3189 --- /dev/null +++ b/src/app/features/dialogs/cancel-transaction-dialog/components/cancel-transaction-actions.tsx @@ -0,0 +1,39 @@ +import { useFormikContext } from 'formik'; + +import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading'; +import { useWalletType } from '@app/common/use-wallet-type'; +import { Button } from '@app/ui/components/button/button'; + +interface CancelTransactionActionsProps { + isDisabled?: boolean; + isBroadcasting?: boolean; + onCancel(): void; +} +export function CancelTransactionActions(props: CancelTransactionActionsProps) { + const { onCancel, isDisabled, isBroadcasting } = props; + + const { handleSubmit } = useFormikContext(); + const { isLoading } = useLoading(LoadingKeys.CANCEL_TRANSACTION_DRAWER); + const { whenWallet } = useWalletType(); + + const actionText = whenWallet({ ledger: 'Confirm on Ledger', software: 'Submit' }); + + return ( + <> + + + + ); +} diff --git a/src/app/features/dialogs/cancel-transaction-dialog/hooks/use-selected-tx.ts b/src/app/features/dialogs/cancel-transaction-dialog/hooks/use-selected-tx.ts new file mode 100644 index 00000000000..83975b47aef --- /dev/null +++ b/src/app/features/dialogs/cancel-transaction-dialog/hooks/use-selected-tx.ts @@ -0,0 +1,7 @@ +import { useTransactionById } from '@app/query/stacks/transactions/transactions-by-id.query'; +import { useRawTxIdState } from '@app/store/transactions/raw.hooks'; + +export function useSelectedTx() { + const [rawTxId] = useRawTxIdState(); + return useTransactionById(rawTxId || '').data; +} diff --git a/src/app/features/dialogs/cancel-transaction-dialog/hooks/use-stx-cancel-transaction.ts b/src/app/features/dialogs/cancel-transaction-dialog/hooks/use-stx-cancel-transaction.ts new file mode 100644 index 00000000000..8e8c906276a --- /dev/null +++ b/src/app/features/dialogs/cancel-transaction-dialog/hooks/use-stx-cancel-transaction.ts @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; + +import { stxToMicroStx } from '@leather-wallet/utils'; +import { TransactionTypes } from '@stacks/connect'; +import * as yup from 'yup'; + +import { useRefreshAllAccountData } from '@app/common/hooks/account/use-refresh-all-account-data'; +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 { stxFeeValidator } from '@app/common/validation/forms/fee-validators'; +import { useStacksBroadcastTransaction } from '@app/features/stacks-transaction-request/hooks/use-stacks-broadcast-transaction'; +import { useCurrentStxAvailableUnlockedBalance } from '@app/query/stacks/balance/account-balance.hooks'; +import { useCalculateStacksTxFees } from '@app/query/stacks/fees/fees.hooks'; +import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; +import { useCurrentStacksNetworkState } from '@app/store/networks/networks.hooks'; +import { useSubmittedTransactionsActions } from '@app/store/submitted-transactions/submitted-transactions.hooks'; +import { useRawDeserializedTxState, useRawTxIdState } from '@app/store/transactions/raw.hooks'; + +import { useSelectedTx } from './use-selected-tx'; + +export function useStxCancelTransaction() { + const [rawTxId, setRawTxId] = useRawTxIdState(); + const tx = useSelectedTx(); + const [, setTxId] = useRawTxIdState(); + const availableUnlockedBalance = useCurrentStxAvailableUnlockedBalance(); + const submittedTransactionsActions = useSubmittedTransactionsActions(); + const rawTx = useRawDeserializedTxState(); + const { data: stxFees } = useCalculateStacksTxFees(rawTx); + const network = useCurrentStacksNetworkState(); + const account = useCurrentStacksAccount(); + const refreshAccountData = useRefreshAllAccountData(); + + const { stacksBroadcastTransaction } = useStacksBroadcastTransaction({ + token: 'STX', + isCancelTransaction: true, + }); + + const fee = Number(rawTx?.auth.spendingCondition?.fee); + + const onSubmit = useCallback( + async (values: any) => { + 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, + refreshAccountData, + submittedTransactionsActions, + stacksBroadcastTransaction, + account, + network, + ] + ); + + return { + availableUnlockedBalance, + stxFees, + fee, + rawTx, + rawTxId, + setRawTxId, + tx, + setTxId, + onSubmit, + validationSchema: yup.object({ fee: stxFeeValidator(availableUnlockedBalance) }), + }; +} diff --git a/src/app/features/stacks-transaction-request/hooks/use-stacks-broadcast-transaction.tsx b/src/app/features/stacks-transaction-request/hooks/use-stacks-broadcast-transaction.tsx index 48f4233f62f..66ae546a2f3 100644 --- a/src/app/features/stacks-transaction-request/hooks/use-stacks-broadcast-transaction.tsx +++ b/src/app/features/stacks-transaction-request/hooks/use-stacks-broadcast-transaction.tsx @@ -27,12 +27,14 @@ interface UseStacksBroadcastTransactionArgs { token: CryptoCurrencies; decimals?: number; isIncreaseFeeTransaction?: boolean; + isCancelTransaction?: boolean; } export function useStacksBroadcastTransaction({ token, decimals, isIncreaseFeeTransaction, + isCancelTransaction, }: UseStacksBroadcastTransactionArgs) { const signStacksTransaction = useSignStacksTransaction(); const [isBroadcasting, setIsBroadcasting] = useState(false); @@ -59,7 +61,7 @@ export function useStacksBroadcastTransaction({ }); } if (txId) { - if (isIncreaseFeeTransaction) { + if (isIncreaseFeeTransaction || isCancelTransaction) { navigate(RouteUrls.Activity); return; } @@ -97,6 +99,9 @@ export function useStacksBroadcastTransaction({ if (isIncreaseFeeTransaction) { toast.success('Fee increased successfully'); } + if (isCancelTransaction) { + toast.success('Transaction cancellation submitted'); + } }, replaceByFee: false, })(signedTx); @@ -136,5 +141,6 @@ export function useStacksBroadcastTransaction({ broadcastTransactionFn, signStacksTransaction, isIncreaseFeeTransaction, + isCancelTransaction, ]); } diff --git a/src/app/routes/app-routes.tsx b/src/app/routes/app-routes.tsx index be665b76641..87397b78ed3 100644 --- a/src/app/routes/app-routes.tsx +++ b/src/app/routes/app-routes.tsx @@ -14,6 +14,7 @@ import { RouteUrls } from '@shared/route-urls'; import { LoadingSpinner } from '@app/components/loading-spinner'; import { AddNetwork } from '@app/features/add-network/add-network'; import { Container } from '@app/features/container/container'; +import { CancelStxTransactionDialog } from '@app/features/dialogs/cancel-transaction-dialog/cancel-stx-transaction-dialog'; import { EditNonceDialog } from '@app/features/dialogs/edit-nonce-dialog/edit-nonce-dialog'; import { IncreaseBtcFeeDialog } from '@app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog'; import { IncreaseStxFeeDialog } from '@app/features/dialogs/increase-fee-dialog/increase-stx-fee-dialog'; @@ -100,6 +101,9 @@ function useAppRoutes() { }> {ledgerStacksTxSigningRoutes} + }> + {ledgerStacksTxSigningRoutes} + } diff --git a/src/shared/route-urls.ts b/src/shared/route-urls.ts index 293f0a6e1de..e939dbc18bb 100644 --- a/src/shared/route-urls.ts +++ b/src/shared/route-urls.ts @@ -27,6 +27,7 @@ export enum RouteUrls { FundChooseCurrency = '/fund-choose-currency', IncreaseStxFee = '/increase-fee/stx', IncreaseBtcFee = '/increase-fee/btc', + CancelStxTransaction = '/cancel-transaction/stx', Send = '/send-transaction', ViewSecretKey = '/view-secret-key',