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:
cartcrom 2023-08-03 12:35:51 -04:00 committed by GitHub
parent cd43e0beaa
commit 6a1f17ab5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 272 additions and 96 deletions

@ -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 && (
<>
&nbsp;
{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])