Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cancel stacks pending transaction #5501

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/common/hooks/use-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions src/app/common/utils/get-burn-address.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

const { data: inscriptionData } = useInscriptionByOutput(transaction);

const bitcoinAddress = useCurrentAccountNativeSegwitAddressIndexZero();

Check warning on line 40 in src/app/components/bitcoin-transaction-item/bitcoin-transaction-item.tsx

View workflow job for this annotation

GitHub Actions / lint-eslint

'useCurrentAccountNativeSegwitAddressIndexZero' is deprecated. Use signer.address instead
const { handleOpenBitcoinTxLink: handleOpenTxLink } = useBitcoinExplorerLink();
const analytics = useAnalytics();
const caption = useMemo(() => getBitcoinTxCaption(transaction), [transaction]);
Expand Down Expand Up @@ -86,7 +86,7 @@
return (
<TransactionItemLayout
openTxLink={openTxLink}
rightElement={isEnabled ? increaseFeeButton : undefined}
actionButtonGroupElement={isEnabled ? increaseFeeButton : undefined}
txCaption={txCaption}
txIcon={
<BitcoinTransactionIcon
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { HStack, styled } from 'leather-styles/jsx';

interface CancelTransactionButtonProps {
isEnabled?: boolean;
isSelected: boolean;
onCancelTransaction(): void;
}
export function CancelTransactionButton(props: CancelTransactionButtonProps) {
const { isEnabled, isSelected, onCancelTransaction } = props;
const isActive = isEnabled && !isSelected;

return (
isActive && (
<styled.button
_hover={{ color: 'ink.text-subdued' }}
bg="ink.background-primary"
maxWidth="125px"
ml="auto"
onClick={e => {
onCancelTransaction();
e.stopPropagation();
}}
pointerEvents={!isActive ? 'none' : 'all'}
position="relative"
px="space.02"
py="space.01"
rounded="xs"
zIndex={999}
>
<HStack gap="space.01">
<styled.span textStyle="label.03" color="yellow.action-primary-default">
Cancel transaction
</styled.span>
</HStack>
</styled.button>
Comment on lines +13 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code here is pretty much similar to increase fee btn, can we create component to avoid code duplication?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code here is pretty much similar to increase fee btn, can we create component to avoid code duplication?

Yes that makes sense, i can work on it

)
);
}
45 changes: 23 additions & 22 deletions src/app/components/stacks-transaction-item/increase-fee-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,28 @@ export function IncreaseFeeButton(props: IncreaseFeeButtonProps) {
const isActive = isEnabled && !isSelected;

return (
<styled.button
_hover={{ color: 'ink.text-subdued' }}
bg="ink.background-primary"
maxWidth="110px"
ml="auto"
onClick={e => {
onIncreaseFee();
e.stopPropagation();
}}
opacity={!isActive ? 0 : 1}
pointerEvents={!isActive ? 'none' : 'all'}
position="relative"
px="space.02"
py="space.01"
rounded="xs"
zIndex={999}
>
<HStack gap="space.01">
<ChevronsRightIcon color="stacks" variant="small" />
<styled.span textStyle="label.03">Increase fee</styled.span>
</HStack>
</styled.button>
isActive && (
<styled.button
_hover={{ color: 'ink.text-subdued' }}
bg="ink.background-primary"
maxWidth="110px"
ml="auto"
onClick={e => {
onIncreaseFee();
e.stopPropagation();
}}
pointerEvents={!isActive ? 'none' : 'all'}
position="relative"
px="space.02"
py="space.01"
rounded="xs"
zIndex={999}
>
<HStack gap="space.01">
<ChevronsRightIcon color="stacks" variant="small" />
<styled.span textStyle="label.03">Increase fee</styled.span>
</HStack>
</styled.button>
)
);
}
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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),
})();
Comment on lines +76 to +83
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can always just navigate? Not sure we need to handle ledger/software differently here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can always just navigate? Not sure we need to handle ledger/software differently here.

I coped this from increase fee logic not sure why it was in that way since increase fee was working fine i thought it's better to rely on already established code

};

const isOriginator = transaction?.sender_address === currentAccount?.address;
const isPending = transaction && isPendingTx(transaction);

Expand All @@ -75,19 +94,30 @@ export function StacksTransactionItem({
);
const title = transaction ? getTxTitle(transaction) : transferDetails?.title || '';
const value = transaction ? getTxValue(transaction, isOriginator) : transferDetails?.value;
const increaseFeeButton = (
<IncreaseFeeButton
isEnabled={isOriginator && isPending}
isSelected={pathname === RouteUrls.IncreaseStxFee}
onIncreaseFee={onIncreaseFee}
/>
const actionButtonGroup = (
<HStack alignItems="start" gap="space.01">
<CancelTransactionButton
isEnabled={isOriginator && isPending}
isSelected={
pathname === RouteUrls.CancelStxTransaction || pathname === RouteUrls.IncreaseStxFee
}
onCancelTransaction={onCancelTransaction}
/>
<IncreaseFeeButton
isEnabled={isOriginator && isPending}
isSelected={
pathname === RouteUrls.IncreaseStxFee || pathname === RouteUrls.CancelStxTransaction
}
onIncreaseFee={onIncreaseFee}
/>
</HStack>
);
const txStatus = transaction && <StacksTransactionStatus transaction={transaction} />;

return (
<TransactionItemLayout
openTxLink={openTxLink}
rightElement={isOriginator && isPending ? increaseFeeButton : undefined}
actionButtonGroupElement={isOriginator && isPending ? actionButtonGroup : undefined}
txCaption={caption}
txIcon={txIcon}
txStatus={txStatus}
Expand Down
31 changes: 17 additions & 14 deletions src/app/components/transaction-item/transaction-item.layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { ReactNode } from 'react';

import { HStack, styled } from 'leather-styles/jsx';
import { HStack, VStack, styled } from 'leather-styles/jsx';

import { ItemLayout } from '@app/ui/components/item-layout/item-layout';
import { Caption } from '@app/ui/components/typography/caption';
import { Pressable } from '@app/ui/pressable/pressable';

interface TransactionItemLayoutProps {
openTxLink(): void;
rightElement?: ReactNode;
actionButtonGroupElement?: ReactNode;
txCaption: ReactNode;
txTitle: ReactNode;
txValue: ReactNode;
Expand All @@ -19,7 +19,7 @@ interface TransactionItemLayoutProps {

export function TransactionItemLayout({
openTxLink,
rightElement,
actionButtonGroupElement,
txCaption,
txIcon,
txStatus,
Expand All @@ -32,19 +32,22 @@ export function TransactionItemLayout({
flagImg={txIcon && txIcon}
titleLeft={txTitle}
captionLeft={
<HStack alignItems="center">
<Caption
overflow="hidden"
textOverflow="ellipsis"
maxWidth={{ base: '160px', md: 'unset' }}
>
{txCaption}
</Caption>
{txStatus && txStatus}
</HStack>
<VStack alignItems="start" gap="space.01">
<HStack alignItems="center">
<Caption
overflow="hidden"
textOverflow="ellipsis"
maxWidth={{ base: '160px', md: 'unset' }}
>
{txCaption}
</Caption>
{txStatus && txStatus}
</HStack>
{actionButtonGroupElement && actionButtonGroupElement}
</VStack>
}
titleRight={
rightElement ? rightElement : <styled.span textStyle="label.02">{txValue}</styled.span>
!actionButtonGroupElement && <styled.span textStyle="label.02">{txValue}</styled.span>
}
/>
</Pressable>
Expand Down
Original file line number Diff line number Diff line change
@@ -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]);
Comment on lines +54 to +61
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

Codebase Verification

The search results indicate that txIdFromParams is being retrieved from the URL search parameters using searchParams.get('txId'), but there is no evidence of validation logic for txIdFromParams in the provided context.

  • File: src/app/features/dialogs/cancel-transaction-dialog/cancel-stx-transaction-dialog.tsx
  • Lines:
    • const txIdFromParams = searchParams.get('txId');
    • if (!rawTxId && txIdFromParams) { setRawTxId(txIdFromParams); }

There is no validation to check if txIdFromParams is valid before it is used.

Analysis chain

Consider adding error handling for cases where txIdFromParams is invalid.

Scripts executed

The following scripts were executed for the analysis:


Script:

#!/bin/bash
# Description: Verify the validation of `txIdFromParams`.

# Test: Search for validation logic for `txIdFromParams`. Expect: Proper validation checks.
rg --type typescript $'txIdFromParams' --context 5

Length of output: 84



Script:

#!/bin/bash
# Description: Verify the validation of `txIdFromParams`.

# Test: Search for validation logic for `txIdFromParams`. Expect: Proper validation checks.
rg --type ts 'txIdFromParams' --context 5

Length of output: 6346


if (!tx || !fee) return <LoadingSpinner />;

const onClose = () => {
setRawTxId(null);
navigate(RouteUrls.Home);
};

return (
<>
<Formik
initialValues={{ fee: new BigNumber(microStxToStx(fee)).toNumber() }}
onSubmit={onSubmit}
validateOnChange={false}
validateOnBlur={false}
validateOnMount={true}
validationSchema={validationSchema}
>
{props => (
<>
<Dialog
isShowing={location.pathname === RouteUrls.CancelStxTransaction}
onClose={onClose}
header={<DialogHeader title="Cancel transaction" />}
footer={
<Footer flexDirection="row">
<CancelTransactionActions
onCancel={() => {
setTxId(null);
navigate(RouteUrls.Home);
}}
isDisabled={stxToMicroStx(props.values.fee).isEqualTo(fee)}
/>
</Footer>
}
>
<Stack gap="space.05" px="space.05" pb="space.05">
<Suspense
fallback={
<Flex alignItems="center" justifyContent="center" p="space.06">
<Spinner />
</Flex>
}
>
<Caption>
Canceling a transaction isn't guaranteed to work. A higher fee can help replace
the old transaction
</Caption>
<Stack gap="space.06">
{tx && <StacksTransactionItem transaction={tx} />}
<Stack gap="space.04">
<FeesRow fees={stxFees} defaultFeeValue={fee + 1} isSponsored={false} />
{availableUnlockedBalance?.amount && (
<Caption>
Balance:
{stacksValue({
value: availableUnlockedBalance.amount,
fixedDecimals: true,
})}
</Caption>
)}
</Stack>
</Stack>
</Suspense>
</Stack>
</Dialog>
<Outlet />
</>
)}
</Formik>
</>
);
}
Loading
Loading