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
This commit is contained in:
parent
cd43e0beaa
commit
6a1f17ab5a
90
src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts
Normal file
90
src/components/AccountDrawer/MiniPortfolio/Activity/hooks.ts
Normal file
@ -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<Activity> {
|
||||
const txHashes = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])]
|
||||
|
||||
return txHashes.reduce((acc: Array<Activity>, 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 }
|
||||
}
|
@ -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<Activity>) => {
|
||||
if (!activities || !activities.length) return []
|
||||
if (!activities) return undefined
|
||||
const now = Date.now()
|
||||
|
||||
const pending: Array<Activity> = []
|
||||
@ -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<Activity> {
|
||||
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<Activity>, 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<number | undefined>(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 (
|
||||
<>
|
||||
<LoadingBubble height="16px" width="80px" margin="16px 16px 8px" />
|
||||
<PortfolioSkeleton shrinkRight />
|
||||
</>
|
||||
)
|
||||
else if (activityGroups.length === 0) {
|
||||
} else if (!activityGroups || activityGroups?.length === 0) {
|
||||
return <EmptyWalletModule type="activity" onNavigateClick={toggleWalletDrawer} />
|
||||
} else {
|
||||
return (
|
||||
|
@ -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<Activity> = {}
|
||||
@ -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
|
||||
|
@ -404,15 +404,19 @@ export function useTimeSince(timestamp: number) {
|
||||
const [timeSince, setTimeSince] = useState<string>(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
|
||||
|
@ -18,6 +18,7 @@ export type Activity = {
|
||||
from: string
|
||||
nonce?: number | null
|
||||
prefixIconSrc?: string
|
||||
cancelled?: boolean
|
||||
}
|
||||
|
||||
export type ActivityMap = { [id: string]: Activity | undefined }
|
||||
|
@ -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`,
|
||||
|
@ -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 (
|
||||
<Trace section={InterfaceSectionName.MINI_PORTFOLIO}>
|
||||
@ -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 ? (
|
||||
<LoaderV2 />
|
||||
) : (
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
@ -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 (
|
||||
|
@ -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 ? (
|
||||
<RowBetween>
|
||||
<Text>
|
||||
<Trans>{pendingTxs.length + pendingOrders.length} Pending</Trans>
|
||||
<Trans>{pendingActivityCount} Pending</Trans>
|
||||
</Text>{' '}
|
||||
<Loader stroke="white" />
|
||||
</RowBetween>
|
||||
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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])
|
||||
}
|
||||
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -210,4 +210,5 @@ export interface TransactionDetails {
|
||||
from: string
|
||||
info: TransactionInfo
|
||||
nonce?: number
|
||||
cancelled?: boolean
|
||||
}
|
||||
|
@ -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<string, TransactionDetails>)
|
||||
}, [chainId, transactions])
|
||||
|
Loading…
Reference in New Issue
Block a user