Skip to content
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
6 changes: 5 additions & 1 deletion src/components/AccountDetails/Transaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ export default function Transaction({ hash }: { hash: string }): any {

const tx = allTransactions?.[hash]
const pending = !tx?.receipt
const success = !pending && tx && (tx.receipt?.status === 1 || typeof tx.receipt?.status === 'undefined')
const success =
!pending &&
tx &&
tx.replacementReason !== 'cancelled' &&
(tx.receipt?.status === 1 || typeof tx.receipt?.status === 'undefined')
const summary = tx?.summary

if (!chainId) return null
Expand Down
9 changes: 9 additions & 0 deletions src/state/transactions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ export const addTransaction = createAction<{
}
}>('transactions/addTransaction')
export const clearAllTransactions = createAction<{ chainId: ChainId }>('transactions/clearAllTransactions')
export const removeTransaction = createAction<{ chainId: ChainId; hash: string }>('transactions/removeTransaction')
export const replaceTransaction = createAction<{
chainId: ChainId
hash: string
replacementHash: string
receipt: SerializableTransactionReceipt
reason: 'repriced' | 'cancelled' | 'replaced'
summary?: string
}>('transactions/replaceTransaction')
export const finalizeTransaction = createAction<{
chainId: ChainId
hash: string
Expand Down
42 changes: 42 additions & 0 deletions src/state/transactions/expiration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import Updater from './updater'
import { PENDING_TIMEOUT_MS } from './lifecycle'
const mockDispatch = jest.fn()
const mockProvider = { getTransactionReceipt: jest.fn(), getTransaction: jest.fn(), getTransactionCount: jest.fn() }
const mockTransactions = { 1: { old: { hash: 'old', from: 'sender', addedTime: 0 } } }
jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
useSelector: (read: any) => read({ transactions: mockTransactions }),
}))
jest.mock('@gooddollar/web3sdk-v2', () => ({ useWeb3Context: () => ({ web3Provider: mockProvider }) }), {
virtual: true,
})
jest.mock('@reown/appkit/react', () => ({ useAppKitNetwork: () => ({ chainId: 1 }) }))
jest.mock('../application/hooks', () => ({ useAddPopup: () => jest.fn(), useBlockNumber: () => undefined }))
describe('pending UI expiration without new blocks', () => {
it('removes pending state and its popup on the wall clock deadline', async () => {
jest.useFakeTimers()
const clock = jest.spyOn(Date, 'now').mockReturnValue(PENDING_TIMEOUT_MS - 100)
mockProvider.getTransactionReceipt.mockResolvedValue(null)
const container = document.createElement('div')
const root = createRoot(container)
try {
flushSync(() => root.render(createElement(Updater)))
expect(mockDispatch).not.toHaveBeenCalled()
clock.mockReturnValue(PENDING_TIMEOUT_MS)
jest.advanceTimersByTime(100)
for (let i = 0; i < 12; i++) await Promise.resolve()
expect(mockDispatch).toHaveBeenCalledWith({
type: 'transactions/removeTransaction',
payload: { chainId: 1, hash: 'old' },
})
expect(mockDispatch).toHaveBeenCalledWith({ type: 'application/removePopup', payload: { key: 'old' } })
} finally {
flushSync(() => root.unmount())
clock.mockRestore()
jest.useRealTimers()
}
})
})
54 changes: 54 additions & 0 deletions src/state/transactions/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { checkPendingTransaction, PENDING_TIMEOUT_MS } from './lifecycle'

describe('pending transaction lifecycle', () => {
const provider = () => ({
getTransactionReceipt: jest.fn().mockResolvedValue(null),
getTransaction: jest.fn().mockResolvedValue({ from: 'sender', nonce: 4 }),
getTransactionCount: jest.fn().mockResolvedValue(4),
})
it('keeps an unmined transaction before timeout', async () => {
expect(await checkPendingTransaction(provider(), 'hash', 100, 101)).toEqual({ pending: true })
})
it('expires at the exact timeout boundary', async () => {
expect(await checkPendingTransaction(provider(), 'hash', 100, 100 + PENDING_TIMEOUT_MS)).toEqual({
dismissed: 'timeout',
})
})
it('keeps a real receipt even after the timeout', async () => {
const p = provider()
p.getTransactionReceipt.mockResolvedValue({ status: 1 })
expect(await checkPendingTransaction(p, 'hash', 0, PENDING_TIMEOUT_MS)).toEqual({ receipt: { status: 1 } })
})
it('removes a replaced or cancelled nonce only once mined', async () => {
const p = provider()
p.getTransactionCount.mockResolvedValue(5)
expect(await checkPendingTransaction(p, 'hash', 0, 1)).toEqual({ dismissed: 'replaced' })
expect(p.getTransactionCount).toHaveBeenCalledWith('sender', 'latest')
})
it('rechecks receipts to avoid racing the original transaction mining', async () => {
const p = provider()
p.getTransactionCount.mockResolvedValue(5)
p.getTransactionReceipt.mockResolvedValueOnce(null).mockResolvedValueOnce({ status: 1 })
expect(await checkPendingTransaction(p, 'hash', 0, 1)).toEqual({ receipt: { status: 1 } })
})
it('does not treat a missing transaction or RPC failure as replacement', async () => {
const p = provider()
p.getTransaction.mockResolvedValue(null)
expect(await checkPendingTransaction(p, 'hash', 0, 1)).toEqual({ pending: true })
p.getTransactionReceipt.mockRejectedValue(new Error('offline'))
await expect(checkPendingTransaction(p, 'hash', 0, 1)).rejects.toThrow('offline')
expect(await checkPendingTransaction(p, 'hash', 0, PENDING_TIMEOUT_MS)).toEqual({ dismissed: 'timeout' })
})
it('expires local pending UI even when the receipt RPC hangs', async () => {
jest.useFakeTimers()
try {
const p = provider()
p.getTransactionReceipt.mockImplementation(() => new Promise(() => {}))
const result = checkPendingTransaction(p, 'hash', 0, PENDING_TIMEOUT_MS)
jest.advanceTimersByTime(10_000)
await expect(result).resolves.toEqual({ dismissed: 'timeout' })
} finally {
jest.useRealTimers()
}
})
})
138 changes: 138 additions & 0 deletions src/state/transactions/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// This expires local pending UI only; it never cancels an on-chain transaction.
export const PENDING_TIMEOUT_MS = 30 * 60 * 1000

type Transaction = {
hash?: string
from: string
nonce: number
to?: string | null
data?: string
value?: { toString(): string }
}
type Provider<Receipt> = {
getTransactionReceipt(hash: string): Promise<Receipt | null>
getTransaction(hash: string): Promise<Transaction | null>
getTransactionCount(address: string, blockTag: 'latest' | number): Promise<number>
getBlockNumber?(): Promise<number>
getBlockWithTransactions?(block: number): Promise<{ transactions: Transaction[] }>
}

export type PendingResult<Receipt> =
| { receipt: Receipt }
| { dismissed: 'timeout' | 'replaced' }
| { pending: true }
| { replacement: { hash: string; receipt: Receipt; reason: 'repriced' | 'cancelled' | 'replaced' } }

function sameAddress(a?: string | null, b?: string | null): boolean {
return a?.toLowerCase() === b?.toLowerCase()
}

/** Locate the first mined nonce transition; do not assume the replacement is in the latest block. */
async function replacementReceipt<Receipt>(
provider: Provider<Receipt>,
original: Transaction
): Promise<PendingResult<Receipt>> {
if (!provider.getBlockNumber || !provider.getBlockWithTransactions) return { dismissed: 'replaced' }
const head = await bounded(provider.getBlockNumber())
let low = 0,
high = head,
distance = 1
// Exponential lookback keeps normal speed-ups cheap and still finds older replacements.
while (high > 0) {
const before = Math.max(0, head - distance)
const count = await bounded(provider.getTransactionCount(original.from, before))
if (count <= original.nonce) {
low = before + 1
break
}
high = before
distance *= 2
}
while (low < high) {
const mid = Math.floor((low + high) / 2)
const count = await bounded(provider.getTransactionCount(original.from, mid))
if (count > original.nonce) high = mid
else low = mid + 1
}
const block = await bounded(provider.getBlockWithTransactions(high))
const replacement = block.transactions.find(
(tx) => sameAddress(tx.from, original.from) && tx.nonce === original.nonce
)
if (!replacement?.hash || replacement.hash === original.hash) return { pending: true }
const receipt = await bounded(provider.getTransactionReceipt(replacement.hash))
if (!receipt) return { pending: true } // A reorg or incomplete RPC response is not a confirmed replacement.
const mined = receipt as { transactionHash?: string; blockNumber?: number }
if (mined.transactionHash && mined.transactionHash.toLowerCase() !== replacement.hash.toLowerCase())
return { pending: true }
if (mined.blockNumber !== undefined && mined.blockNumber !== high) return { pending: true }
const repriced =
original.data !== undefined &&
original.value !== undefined &&
replacement.data === original.data &&
sameAddress(replacement.to, original.to) &&
replacement.value?.toString() === original.value.toString()
const cancelled =
sameAddress(replacement.to, original.from) && replacement.data === '0x' && replacement.value?.toString() === '0'
return {
replacement: {
hash: replacement.hash,
receipt,
reason: repriced ? 'repriced' : cancelled ? 'cancelled' : 'replaced',
},
}
}

async function bounded<T>(operation: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Transaction RPC timed out')), 10_000)
}),
])
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}

export async function checkPendingTransaction<Receipt>(
provider: Provider<Receipt>,
hash: string,
addedTime: number,
now = Date.now()
): Promise<PendingResult<Receipt>> {
try {
const receipt = await bounded(provider.getTransactionReceipt(hash))
if (receipt) return { receipt }
if (now - addedTime >= PENDING_TIMEOUT_MS) return { dismissed: 'timeout' }
const transaction = await bounded(provider.getTransaction(hash))
if (
transaction &&
(await bounded(provider.getTransactionCount(transaction.from, 'latest'))) > transaction.nonce
) {
const mined = await bounded(provider.getTransactionReceipt(hash))
if (mined) return { receipt: mined }
return await replacementReceipt(provider, { ...transaction, hash })
}
return { pending: true }
} catch (error) {
// RPC errors are not evidence of replacement; the local timeout still applies.
if (now - addedTime >= PENDING_TIMEOUT_MS) return { dismissed: 'timeout' }
throw error
}
}

export function shouldCheck(
lastBlockNumber: number,
tx: { addedTime: number; receipt?: object; lastCheckedBlockNumber?: number }
): boolean {
if (tx.receipt) return false
if (!tx.lastCheckedBlockNumber) return true
const blocksSinceCheck = lastBlockNumber - tx.lastCheckedBlockNumber
if (blocksSinceCheck < 1) return false
const minutesPending = (Date.now() - tx.addedTime) / 1000 / 60
if (minutesPending > 60) return blocksSinceCheck > 9
if (minutesPending > 5) return blocksSinceCheck > 2
return true
}
31 changes: 30 additions & 1 deletion src/state/transactions/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
checkedTransaction,
clearAllTransactions,
finalizeTransaction,
removeTransaction,
replaceTransaction,
SerializableTransactionReceipt,
} from './actions'

Expand All @@ -18,6 +20,7 @@ export interface TransactionDetails {
lastCheckedBlockNumber?: number
addedTime: number
confirmedTime?: number
replacementReason?: 'repriced' | 'cancelled' | 'replaced'
from: string
tradeInfo?: {
input: {
Expand Down Expand Up @@ -56,6 +59,32 @@ const transactions = createReducer(initialState, (builder) =>
if (!transactions[chainId]) return
transactions[chainId] = {}
})
.addCase(removeTransaction, (transactions, { payload: { chainId, hash } }) => {
if (transactions[chainId]?.[hash] && !transactions[chainId][hash].receipt) {
delete transactions[chainId][hash]
}
})
.addCase(
replaceTransaction,
(transactions, { payload: { chainId, hash, replacementHash, receipt, reason, summary } }) => {
const tx = transactions[chainId]?.[hash]
if (!tx || tx.receipt || hash === replacementHash) return
const existing = transactions[chainId][replacementHash]
transactions[chainId][replacementHash] = {
...(reason === 'repriced' ? tx : { from: receipt.from, addedTime: tx.addedTime }),
...existing,
hash: replacementHash,
receipt,
confirmedTime: now(),
replacementReason: reason,
claim: reason === 'repriced' ? existing?.claim ?? tx.claim : undefined,
approval: reason === 'repriced' ? existing?.approval ?? tx.approval : undefined,
tradeInfo: reason === 'repriced' ? existing?.tradeInfo ?? tx.tradeInfo : undefined,
summary: existing?.summary ?? (reason === 'repriced' ? tx.summary : summary),
}
delete transactions[chainId][hash]
}
)
.addCase(checkedTransaction, (transactions, { payload: { chainId, hash, blockNumber } }) => {
const tx = transactions[chainId]?.[hash]
if (!tx) {
Expand All @@ -74,7 +103,7 @@ const transactions = createReducer(initialState, (builder) =>
}
tx.receipt = receipt
tx.confirmedTime = now()
tx.summary = summary
if (summary !== undefined) tx.summary = summary
})
)

Expand Down
37 changes: 37 additions & 0 deletions src/state/transactions/remove.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ChainId } from '@sushiswap/sdk'
import { addTransaction, finalizeTransaction, removeTransaction } from './actions'
import reducer from './reducer'

describe('remove pending transaction', () => {
it('removes only the requested chain and hash', () => {
let state = reducer(undefined, addTransaction({ chainId: ChainId.MAINNET, hash: 'old', from: 'sender' }))
state = reducer(state, addTransaction({ chainId: ChainId.MAINNET, hash: 'other', from: 'sender' }))
state = reducer(state, removeTransaction({ chainId: ChainId.MAINNET, hash: 'old' }))
expect(state[ChainId.MAINNET].old).toBeUndefined()
expect(state[ChainId.MAINNET].other).toBeDefined()
expect(reducer(state, removeTransaction({ chainId: ChainId.MAINNET, hash: 'missing' }))).toBe(state)
})
it('does not remove a receipt if a late timeout races confirmation', () => {
let state = reducer(undefined, addTransaction({ chainId: ChainId.MAINNET, hash: 'old', from: 'sender' }))
state = reducer(
state,
finalizeTransaction({
chainId: ChainId.MAINNET,
hash: 'old',
receipt: {
to: 'to',
from: 'sender',
contractAddress: '',
transactionIndex: 0,
blockHash: 'block',
transactionHash: 'old',
blockNumber: 1,
status: 1,
},
})
)
expect(
reducer(state, removeTransaction({ chainId: ChainId.MAINNET, hash: 'old' }))[ChainId.MAINNET].old.receipt
).toBeDefined()
})
})
Loading