diff --git a/src/components/AccountDetails/Transaction.tsx b/src/components/AccountDetails/Transaction.tsx index 75f9b0b54..bb0d614a1 100644 --- a/src/components/AccountDetails/Transaction.tsx +++ b/src/components/AccountDetails/Transaction.tsx @@ -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 diff --git a/src/state/transactions/actions.ts b/src/state/transactions/actions.ts index 388ec5b7a..c27188ec8 100644 --- a/src/state/transactions/actions.ts +++ b/src/state/transactions/actions.ts @@ -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 diff --git a/src/state/transactions/expiration.test.ts b/src/state/transactions/expiration.test.ts new file mode 100644 index 000000000..2ce399dc5 --- /dev/null +++ b/src/state/transactions/expiration.test.ts @@ -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() + } + }) +}) diff --git a/src/state/transactions/lifecycle.test.ts b/src/state/transactions/lifecycle.test.ts new file mode 100644 index 000000000..904c2ede2 --- /dev/null +++ b/src/state/transactions/lifecycle.test.ts @@ -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() + } + }) +}) diff --git a/src/state/transactions/lifecycle.ts b/src/state/transactions/lifecycle.ts new file mode 100644 index 000000000..15de912b8 --- /dev/null +++ b/src/state/transactions/lifecycle.ts @@ -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 = { + getTransactionReceipt(hash: string): Promise + getTransaction(hash: string): Promise + getTransactionCount(address: string, blockTag: 'latest' | number): Promise + getBlockNumber?(): Promise + getBlockWithTransactions?(block: number): Promise<{ transactions: Transaction[] }> +} + +export type PendingResult = + | { 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( + provider: Provider, + original: Transaction +): Promise> { + 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(operation: Promise): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Transaction RPC timed out')), 10_000) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +export async function checkPendingTransaction( + provider: Provider, + hash: string, + addedTime: number, + now = Date.now() +): Promise> { + 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 +} diff --git a/src/state/transactions/reducer.ts b/src/state/transactions/reducer.ts index 3541c9192..3108e3f63 100644 --- a/src/state/transactions/reducer.ts +++ b/src/state/transactions/reducer.ts @@ -4,6 +4,8 @@ import { checkedTransaction, clearAllTransactions, finalizeTransaction, + removeTransaction, + replaceTransaction, SerializableTransactionReceipt, } from './actions' @@ -18,6 +20,7 @@ export interface TransactionDetails { lastCheckedBlockNumber?: number addedTime: number confirmedTime?: number + replacementReason?: 'repriced' | 'cancelled' | 'replaced' from: string tradeInfo?: { input: { @@ -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) { @@ -74,7 +103,7 @@ const transactions = createReducer(initialState, (builder) => } tx.receipt = receipt tx.confirmedTime = now() - tx.summary = summary + if (summary !== undefined) tx.summary = summary }) ) diff --git a/src/state/transactions/remove.test.ts b/src/state/transactions/remove.test.ts new file mode 100644 index 000000000..25d3da2de --- /dev/null +++ b/src/state/transactions/remove.test.ts @@ -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() + }) +}) diff --git a/src/state/transactions/replacement-reducer.test.ts b/src/state/transactions/replacement-reducer.test.ts new file mode 100644 index 000000000..64119423d --- /dev/null +++ b/src/state/transactions/replacement-reducer.test.ts @@ -0,0 +1,63 @@ +import { addTransaction, replaceTransaction } from './actions' +import reducer from './reducer' +const receipt = { + to: 'recipient', + from: 'sender', + contractAddress: '', + transactionIndex: 0, + blockHash: 'block', + transactionHash: 'new', + blockNumber: 8, + status: 1, +} +describe('atomic replacement state', () => { + for (const reason of ['repriced', 'cancelled', 'replaced'] as const) + it(`replaces old queue entry for ${reason}`, () => { + let state = reducer( + undefined, + addTransaction({ + chainId: 1, + hash: 'old', + from: 'sender', + summary: 'Claim reward', + claim: { recipient: 'sender' }, + approval: { tokenAddress: 'token', spender: 'spender' }, + }) + ) + state = reducer( + state, + replaceTransaction({ + chainId: 1, + hash: 'old', + replacementHash: 'new', + receipt, + reason, + summary: 'Replacement', + }) + ) + expect(state[1].old).toBeUndefined() + expect(state[1].new.receipt?.transactionHash).toBe('new') + expect(state[1].new.replacementReason).toBe(reason) + expect(state[1].new.claim).toEqual(reason === 'repriced' ? { recipient: 'sender' } : undefined) + expect(state[1].new.approval).toEqual( + reason === 'repriced' ? { tokenAddress: 'token', spender: 'spender' } : undefined + ) + expect(state[1].new.summary).toBe(reason === 'repriced' ? 'Claim reward' : 'Replacement') + expect( + reducer(state, replaceTransaction({ chainId: 1, hash: 'old', replacementHash: 'new', receipt, reason })) + ).toBe(state) + }) + it('preserves a replacement already tracked by its real hash', () => { + let state = reducer( + undefined, + addTransaction({ chainId: 1, hash: 'old', from: 'sender', summary: 'Old summary' }) + ) + state = reducer(state, addTransaction({ chainId: 1, hash: 'new', from: 'sender', summary: 'Already tracked' })) + state = reducer( + state, + replaceTransaction({ chainId: 1, hash: 'old', replacementHash: 'new', receipt, reason: 'repriced' }) + ) + expect(state[1].new.summary).toBe('Already tracked') + expect(Object.keys(state[1])).toEqual(['new']) + }) +}) diff --git a/src/state/transactions/replacement-updater.test.ts b/src/state/transactions/replacement-updater.test.ts new file mode 100644 index 000000000..63362a018 --- /dev/null +++ b/src/state/transactions/replacement-updater.test.ts @@ -0,0 +1,132 @@ +import { createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' +import Updater from './updater' +const mockDispatch = jest.fn(), + mockPopup = jest.fn() +let mockBlock: number | undefined = 10 +const mockProvider = { + getTransactionReceipt: jest.fn(), + getTransaction: jest.fn(), + getTransactionCount: jest.fn(), + getBlockNumber: jest.fn(), + getBlockWithTransactions: jest.fn(), +} +const mockTransactions: Record> = { 1: {} } +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: () => mockPopup, useBlockNumber: () => mockBlock })) +jest.mock('@lingui/core', () => ({ i18n: { _: (id: string) => id } })) +const value = (n: number) => ({ toString: () => String(n) }) +const receipt = { + transactionHash: 'new', + blockHash: 'block8', + blockNumber: 8, + status: 1, + from: 'sender', + to: 'recipient', + transactionIndex: 0, + contractAddress: '', +} +async function renderAndFlush() { + const root = createRoot(document.createElement('div')) + flushSync(() => root.render(createElement(Updater))) + for (let i = 0; i < 80; i++) await Promise.resolve() + return root +} +beforeEach(() => { + jest.clearAllMocks() + mockBlock = 10 + mockTransactions[1] = { old: { hash: 'old', from: 'sender', addedTime: Date.now(), summary: 'Claim reward' } } +}) +describe('replacement UI notifications', () => { + for (const cancelled of [false, true]) + it(`records the real replacement and clears old popup (cancelled=${cancelled})`, async () => { + const original = { hash: 'old', from: 'sender', nonce: 4, to: 'recipient', data: '0x1234', value: value(7) } + const candidate = { + ...original, + hash: 'new', + ...(cancelled ? { to: 'sender', data: '0x', value: value(0) } : {}), + } + mockProvider.getTransactionReceipt.mockImplementation(async (hash: string) => + hash === 'new' ? receipt : null + ) + mockProvider.getTransaction.mockResolvedValue(original) + mockProvider.getTransactionCount.mockImplementation(async (_from: string, tag: number | 'latest') => + tag === 'latest' || tag >= 8 ? 5 : 4 + ) + mockProvider.getBlockNumber.mockResolvedValue(10) + mockProvider.getBlockWithTransactions.mockResolvedValue({ transactions: [candidate] }) + const root = await renderAndFlush() + try { + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'transactions/replaceTransaction', + payload: expect.objectContaining({ + hash: 'old', + replacementHash: 'new', + reason: cancelled ? 'cancelled' : 'repriced', + receipt, + }), + }) + ) + expect(mockDispatch).toHaveBeenCalledWith({ type: 'application/removePopup', payload: { key: 'old' } }) + expect(mockPopup).toHaveBeenCalledTimes(1) + expect(mockPopup).toHaveBeenCalledWith( + { + txn: { + hash: 'new', + success: !cancelled, + summary: cancelled ? 'Transaction cancelled' : 'Claim reward', + }, + }, + 'new' + ) + } finally { + flushSync(() => root.unmount()) + } + }) + it('does not duplicate a replacement notification already confirmed in the store', async () => { + const original = { hash: 'old', from: 'sender', nonce: 4, to: 'recipient', data: '0x', value: value(7) } + mockTransactions[1].new = { hash: 'new', from: 'sender', addedTime: 0, receipt } + mockProvider.getTransactionReceipt.mockImplementation(async (hash: string) => (hash === 'new' ? receipt : null)) + mockProvider.getTransaction.mockResolvedValue(original) + mockProvider.getTransactionCount.mockImplementation(async (_f: string, tag: number | 'latest') => + tag === 'latest' || tag >= 8 ? 5 : 4 + ) + mockProvider.getBlockNumber.mockResolvedValue(10) + mockProvider.getBlockWithTransactions.mockResolvedValue({ transactions: [{ ...original, hash: 'new' }] }) + const root = await renderAndFlush() + try { + expect(mockPopup).not.toHaveBeenCalled() + } finally { + flushSync(() => root.unmount()) + } + }) + it('finalizes a normal receipt found by the deadline timer even without blocks', async () => { + jest.useFakeTimers() + const clock = jest.spyOn(Date, 'now').mockReturnValue(1800000) + mockBlock = undefined + mockTransactions[1].old.addedTime = 0 + mockProvider.getTransactionReceipt.mockResolvedValue({ ...receipt, transactionHash: 'old' }) + const root = await renderAndFlush() + try { + jest.advanceTimersByTime(0) + for (let i = 0; i < 40; i++) await Promise.resolve() + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'transactions/finalizeTransaction' }) + ) + expect(mockPopup).toHaveBeenCalledTimes(1) + } finally { + flushSync(() => root.unmount()) + clock.mockRestore() + jest.useRealTimers() + } + }) +}) diff --git a/src/state/transactions/replacement.test.ts b/src/state/transactions/replacement.test.ts new file mode 100644 index 000000000..dea0b106b --- /dev/null +++ b/src/state/transactions/replacement.test.ts @@ -0,0 +1,61 @@ +import { checkPendingTransaction } from './lifecycle' +const amount = (n: number) => ({ toString: () => String(n) }) +function fixture(reason = 'repriced', mined = 8, head = 10) { + const original = { hash: 'old', from: '0xAA', nonce: 4, to: '0xBB', data: '0x1234', value: amount(7) } + const candidate = { + ...original, + hash: 'new', + ...(reason === 'cancelled' + ? { to: '0xaa', data: '0x', value: amount(0) } + : reason === 'replaced' + ? { to: '0xCC' } + : {}), + } + const receipt = { transactionHash: 'new', blockNumber: mined, status: 1 } + const provider = { + getTransactionReceipt: jest.fn(async (hash: string) => (hash === 'new' ? receipt : null)), + getTransaction: jest.fn(async () => original), + getTransactionCount: jest.fn(async (_from: string, tag: number | 'latest') => + tag === 'latest' || tag >= mined ? 5 : 4 + ), + getBlockNumber: jest.fn(async () => head), + getBlockWithTransactions: jest.fn(async () => ({ transactions: [{ ...candidate, nonce: 3 }, candidate] })), + } + return { provider, receipt } +} +describe('replacement receipt discovery', () => { + for (const reason of ['repriced', 'cancelled', 'replaced']) + it(`tracks ${reason} under the real hash`, async () => { + const { provider, receipt } = fixture(reason) + expect(await checkPendingTransaction(provider, 'old', 0, 1)).toEqual({ + replacement: { hash: 'new', receipt, reason }, + }) + expect(provider.getBlockWithTransactions).toHaveBeenCalledWith(8) + }) + it('locates an older nonce transition without assuming a recent block', async () => { + const { provider } = fixture('repriced', 800, 1024) + const result = await checkPendingTransaction(provider, 'old', 0, 1) + expect(result).toHaveProperty('replacement.hash', 'new') + expect(provider.getBlockWithTransactions).toHaveBeenCalledWith(800) + expect(provider.getTransactionCount.mock.calls.length).toBeLessThan(25) + }) + it('keeps an incomplete or reorganized replacement pending', async () => { + const { provider } = fixture() + provider.getTransactionReceipt.mockImplementation(async () => null) + expect(await checkPendingTransaction(provider, 'old', 0, 1)).toEqual({ pending: true }) + }) + it('rejects a receipt from a different block', async () => { + const { provider } = fixture() + provider.getTransactionReceipt.mockImplementation(async (hash: string) => + hash === 'new' ? { transactionHash: 'new', blockNumber: 9, status: 1 } : null + ) + expect(await checkPendingTransaction(provider, 'old', 0, 1)).toEqual({ pending: true }) + }) + it('does not accept an unrelated sender or nonce as replacement', async () => { + const { provider } = fixture() + provider.getBlockWithTransactions.mockResolvedValue({ + transactions: [{ hash: 'other', from: 'foreign', nonce: 4, to: '', data: '', value: amount(0) }], + }) + expect(await checkPendingTransaction(provider, 'old', 0, 1)).toEqual({ pending: true }) + }) +}) diff --git a/src/state/transactions/updater.test.ts b/src/state/transactions/updater.test.ts index 58a2f0994..a5f271e6d 100644 --- a/src/state/transactions/updater.test.ts +++ b/src/state/transactions/updater.test.ts @@ -1,4 +1,4 @@ -import { shouldCheck } from './updater' +import { shouldCheck } from './lifecycle' describe('transactions updater', () => { describe('shouldCheck', () => { diff --git a/src/state/transactions/updater.tsx b/src/state/transactions/updater.tsx index 731b49cbc..8d9db3de8 100644 --- a/src/state/transactions/updater.tsx +++ b/src/state/transactions/updater.tsx @@ -1,100 +1,124 @@ import { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useWeb3Context } from '@gooddollar/web3sdk-v2' -import { Web3Provider } from '@ethersproject/providers' +import { Web3Provider, TransactionReceipt } from '@ethersproject/providers' import { useAppKitNetwork } from '@reown/appkit/react' +import { i18n } from '@lingui/core' import { useAddPopup, useBlockNumber } from '../application/hooks' import { AppDispatch, AppState } from '../index' -import { checkedTransaction, finalizeTransaction } from './actions' +import { + checkedTransaction, + finalizeTransaction, + removeTransaction, + replaceTransaction, + SerializableTransactionReceipt, +} from './actions' +import { removePopup } from '../application/actions' +import { checkPendingTransaction, PENDING_TIMEOUT_MS, shouldCheck } from './lifecycle' +import type { TransactionDetails } from './reducer' -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 = (new Date().getTime() - tx.addedTime) / 1000 / 60 - if (minutesPending > 60) { - // every 10 blocks if pending for longer than an hour - return blocksSinceCheck > 9 - } else if (minutesPending > 5) { - // every 3 blocks if pending more than 5 minutes - return blocksSinceCheck > 2 - } else { - // otherwise every block - return true +export { shouldCheck } from './lifecycle' + +function serializable(receipt: TransactionReceipt): SerializableTransactionReceipt { + return { + blockHash: receipt.blockHash, + blockNumber: receipt.blockNumber, + contractAddress: receipt.contractAddress, + from: receipt.from, + status: receipt.status, + to: receipt.to, + transactionHash: receipt.transactionHash, + transactionIndex: receipt.transactionIndex, } } export default function Updater(): null { const { chainId } = useAppKitNetwork() const { web3Provider: library } = useWeb3Context() as { web3Provider: Web3Provider } - const lastBlockNumber = useBlockNumber() - const dispatch = useDispatch() const state = useSelector((state) => state.transactions) - - const transactions = chainId ? state[chainId] ?? {} : {} - - // show popup on confirm + const transactions: Record = chainId ? state[chainId] ?? {} : {} const addPopup = useAddPopup() useEffect(() => { - if (!chainId || !library || !lastBlockNumber) return - Object.keys(transactions) - .filter((hash) => shouldCheck(lastBlockNumber, transactions[hash])) - .forEach((hash) => { - library - .getTransactionReceipt(hash) - .then((receipt) => { - const confirmedSummary = transactions[hash]?.summary - if (receipt) { - dispatch( - finalizeTransaction({ - chainId: +(chainId ?? 1), - hash, - receipt: { - blockHash: receipt.blockHash, - blockNumber: receipt.blockNumber, - contractAddress: receipt.contractAddress, - from: receipt.from, - status: receipt.status, - to: receipt.to, - transactionHash: receipt.transactionHash, - transactionIndex: receipt.transactionIndex, - }, - summary: confirmedSummary, - }) - ) - - addPopup( - { - txn: { - hash, - success: receipt.status === 1, - summary: confirmedSummary, - }, + if (!chainId || !library) return + let active = true + const checking = new Set() + const timers: ReturnType[] = [] + const check = async (hash: string, tx: TransactionDetails) => { + if (checking.has(hash)) return + checking.add(hash) + try { + const result = await checkPendingTransaction(library, hash, tx.addedTime) + if (!active) return + if ('replacement' in result) { + const { hash: replacementHash, receipt, reason } = result.replacement + const summary = + reason === 'repriced' + ? tx.summary + : reason === 'cancelled' + ? i18n._('Transaction cancelled') + : i18n._('Replacement transaction') + dispatch( + replaceTransaction({ + chainId: +chainId, + hash, + replacementHash, + receipt: serializable(receipt), + reason, + summary, + }) + ) + dispatch(removePopup({ key: hash })) + if (!transactions[replacementHash]?.receipt) { + addPopup( + { + txn: { + hash: replacementHash, + success: reason !== 'cancelled' && receipt.status === 1, + summary, }, - hash - ) - } else { - dispatch( - checkedTransaction({ - chainId: +(chainId ?? 1), - hash, - blockNumber: lastBlockNumber, - }) - ) - } - }) - .catch((error) => { - console.error(`failed to check transaction hash: ${hash}`, error) - }) - }) + }, + replacementHash + ) + } + } else if ('dismissed' in result) { + dispatch(removeTransaction({ chainId: +chainId, hash })) + dispatch(removePopup({ key: hash })) + } else if ('receipt' in result) { + dispatch( + finalizeTransaction({ + chainId: +chainId, + hash, + receipt: serializable(result.receipt), + summary: tx.summary, + }) + ) + addPopup({ txn: { hash, success: result.receipt.status === 1, summary: tx.summary } }, hash) + } else if (lastBlockNumber) { + dispatch(checkedTransaction({ chainId: +chainId, hash, blockNumber: lastBlockNumber })) + } + } catch (error) { + if (active) console.error(`failed to check transaction hash: ${hash}`, error) + } finally { + checking.delete(hash) + } + } + for (const [hash, tx] of Object.entries(transactions)) { + if (tx.receipt) continue + if (lastBlockNumber && shouldCheck(lastBlockNumber, tx)) void check(hash, tx) + // The timer uses the same receipt handling as block polling, including confirmations. + timers.push( + setTimeout(() => { + void check(hash, tx) + }, Math.max(0, tx.addedTime + PENDING_TIMEOUT_MS - Date.now())) + ) + } + return () => { + active = false + timers.forEach(clearTimeout) + } }, [chainId, library, transactions, lastBlockNumber, dispatch, addPopup]) - return null }