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 pending transaction #5520

Closed
wants to merge 2 commits into from
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';
}
Comment on lines +4 to +12
Copy link

Choose a reason for hiding this comment

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

Consider handling the default case explicitly in getBurnAddress to avoid potential issues in production environments.

-      return 'ST000000000000000000002AMW42H';
+      throw new Error('Unsupported network chain ID');
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getBurnAddress(network: StacksNetwork): string {
switch (network.chainId) {
case ChainID.Mainnet:
return 'SP00000000000003SCNSJTCSE62ZF4MSE';
case ChainID.Testnet:
return 'ST000000000000000000002AMW42H';
default:
return 'ST000000000000000000002AMW42H';
}
export function getBurnAddress(network: StacksNetwork): string {
switch (network.chainId) {
case ChainID.Mainnet:
return 'SP00000000000003SCNSJTCSE62ZF4MSE';
case ChainID.Testnet:
return 'ST000000000000000000002AMW42H';
default:
throw new Error('Unsupported network chain ID');
}

}
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 +12 to +37
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I'd suggest short circuiting the component's render with an if statement

Suggested change
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>
)
);
if(!isActive) {
return null;
}
return (
<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>
);

}
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 && (
Copy link
Contributor

Choose a reason for hiding this comment

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

here too

<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),
})();
};

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]);
Comment on lines +47 to +52
Copy link

Choose a reason for hiding this comment

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

Refactor to simplify the effect handling transaction status.

The effect on lines 47-52 could be simplified or broken down into smaller, more testable functions. This would improve readability and maintainability.


useEffect(() => {
if (!rawTxId && txIdFromParams) {
setRawTxId(txIdFromParams);
}
if (isLoading && !rawTxId) {
setIsIdle();
}
}, [isLoading, rawTxId, setIsIdle, setRawTxId, txIdFromParams]);

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

Choose a reason for hiding this comment

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

Handle potential null values gracefully in rendering logic.

The rendering logic on line 63 directly accesses properties which might be null or undefined. This could lead to runtime errors. Consider adding null checks or using optional chaining to safeguard against these issues.


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>
</>
);
Comment on lines +70 to +133
Copy link

Choose a reason for hiding this comment

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

Review and optimize the Formik usage for performance.

The Formik component is used extensively in the rendering logic. Consider optimizing the re-renders and validations to enhance performance, especially since validateOnMount is set to true, which might cause unnecessary validations on each render.

}
Comment on lines +26 to +134
Copy link

Choose a reason for hiding this comment

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

Ensure proper error handling and edge case management in CancelStxTransactionDialog.

The function CancelStxTransactionDialog handles various states and transitions, but there seems to be a lack of explicit error handling for network or transaction failures. Consider adding error handling mechanisms to improve the robustness of the component.

Loading
Loading