From 6a1f17ab5abe631760f68a93b64e4ae371c20ca8 Mon Sep 17 00:00:00 2001 From: cartcrom <39385577+cartcrom@users.noreply.github.com> Date: Thu, 3 Aug 2023 12:35:51 -0400 Subject: [PATCH] feat: update cancelled local tx's (#7008) * feat: update cancelled local tx's * fix: use updated queries * test: add cancel reducer and hook tests * fix: spelling in test descriptor * feat: improved activity descriptors * fix: check activity groups instead of activity * fix: pending hooks cleanup * fix: destruct object from pending hook * refactor: update usage of pending util * fix: removed unused util --- .../MiniPortfolio/Activity/hooks.ts | 90 +++++++++++++++++++ .../MiniPortfolio/Activity/index.tsx | 59 ++---------- .../MiniPortfolio/Activity/parseLocal.ts | 12 ++- .../MiniPortfolio/Activity/parseRemote.tsx | 20 +++-- .../MiniPortfolio/Activity/types.ts | 1 + .../AccountDrawer/MiniPortfolio/constants.tsx | 29 ++++++ .../AccountDrawer/MiniPortfolio/index.tsx | 12 +-- src/components/Popups/PopupContent.tsx | 2 +- src/components/Web3Status/index.tsx | 20 +---- src/state/transactions/hooks.test.tsx | 27 +++++- src/state/transactions/hooks.tsx | 29 ++++-- src/state/transactions/reducer.test.ts | 41 +++++++++ src/state/transactions/reducer.ts | 22 ++++- src/state/transactions/types.ts | 1 + src/state/transactions/updater.tsx | 3 +- 15 files changed, 272 insertions(+), 96 deletions(-) create mode 100644 src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts b/src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts new file mode 100644 index 0000000000..3996cca85e --- /dev/null +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts @@ -0,0 +1,90 @@ +import { TransactionStatus, useActivityQuery } from 'graphql/data/__generated__/types-and-hooks' +import { useEffect, useMemo } from 'react' +import { usePendingOrders } from 'state/signatures/hooks' +import { usePendingTransactions, useTransactionCanceller } from 'state/transactions/hooks' + +import { useLocalActivities } from './parseLocal' +import { parseRemoteActivities } from './parseRemote' +import { Activity, ActivityMap } from './types' + +/** Detects transactions from same account with the same nonce and different hash */ +function findCancelTx(localActivity: Activity, remoteMap: ActivityMap, account: string): string | undefined { + // handles locally cached tx's that were stored before we started tracking nonces + if (!localActivity.nonce || localActivity.status !== TransactionStatus.Pending) return undefined + + for (const remoteTx of Object.values(remoteMap)) { + if (!remoteTx) continue + + // A pending tx is 'cancelled' when another tx with the same account & nonce but different hash makes it on chain + if ( + remoteTx.nonce === localActivity.nonce && + remoteTx.from.toLowerCase() === account.toLowerCase() && + remoteTx.hash.toLowerCase() !== localActivity.hash.toLowerCase() + ) { + return remoteTx.hash + } + } + + return undefined +} + +/** Deduplicates local and remote activities */ +function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}): Array { + const txHashes = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])] + + return txHashes.reduce((acc: Array, hash) => { + const localActivity = (localMap?.[hash] ?? {}) as Activity + const remoteActivity = (remoteMap?.[hash] ?? {}) as Activity + + if (localActivity.cancelled) { + // Remote data only contains data of the cancel tx, rather than the original tx, so we prefer local data here + acc.push(localActivity) + } else { + // Generally prefer remote values to local value because i.e. remote swap amounts are on-chain rather than client-estimated + acc.push({ ...localActivity, ...remoteActivity } as Activity) + } + + return acc + }, []) +} + +export function useAllActivities(account: string) { + const { data, loading, refetch } = useActivityQuery({ + variables: { account }, + errorPolicy: 'all', + fetchPolicy: 'cache-first', + }) + + const localMap = useLocalActivities(account) + const remoteMap = useMemo(() => parseRemoteActivities(data?.portfolios?.[0].assetActivities), [data?.portfolios]) + const updateCancelledTx = useTransactionCanceller() + + /* Updates locally stored pendings tx's when remote data contains a conflicting cancellation tx */ + useEffect(() => { + if (!remoteMap) return + + Object.values(localMap).forEach((localActivity) => { + if (!localActivity) return + + const cancelHash = findCancelTx(localActivity, remoteMap, account) + if (cancelHash) updateCancelledTx(localActivity.hash, localActivity.chainId, cancelHash) + }) + }, [account, localMap, remoteMap, updateCancelledTx]) + + const combinedActivities = useMemo( + () => (remoteMap ? combineActivities(localMap, remoteMap) : undefined), + [localMap, remoteMap] + ) + + return { loading, activities: combinedActivities, refetch } +} + +export function useHasPendingActivity() { + const pendingTransactions = usePendingTransactions() + const pendingOrders = usePendingOrders() + + const hasPendingActivity = pendingTransactions.length > 0 || pendingOrders.length > 0 + const pendingActivityCount = pendingTransactions.length + pendingOrders.length + + return { hasPendingActivity, pendingActivityCount } +} diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx b/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx index ba45c7056c..466437ebc3 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx @@ -3,7 +3,7 @@ import { useAccountDrawer } from 'components/AccountDrawer' import Column from 'components/Column' import { LoadingBubble } from 'components/Tokens/loading' import { getYear, isSameDay, isSameMonth, isSameWeek, isSameYear } from 'date-fns' -import { TransactionStatus, useActivityQuery } from 'graphql/data/__generated__/types-and-hooks' +import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks' import { PollingInterval } from 'graphql/data/util' import { atom, useAtom } from 'jotai' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' @@ -13,9 +13,8 @@ import { ThemedText } from 'theme' import { PortfolioSkeleton, PortfolioTabWrapper } from '../PortfolioRow' import { ActivityRow } from './ActivityRow' -import { useLocalActivities } from './parseLocal' -import { parseRemoteActivities } from './parseRemote' -import { Activity, ActivityMap } from './types' +import { useAllActivities } from './hooks' +import { Activity } from './types' interface ActivityGroup { title: string @@ -25,7 +24,7 @@ interface ActivityGroup { const sortActivities = (a: Activity, b: Activity) => b.timestamp - a.timestamp const createGroups = (activities?: Array) => { - if (!activities || !activities.length) return [] + if (!activities) return undefined const now = Date.now() const pending: Array = [] @@ -82,51 +81,13 @@ const ActivityGroupWrapper = styled(Column)` gap: 8px; ` -/* Detects transactions from same account with the same nonce and different hash */ -function wasTxCancelled(localActivity: Activity, remoteMap: ActivityMap, account: string): boolean { - // handles locally cached tx's that were stored before we started tracking nonces - if (!localActivity.nonce || localActivity.status !== TransactionStatus.Pending) return false - - return Object.values(remoteMap).some((remoteTx) => { - if (!remoteTx) return false - - // Cancellations are only possible when both nonce and tx.from are the same - if (remoteTx.nonce === localActivity.nonce && remoteTx.from.toLowerCase() === account.toLowerCase()) { - // If the remote tx has a different hash than the local tx, the local tx was cancelled - return remoteTx.hash.toLowerCase() !== localActivity.hash.toLowerCase() - } - return false - }) -} - -function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}, account: string): Array { - const txHashes = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])] - - // Merges local and remote activities w/ same hash, preferring remote data - return txHashes.reduce((acc: Array, hash) => { - const localActivity = (localMap?.[hash] ?? {}) as Activity - const remoteActivity = (remoteMap?.[hash] ?? {}) as Activity - - // TODO(WEB-2064): Display cancelled status in UI rather than completely hiding cancelled TXs - if (wasTxCancelled(localActivity, remoteMap, account)) return acc - - // TODO(cartcrom): determine best logic for which fields to prefer from which sources - // i.e.prefer remote exact swap output instead of local estimated output - acc.push({ ...localActivity, ...remoteActivity } as Activity) - - return acc - }, []) -} - const lastFetchedAtom = atom(0) export function ActivityTab({ account }: { account: string }) { const [drawerOpen, toggleWalletDrawer] = useAccountDrawer() const [lastFetched, setLastFetched] = useAtom(lastFetchedAtom) - const localMap = useLocalActivities(account) - - const { data, loading, refetch } = useActivityQuery({ variables: { account } }) + const { activities, loading, refetch } = useAllActivities(account) // We only refetch remote activity if the user renavigates to the activity tab by changing tabs or opening the drawer useEffect(() => { @@ -139,20 +100,16 @@ export function ActivityTab({ account }: { account: string }) { } }, [drawerOpen, lastFetched, refetch, setLastFetched]) - const activityGroups = useMemo(() => { - const remoteMap = parseRemoteActivities(data?.portfolios?.[0].assetActivities) - const allActivities = combineActivities(localMap, remoteMap, account) - return createGroups(allActivities) - }, [data?.portfolios, localMap, account]) + const activityGroups = useMemo(() => createGroups(activities), [activities]) - if (!data && loading) + if (!activityGroups && loading) { return ( <> ) - else if (activityGroups.length === 0) { + } else if (!activityGroups || activityGroups?.length === 0) { return } else { return ( diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts b/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts index 93592248bd..850ab96304 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts @@ -25,7 +25,7 @@ import { WrapTransactionInfo, } from 'state/transactions/types' -import { getActivityTitle, OrderTextTable } from '../constants' +import { CancelledTransactionTitleTable, getActivityTitle, OrderTextTable } from '../constants' import { Activity, ActivityMap } from './types' function getCurrency(currencyId: string, chainId: ChainId, tokens: ChainTokenMap): Currency | undefined { @@ -162,6 +162,7 @@ export function transactionToActivity( timestamp: (details.confirmedTime ?? details.addedTime) / 1000, from: details.from, nonce: details.nonce, + cancelled: details.cancelled, } let additionalFields: Partial = {} @@ -184,7 +185,14 @@ export function transactionToActivity( additionalFields = parseMigrateCreateV3(info, chainId, tokens) } - return { ...defaultFields, ...additionalFields } + const activity = { ...defaultFields, ...additionalFields } + + if (details.cancelled) { + activity.title = CancelledTransactionTitleTable[details.info.type] + activity.status = TransactionStatus.Confirmed + } + + return activity } catch (error) { console.debug(`Failed to parse transaction ${details.hash}`, error) return undefined diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx b/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx index e0cfe988bd..613e7092aa 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx @@ -404,15 +404,19 @@ export function useTimeSince(timestamp: number) { const [timeSince, setTimeSince] = useState(getTimeSince(timestamp)) useEffect(() => { - const refreshTime = () => { - if (Math.floor(Date.now() - timestamp * 1000) / ms`61s` <= 1) { - setTimeSince(getTimeSince(timestamp)) - setTimeout(() => { - refreshTime() - }, ms`1s`) - } + const refreshTime = () => + setTimeout(() => { + if (Math.floor(Date.now() - timestamp * 1000) / ms`61s` <= 1) { + setTimeSince(getTimeSince(timestamp)) + timeout = refreshTime() + } + }, ms`1s`) + + let timeout = refreshTime() + + return () => { + timeout && clearTimeout(timeout) } - refreshTime() }, [timestamp]) return timeSince diff --git a/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts b/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts index 819ffddd52..8fbe112149 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts +++ b/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts @@ -18,6 +18,7 @@ export type Activity = { from: string nonce?: number | null prefixIconSrc?: string + cancelled?: boolean } export type ActivityMap = { [id: string]: Activity | undefined } diff --git a/src/components/AccountDrawer/MiniPortfolio/constants.tsx b/src/components/AccountDrawer/MiniPortfolio/constants.tsx index 673950f9e9..d9033c60ec 100644 --- a/src/components/AccountDrawer/MiniPortfolio/constants.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/constants.tsx @@ -139,6 +139,35 @@ const TransactionTitleTable: { [key in TransactionType]: { [state in Transaction }, } +export const CancelledTransactionTitleTable: { [key in TransactionType]: string } = { + [TransactionType.SWAP]: t`Swap cancelled`, + [TransactionType.WRAP]: t`Wrap cancelled`, + [TransactionType.ADD_LIQUIDITY_V3_POOL]: t`Add liquidity cancelled`, + [TransactionType.REMOVE_LIQUIDITY_V3]: t`Remove liquidity cancelled`, + [TransactionType.CREATE_V3_POOL]: t`Create pool cancelled`, + [TransactionType.COLLECT_FEES]: t`Collect fees cancelled`, + [TransactionType.APPROVAL]: t`Approval cancelled`, + [TransactionType.CLAIM]: t`Claim cancelled`, + [TransactionType.BUY]: t`Buy cancelled`, + [TransactionType.SEND]: t`Send cancelled`, + [TransactionType.RECEIVE]: t`Receive cancelled`, + [TransactionType.MINT]: t`Mint cancelled`, + [TransactionType.BURN]: t`Burn cancelled`, + [TransactionType.VOTE]: t`Vote cancelled`, + [TransactionType.QUEUE]: t`Queue cancelled`, + [TransactionType.EXECUTE]: t`Execute cancelled`, + [TransactionType.BORROW]: t`Borrow cancelled`, + [TransactionType.REPAY]: t`Repay cancelled`, + [TransactionType.DEPLOY]: t`Deploy cancelled`, + [TransactionType.CANCEL]: t`Cancellation cancelled`, + [TransactionType.DELEGATE]: t`Delegate cancelled`, + [TransactionType.DEPOSIT_LIQUIDITY_STAKING]: t`Deposit cancelled`, + [TransactionType.WITHDRAW_LIQUIDITY_STAKING]: t`Withdrawal cancelled`, + [TransactionType.ADD_LIQUIDITY_V2_POOL]: t`Add V2 liquidity cancelled`, + [TransactionType.MIGRATE_LIQUIDITY_V3]: t`Migrate liquidity cancelled`, + [TransactionType.SUBMIT_PROPOSAL]: t`Submit proposal cancelled`, +} + const AlternateTransactionTitleTable: { [key in TransactionType]?: { [state in TransactionStatus]: string } } = { [TransactionType.WRAP]: { [TransactionStatus.Pending]: t`Unwrapping`, diff --git a/src/components/AccountDrawer/MiniPortfolio/index.tsx b/src/components/AccountDrawer/MiniPortfolio/index.tsx index 706efebcd7..66e6b9cd7c 100644 --- a/src/components/AccountDrawer/MiniPortfolio/index.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/index.tsx @@ -7,11 +7,11 @@ import { AutoRow } from 'components/Row' import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' import { useIsNftPage } from 'hooks/useIsNftPage' import { useEffect, useState } from 'react' -import { useHasPendingTransactions } from 'state/transactions/hooks' import styled, { useTheme } from 'styled-components/macro' import { BREAKPOINTS, ThemedText } from 'theme' import { ActivityTab } from './Activity' +import { useHasPendingActivity } from './Activity/hooks' import NFTs from './NFTs' import Pools from './Pools' import { PortfolioRowWrapper } from './PortfolioRow' @@ -103,11 +103,11 @@ export default function MiniPortfolio({ account }: { account: string }) { const { component: Page, key: currentKey } = Pages[currentPage] - const hasPendingTransactions = useHasPendingTransactions() + const { hasPendingActivity } = useHasPendingActivity() useEffect(() => { - if (hasPendingTransactions && currentKey !== 'activity') setActivityUnread(true) - }, [currentKey, hasPendingTransactions]) + if (hasPendingActivity && currentKey !== 'activity') setActivityUnread(true) + }, [currentKey, hasPendingActivity]) return ( @@ -116,7 +116,7 @@ export default function MiniPortfolio({ account }: { account: string }) { {Pages.map(({ title, loggingElementName, key }, index) => { if (shouldDisableNFTRoutes && loggingElementName.includes('nft')) return null const isUnselectedActivity = key === 'activity' && currentKey !== 'activity' - const showActivityIndicator = isUnselectedActivity && (hasPendingTransactions || activityUnread) + const showActivityIndicator = isUnselectedActivity && (hasPendingActivity || activityUnread) const handleNavItemClick = () => { setCurrentPage(index) if (key === 'activity') setActivityUnread(false) @@ -133,7 +133,7 @@ export default function MiniPortfolio({ account }: { account: string }) { {showActivityIndicator && ( <>   - {hasPendingTransactions ? ( + {hasPendingActivity ? ( ) : ( diff --git a/src/components/Popups/PopupContent.tsx b/src/components/Popups/PopupContent.tsx index 01c76675db..e344f7fb1e 100644 --- a/src/components/Popups/PopupContent.tsx +++ b/src/components/Popups/PopupContent.tsx @@ -92,7 +92,7 @@ const Descriptor = styled(ThemedText.BodySmall)` type ActivityPopupContentProps = { activity: Activity; onClick: () => void; onClose: () => void } function ActivityPopupContent({ activity, onClick, onClose }: ActivityPopupContentProps) { - const success = activity.status === TransactionStatus.Confirmed + const success = activity.status === TransactionStatus.Confirmed && !activity.cancelled const { ENSName } = useENSName(activity?.otherAccount) return ( diff --git a/src/components/Web3Status/index.tsx b/src/components/Web3Status/index.tsx index ff1a16f6e6..62fe105727 100644 --- a/src/components/Web3Status/index.tsx +++ b/src/components/Web3Status/index.tsx @@ -3,6 +3,7 @@ import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap import { useWeb3React } from '@web3-react/core' import { sendAnalyticsEvent, TraceEvent } from 'analytics' import PortfolioDrawer, { useAccountDrawer } from 'components/AccountDrawer' +import { useHasPendingActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' import PrefetchBalancesWrapper from 'components/AccountDrawer/PrefetchBalancesWrapper' import Loader from 'components/Icons/LoadingSpinner' import { IconWrapper } from 'components/Identicon/StatusIcon' @@ -13,15 +14,13 @@ import { navSearchInputVisibleSize } from 'hooks/useScreenSize' import { Portal } from 'nft/components/common/Portal' import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable' import { darken } from 'polished' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { useAppSelector } from 'state/hooks' -import { usePendingOrders } from 'state/signatures/hooks' import styled from 'styled-components/macro' import { colors } from 'theme/colors' import { flexRowNoWrap } from 'theme/styles' import { shortenAddress } from 'utils' -import { isTransactionRecent, useAllTransactions } from '../../state/transactions/hooks' import { TransactionDetails } from '../../state/transactions/types' import { ButtonSecondary } from '../Button' import StatusIcon from '../Identicon/StatusIcon' @@ -147,18 +146,7 @@ function Web3StatusInner() { }, [toggleAccountDrawer]) const isClaimAvailable = useIsNftClaimAvailable((state) => state.isClaimAvailable) - const allTransactions = useAllTransactions() - - const sortedRecentTransactions = useMemo(() => { - const txs = Object.values(allTransactions) - return txs.filter(isTransactionRecent).sort(newTransactionsFirst) - }, [allTransactions]) - - const pendingOrders = usePendingOrders() - - const pendingTxs = sortedRecentTransactions.filter((tx) => !tx.receipt).map((tx) => tx.hash) - - const hasPendingActivity = !!pendingTxs.length || !!pendingOrders.length + const { hasPendingActivity, pendingActivityCount } = useHasPendingActivity() if (account) { return ( @@ -180,7 +168,7 @@ function Web3StatusInner() { {hasPendingActivity ? ( - {pendingTxs.length + pendingOrders.length} Pending + {pendingActivityCount} Pending {' '} diff --git a/src/state/transactions/hooks.test.tsx b/src/state/transactions/hooks.test.tsx index dcfbd1d8ad..e3a12cfc51 100644 --- a/src/state/transactions/hooks.test.tsx +++ b/src/state/transactions/hooks.test.tsx @@ -7,7 +7,13 @@ import store from 'state' import { mocked } from 'test-utils/mocked' import { act, renderHook } from 'test-utils/render' -import { useHasPendingApproval, useHasPendingRevocation, useTransactionAdder, useTransactionRemover } from './hooks' +import { + useHasPendingApproval, + useHasPendingRevocation, + useTransactionAdder, + useTransactionCanceller, + useTransactionRemover, +} from './hooks' import { clearAllTransactions, finalizeTransaction } from './reducer' import { ApproveTransactionInfo, TransactionInfo, TransactionType } from './types' @@ -177,4 +183,23 @@ describe('Transactions hooks', () => { expect(result.current).toBe(false) }) }) + + describe('useTransactionCanceller', () => { + it('Replaces the original tx with a cancel tx with a different hash', () => { + addPendingTransaction(mockApprovalTransactionInfo) + const { result: canceller } = renderHook(() => useTransactionCanceller()) + + const originalTransactionDetails = store.getState().transactions[ChainId.MAINNET][pendingTransactionResponse.hash] + + act(() => canceller.current(pendingTransactionResponse.hash, ChainId.MAINNET, '0x456')) + + expect(store.getState().transactions[ChainId.MAINNET][pendingTransactionResponse.hash]).toBeUndefined() + + expect(store.getState().transactions[ChainId.MAINNET]['0x456']).toEqual({ + ...originalTransactionDetails, + hash: '0x456', + cancelled: true, + }) + }) + }) }) diff --git a/src/state/transactions/hooks.tsx b/src/state/transactions/hooks.tsx index 954e6e85f3..e3bc9090ac 100644 --- a/src/state/transactions/hooks.tsx +++ b/src/state/transactions/hooks.tsx @@ -9,7 +9,7 @@ import { useCallback, useMemo } from 'react' import { useAppDispatch, useAppSelector } from 'state/hooks' import { TradeFillType } from 'state/routing/types' -import { addTransaction, removeTransaction } from './reducer' +import { addTransaction, cancelTransaction, removeTransaction } from './reducer' import { TransactionDetails, TransactionInfo, TransactionType } from './types' // helper that can take a ethers library transaction response and add it to the list of transactions @@ -51,6 +51,17 @@ export function useTransactionRemover() { ) } +export function useTransactionCanceller() { + const dispatch = useAppDispatch() + + return useCallback( + (hash: string, chainId: number, cancelHash: string) => { + dispatch(cancelTransaction({ hash, chainId, cancelHash })) + }, + [dispatch] + ) +} + export function useMultichainTransactions(): [TransactionDetails, ChainId][] { const state = useAppSelector((state) => state.transactions) return SUPPORTED_CHAINS.flatMap((chainId) => @@ -82,7 +93,7 @@ export function useIsTransactionPending(transactionHash?: string): boolean { if (!transactionHash || !transactions[transactionHash]) return false - return !transactions[transactionHash].receipt + return isPendingTx(transactions[transactionHash]) } export function useIsTransactionConfirmed(transactionHash?: string): boolean { @@ -103,7 +114,7 @@ export function useSwapTransactionStatus(swapResult: SwapResult | undefined): Tr * Returns whether a transaction happened in the last day (86400 seconds * 1000 milliseconds / second) * @param tx to check for recency */ -export function isTransactionRecent(tx: TransactionDetails): boolean { +function isTransactionRecent(tx: TransactionDetails): boolean { return new Date().getTime() - tx.addedTime < 86_400_000 } @@ -133,9 +144,11 @@ export function useHasPendingRevocation(token?: Token, spender?: string): boolea return usePendingApprovalAmount(token, spender)?.eq(0) ?? false } -export function useHasPendingTransactions() { - const allTransactions = useAllTransactions() - return useMemo(() => { - return Object.values(allTransactions).filter((tx) => !tx.receipt).length > 0 - }, [allTransactions]) +export function isPendingTx(tx: TransactionDetails): boolean { + return !tx.receipt && !tx.cancelled +} + +export function usePendingTransactions(): TransactionDetails[] { + const allTransactions = useAllTransactions() + return useMemo(() => Object.values(allTransactions).filter(isPendingTx), [allTransactions]) } diff --git a/src/state/transactions/reducer.test.ts b/src/state/transactions/reducer.test.ts index 4beebf11f7..d4b178d43a 100644 --- a/src/state/transactions/reducer.test.ts +++ b/src/state/transactions/reducer.test.ts @@ -4,6 +4,7 @@ import { createStore, Store } from 'redux' import { updateVersion } from '../global/actions' import reducer, { addTransaction, + cancelTransaction, checkedTransaction, clearAllTransactions, finalizeTransaction, @@ -232,4 +233,44 @@ describe('transaction reducer', () => { expect(Object.keys(store.getState()[ChainId.OPTIMISM] ?? {})).toEqual(['0x1']) }) }) + + describe('cancelTransaction', () => { + it('replaces original tx with a cancel tx', () => { + store.dispatch( + addTransaction({ + chainId: ChainId.MAINNET, + hash: '0x0', + nonce: 7, + info: { type: TransactionType.APPROVAL, spender: 'abc', tokenAddress: 'def', amount: '10000' }, + from: 'abc', + }) + ) + const originalTx = store.getState()[ChainId.MAINNET]?.['0x0'] + store.dispatch( + cancelTransaction({ + chainId: ChainId.MAINNET, + hash: '0x0', + cancelHash: '0x1', + }) + ) + expect(Object.keys(store.getState())).toHaveLength(1) + expect(Object.keys(store.getState())).toEqual([String(ChainId.MAINNET)]) + expect(Object.keys(store.getState()[ChainId.MAINNET] ?? {})).toEqual(['0x1']) + + const cancelTx = store.getState()[ChainId.MAINNET]?.['0x1'] + + expect(cancelTx).toEqual({ ...originalTx, hash: '0x1', cancelled: true }) + }) + it('does not error on cancelling a non-existant tx', () => { + store.dispatch( + cancelTransaction({ + chainId: ChainId.MAINNET, + hash: '0x0', + cancelHash: '0x1', + }) + ) + expect(Object.keys(store.getState())).toHaveLength(0) + expect(Object.keys(store.getState())).toEqual([]) + }) + }) }) diff --git a/src/state/transactions/reducer.ts b/src/state/transactions/reducer.ts index d30c2dc897..9848a5b3d2 100644 --- a/src/state/transactions/reducer.ts +++ b/src/state/transactions/reducer.ts @@ -67,6 +67,18 @@ const transactionSlice = createSlice({ tx.receipt = receipt tx.confirmedTime = Date.now() }, + cancelTransaction(transactions, { payload: { hash, chainId, cancelHash } }) { + const tx = transactions[chainId]?.[hash] + + if (tx) { + delete transactions[chainId]?.[hash] + transactions[chainId][cancelHash] = { + ...tx, + hash: cancelHash, + cancelled: true, + } + } + }, }, extraReducers: (builder) => { builder.addCase(updateVersion, (transactions) => { @@ -84,6 +96,12 @@ const transactionSlice = createSlice({ }, }) -export const { addTransaction, clearAllTransactions, checkedTransaction, finalizeTransaction, removeTransaction } = - transactionSlice.actions +export const { + addTransaction, + clearAllTransactions, + checkedTransaction, + finalizeTransaction, + removeTransaction, + cancelTransaction, +} = transactionSlice.actions export default transactionSlice.reducer diff --git a/src/state/transactions/types.ts b/src/state/transactions/types.ts index dbcea22913..3bcf07f458 100644 --- a/src/state/transactions/types.ts +++ b/src/state/transactions/types.ts @@ -210,4 +210,5 @@ export interface TransactionDetails { from: string info: TransactionInfo nonce?: number + cancelled?: boolean } diff --git a/src/state/transactions/updater.tsx b/src/state/transactions/updater.tsx index 85ed8fa7c1..4389ddff80 100644 --- a/src/state/transactions/updater.tsx +++ b/src/state/transactions/updater.tsx @@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from 'state/hooks' import { L2_CHAIN_IDS } from '../../constants/chains' import { useAddPopup } from '../application/hooks' +import { isPendingTx } from './hooks' import { checkedTransaction, finalizeTransaction } from './reducer' import { SerializableTransactionReceipt, TransactionDetails } from './types' @@ -33,7 +34,7 @@ export default function Updater() { const pendingTransactions = useMemo(() => { if (!chainId || !transactions[chainId]) return {} return Object.values(transactions[chainId]).reduce((acc, tx) => { - if (!tx.receipt) acc[tx.hash] = tx + if (isPendingTx(tx)) acc[tx.hash] = tx return acc }, {} as Record) }, [chainId, transactions])