Commit 0b66fde2 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

fix: only remove expired transactions from updater (#6625)

* test(e2e): disable video compression

* refactor: improve popupList impl/test

* test(e2e): log JSON-RPC calls

* fix: retry backoff logic

* test(e2e): wait for hardhat/popup assertions

* fix: remove transactions after expired

* test(e2e): re-enable other swap tests
Co-authored-by: default avatarJordan Frankfurt <jordanwfrankfurt@gmail.com>

* chore: rm console.log

* fix: expire txs after 6h

* refactor: dry trade info

* test(e2e): use default deadline

---------
Co-authored-by: default avatarJordan Frankfurt <jordanwfrankfurt@gmail.com>
parent 57883859
...@@ -9,6 +9,7 @@ export default defineConfig({ ...@@ -9,6 +9,7 @@ export default defineConfig({
chromeWebSecurity: false, chromeWebSecurity: false,
experimentalMemoryManagement: true, // better memory management, see https://github.com/cypress-io/cypress/pull/25462 experimentalMemoryManagement: true, // better memory management, see https://github.com/cypress-io/cypress/pull/25462
retries: { runMode: 2 }, retries: { runMode: 2 },
videoCompression: false,
e2e: { e2e: {
async setupNodeEvents(on, config) { async setupNodeEvents(on, config) {
await setupHardhatEvents(on, config) await setupHardhatEvents(on, config)
......
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { SupportedChainId } from '@uniswap/sdk-core' import { SupportedChainId } from '@uniswap/sdk-core'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../../src/constants/misc'
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens' import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
import { getBalance, getTestSelector } from '../../utils' import { getBalance, getTestSelector } from '../../utils'
...@@ -13,15 +14,18 @@ describe('Swap errors', () => { ...@@ -13,15 +14,18 @@ describe('Swap errors', () => {
// Stub the wallet to reject any transaction. // Stub the wallet to reject any transaction.
cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled')) cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled'))
// Attempt to swap. // Enter amount to swap
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1') cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '') cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
// Submit transaction
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.contains('Confirm swap').click()
cy.wait('@eth_estimateGas')
cy.contains('Review swap').should('exist') // Verify rejection
cy.get('body').click('topRight') cy.contains('Review swap')
cy.contains('Review swap').should('not.exist') cy.contains('Confirm swap')
}) })
}) })
...@@ -29,32 +33,33 @@ describe('Swap errors', () => { ...@@ -29,32 +33,33 @@ describe('Swap errors', () => {
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' }) cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
cy.hardhat({ automine: false }) cy.hardhat({ automine: false })
getBalance(USDC_MAINNET).then((initialBalance) => { getBalance(USDC_MAINNET).then((initialBalance) => {
// Set deadline to minimum. (1 minute) // Enter amount to swap
cy.get(getTestSelector('open-settings-dialog-button')).click() cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
cy.get(getTestSelector('transaction-deadline-settings')).click()
cy.get(getTestSelector('deadline-input')).clear().type('1') // 1 minute
// Click outside of modal to dismiss it.
cy.get('body').click('topRight')
cy.get(getTestSelector('deadline-input')).should('not.exist')
// Attempt to swap.
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1')
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '') cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
// Submit transaction
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.contains('Confirm swap').click()
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
cy.contains('Transaction submitted')
cy.get(getTestSelector('confirmation-close-icon')).click() cy.get(getTestSelector('confirmation-close-icon')).click()
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine(1, /* 10 minutes */ 1000 * 60 * 10)) // mines past the deadline
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey. // Mine transaction
// cy.get(getTestSelector('transaction-popup')).contains('Swap failed') cy.hardhat().then(async (hardhat) => {
// Remove the transaction from the mempool, so that it doesn't fail but it is past the deadline.
// This should result in it being removed from pending transactions, without a failure notificiation.
const transactions = await hardhat.send('eth_pendingTransactions', [])
await hardhat.send('hardhat_dropTransaction', [transactions[0].hash])
// Mine past the deadline
await hardhat.mine(1, DEFAULT_DEADLINE_FROM_NOW + 1)
})
cy.wait('@eth_getTransactionReceipt')
// Verify the balance is unchanged. // Verify transaction did not occur
cy.get('#swap-currency-output [data-testid="balance-text"]').should('have.text', `Balance: ${initialBalance}`) cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
cy.get(getTestSelector('popups')).should('not.contain', 'Swap failed')
cy.get('#swap-currency-output').contains(`Balance: ${initialBalance}`)
getBalance(USDC_MAINNET).should('eq', initialBalance) getBalance(USDC_MAINNET).should('eq', initialBalance)
}) })
}) })
...@@ -74,43 +79,29 @@ describe('Swap errors', () => { ...@@ -74,43 +79,29 @@ describe('Swap errors', () => {
cy.get(getTestSelector('open-settings-dialog-button')).click() cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click() cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('0.01') cy.get(getTestSelector('slippage-input')).clear().type('0.01')
cy.get('body').click('topRight') // close modal
// Click outside of modal to dismiss it.
cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist') cy.get(getTestSelector('slippage-input')).should('not.exist')
// Swap 2 times. // Submit 2 transactions
const AMOUNT_TO_SWAP = 200 for (let i = 0; i < 2; i++) {
cy.get('#swap-currency-input .token-amount-input') cy.get('#swap-currency-input .token-amount-input').type('200').should('have.value', '200')
.clear() cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
.type(AMOUNT_TO_SWAP.toString()) cy.get('#swap-button').click()
.should('have.value', AMOUNT_TO_SWAP.toString()) cy.contains('Confirm swap').click()
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '') cy.wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
cy.get('#swap-button').click() cy.contains('Transaction submitted')
cy.get('#confirm-swap-or-send').click() cy.get(getTestSelector('confirmation-close-icon')).click()
cy.contains('Confirm Swap').should('exist') }
cy.get(getTestSelector('confirmation-close-icon')).click()
cy.get('#swap-currency-input .token-amount-input')
.clear()
.type(AMOUNT_TO_SWAP.toString())
.should('have.value', AMOUNT_TO_SWAP.toString())
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.contains('Confirm Swap').should('exist')
cy.get(getTestSelector('confirmation-close-icon')).click()
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '2 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '2 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey. // Mine transactions
// cy.get(getTestSelector('transaction-popup')).contains('Swap failed') cy.hardhat().then((hardhat) => hardhat.mine())
cy.wait('@eth_getTransactionReceipt')
// Assert that the transactions were unsuccessful by checking on-chain balance. // Verify transaction did not occur
getBalance(UNI_MAINNET).should('equal', initialBalance) cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
cy.get(getTestSelector('popups')).contains('Swap failed')
getBalance(UNI_MAINNET).should('eq', initialBalance)
}) })
}) })
}) })
...@@ -41,29 +41,35 @@ describe('Swap', () => { ...@@ -41,29 +41,35 @@ describe('Swap', () => {
cy.visit('/swap', { ethereum: 'hardhat' }) cy.visit('/swap', { ethereum: 'hardhat' })
cy.hardhat({ automine: false }) cy.hardhat({ automine: false })
getBalance(USDC_MAINNET).then((initialBalance) => { getBalance(USDC_MAINNET).then((initialBalance) => {
// Select USDC
cy.get('#swap-currency-output .open-currency-select-button').click() cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get(getTestSelector('token-search-input')).clear().type(USDC_MAINNET.address) cy.get(getTestSelector('token-search-input')).type(USDC_MAINNET.address)
cy.contains('USDC').click() cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1')
// Enter amount to swap
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '') cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
// Submit transaction
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click() cy.contains('Review swap')
cy.contains('Confirm swap').click()
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
cy.contains('Transaction submitted')
cy.get(getTestSelector('confirmation-close-icon')).click() cy.get(getTestSelector('confirmation-close-icon')).click()
cy.contains('Transaction submitted').should('not.exist')
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey. // Mine transaction
// cy.get(getTestSelector('transaction-popup')).contains('Swapped') cy.hardhat().then((hardhat) => hardhat.mine())
cy.wait('@eth_getTransactionReceipt')
// Verify the balance is updated. // Verify transaction
cy.get('#swap-currency-output [data-testid="balance-text"]').should( cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
'have.text', cy.get(getTestSelector('popups')).contains('Swapped')
`Balance: ${initialBalance + 1}` const finalBalance = initialBalance + 1
) cy.get('#swap-currency-output').contains(`Balance: ${finalBalance}`)
getBalance(USDC_MAINNET).should('eq', initialBalance + 1) getBalance(USDC_MAINNET).should('eq', finalBalance)
}) })
}) })
}) })
......
...@@ -12,7 +12,7 @@ describe('Swap wrap', () => { ...@@ -12,7 +12,7 @@ describe('Swap wrap', () => {
}) })
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => { it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01').should('have.value', '0.01') cy.get('#swap-currency-input .token-amount-input').type('0.01').should('have.value', '0.01')
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01') cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
cy.get('#swap-currency-output .token-amount-input').clear().type('0.02').should('have.value', '0.02') cy.get('#swap-currency-output .token-amount-input').clear().type('0.02').should('have.value', '0.02')
...@@ -20,31 +20,28 @@ describe('Swap wrap', () => { ...@@ -20,31 +20,28 @@ describe('Swap wrap', () => {
}) })
it('should be able to wrap ETH', () => { it('should be able to wrap ETH', () => {
getBalance(WETH).then((initialWethBalance) => { getBalance(WETH).then((initialBalance) => {
cy.contains('Enter ETH amount') cy.contains('Enter ETH amount')
// Enter the amount to wrap. // Enter amount to wrap
cy.get('#swap-currency-output .token-amount-input').click().type('1').should('have.value', 1) cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', 1)
// This also ensures we don't click "Wrap" before the UI has caught up.
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1) cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
// Click the wrap button. // Submit transaction
cy.contains('Wrap').click() cy.contains('Wrap').click()
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey. // Mine transaction
// cy.get(getTestSelector('transaction-popup')).contains('Wrapped') cy.hardhat().then((hardhat) => hardhat.mine())
// cy.get(getTestSelector('transaction-popup')).contains('1.00 ETH for 1.00 WETH') cy.wait('@eth_getTransactionReceipt')
// The UI balance should have increased.
cy.get('#swap-currency-output').should('contain', `Balance: ${initialWethBalance + 1}`)
// The user's WETH account balance should have increased // Verify transaction
getBalance(WETH).should('equal', initialWethBalance + 1) cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
cy.get(getTestSelector('popups')).contains('Wrapped')
const finalBalance = initialBalance + 1
cy.get('#swap-currency-output').contains(`Balance: ${finalBalance}`)
getBalance(WETH).should('equal', finalBalance)
}) })
}) })
...@@ -54,33 +51,30 @@ describe('Swap wrap', () => { ...@@ -54,33 +51,30 @@ describe('Swap wrap', () => {
await hardhat.mine() await hardhat.mine()
}) })
getBalance(WETH).then((initialWethBalance) => { getBalance(WETH).then((initialBalance) => {
// Swap input/output to unwrap WETH. // Swap input/output to unwrap WETH
cy.get(getTestSelector('swap-currency-button')).click() cy.get(getTestSelector('swap-currency-button')).click()
cy.contains('Enter WETH amount') cy.contains('Enter WETH amount')
// Enter the amount to unwrap. // Enter the amount to unwrap
cy.get('#swap-currency-output .token-amount-input').click().type('1').should('have.value', 1) cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', 1)
// This also ensures we don't click "Wrap" before the UI has caught up.
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1) cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
// Click the unwrap button. // Submit transaction
cy.contains('Unwrap').click() cy.contains('Unwrap').click()
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending') cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey. // Mine transaction
// cy.get(getTestSelector('transaction-popup')).contains('Unwrapped') cy.hardhat().then((hardhat) => hardhat.mine())
// cy.get(getTestSelector('transaction-popup')).contains('1.00 WETH for 1.00 ETH') cy.wait('@eth_getTransactionReceipt')
// The UI balance should have increased.
cy.get('#swap-currency-input').should('contain', `Balance: ${initialWethBalance - 1}`)
// The user's WETH account balance should have increased // Verify transaction
getBalance(WETH).should('equal', initialWethBalance - 1) cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
cy.get(getTestSelector('popups')).contains('Unwrapped')
const finalBalance = initialBalance - 1
cy.get('#swap-currency-input').contains(`Balance: ${finalBalance}`)
getBalance(WETH).should('equal', finalBalance)
}) })
}) })
}) })
// @ts-ignore // @ts-ignore
import TokenListJSON from '@uniswap/default-token-list' import TokenListJSON from '@uniswap/default-token-list'
import { CyHttpMessages } from 'cypress/types/net-stubbing'
beforeEach(() => { beforeEach(() => {
// Many API calls enforce that requests come from our app, so we must mock Origin and Referer. // Many API calls enforce that requests come from our app, so we must mock Origin and Referer.
...@@ -16,6 +17,9 @@ beforeEach(() => { ...@@ -16,6 +17,9 @@ beforeEach(() => {
req.continue() req.continue()
}) })
// Log requests to hardhat.
cy.intercept(/:8545/, logJsonRpc)
// Mock analytics responses to avoid analytics in tests. // Mock analytics responses to avoid analytics in tests.
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => { cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
const requestBody = JSON.stringify(req.body) const requestBody = JSON.stringify(req.body)
...@@ -39,3 +43,21 @@ beforeEach(() => { ...@@ -39,3 +43,21 @@ beforeEach(() => {
// This resets the fork, as well as options like automine. // This resets the fork, as well as options like automine.
cy.hardhat().then((hardhat) => hardhat.reset()) cy.hardhat().then((hardhat) => hardhat.reset())
}) })
function logJsonRpc(req: CyHttpMessages.IncomingHttpRequest) {
req.alias = req.body.method
const log = Cypress.log({
autoEnd: false,
name: req.body.method,
message: req.body.params?.map((param: unknown) =>
typeof param === 'object' ? '{...}' : param?.toString().substring(0, 10)
),
})
req.on('after:response', (res) => {
if (res.statusCode === 200) {
log.end()
} else {
log.error(new Error(`${res.statusCode}: ${res.statusMessage}`))
}
})
}
...@@ -29,7 +29,6 @@ function TransactionPopupContent({ tx, chainId }: { tx: TransactionDetails; chai ...@@ -29,7 +29,6 @@ function TransactionPopupContent({ tx, chainId }: { tx: TransactionDetails; chai
return ( return (
<PortfolioRow <PortfolioRow
data-testid="transaction-popup"
left={ left={
success ? ( success ? (
<Column> <Column>
......
...@@ -66,14 +66,14 @@ export default function Popups() { ...@@ -66,14 +66,14 @@ export default function Popups() {
return ( return (
<> <>
<FixedPopupColumn gap="20px" extraPadding={urlWarningActive} xlPadding={isNotOnMainnet}> <FixedPopupColumn gap="20px" extraPadding={urlWarningActive} xlPadding={isNotOnMainnet} data-testid="popups">
<ClaimPopup /> <ClaimPopup />
{activePopups.map((item) => ( {activePopups.map((item) => (
<PopupItem key={item.key} content={item.content} popKey={item.key} removeAfterMs={item.removeAfterMs} /> <PopupItem key={item.key} content={item.content} popKey={item.key} removeAfterMs={item.removeAfterMs} />
))} ))}
</FixedPopupColumn> </FixedPopupColumn>
{activePopups?.length > 0 && ( {activePopups?.length > 0 && (
<MobilePopupWrapper> <MobilePopupWrapper data-testid="popups">
<MobilePopupInner> <MobilePopupInner>
{activePopups // reverse so new items up front {activePopups // reverse so new items up front
.slice(0) .slice(0)
......
...@@ -4,7 +4,7 @@ import { PermitSignature } from 'hooks/usePermitAllowance' ...@@ -4,7 +4,7 @@ import { PermitSignature } from 'hooks/usePermitAllowance'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTransactionAdder } from '../state/transactions/hooks' import { useTransactionAdder } from '../state/transactions/hooks'
import { TransactionType } from '../state/transactions/types' import { TransactionInfo, TransactionType } from '../state/transactions/types'
import { currencyId } from '../utils/currencyId' import { currencyId } from '../utils/currencyId'
import useTransactionDeadline from './useTransactionDeadline' import useTransactionDeadline from './useTransactionDeadline'
import { useUniversalRouterSwapCallback } from './useUniversalRouter' import { useUniversalRouterSwapCallback } from './useUniversalRouter'
...@@ -30,33 +30,30 @@ export function useSwapCallback( ...@@ -30,33 +30,30 @@ export function useSwapCallback(
const callback = useMemo(() => { const callback = useMemo(() => {
if (!trade || !swapCallback) return null if (!trade || !swapCallback) return null
const info: TransactionInfo = {
type: TransactionType.SWAP,
inputCurrencyId: currencyId(trade.inputAmount.currency),
outputCurrencyId: currencyId(trade.outputAmount.currency),
...(trade.tradeType === TradeType.EXACT_INPUT
? {
tradeType: TradeType.EXACT_INPUT,
inputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
expectedOutputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
minimumOutputCurrencyAmountRaw: trade.minimumAmountOut(allowedSlippage).quotient.toString(),
}
: {
tradeType: TradeType.EXACT_OUTPUT,
maximumInputCurrencyAmountRaw: trade.maximumAmountIn(allowedSlippage).quotient.toString(),
outputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
expectedInputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
}),
}
return () => return () =>
swapCallback().then((response) => { swapCallback().then((response) => {
addTransaction( addTransaction(response, info, deadline?.toNumber())
response,
trade.tradeType === TradeType.EXACT_INPUT
? {
type: TransactionType.SWAP,
tradeType: TradeType.EXACT_INPUT,
inputCurrencyId: currencyId(trade.inputAmount.currency),
inputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
expectedOutputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
outputCurrencyId: currencyId(trade.outputAmount.currency),
minimumOutputCurrencyAmountRaw: trade.minimumAmountOut(allowedSlippage).quotient.toString(),
}
: {
type: TransactionType.SWAP,
tradeType: TradeType.EXACT_OUTPUT,
inputCurrencyId: currencyId(trade.inputAmount.currency),
maximumInputCurrencyAmountRaw: trade.maximumAmountIn(allowedSlippage).quotient.toString(),
outputCurrencyId: currencyId(trade.outputAmount.currency),
outputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
expectedInputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
}
)
return response.hash return response.hash
}) })
}, [addTransaction, allowedSlippage, swapCallback, trade]) }, [addTransaction, allowedSlippage, deadline, swapCallback, trade])
return { return {
callback, callback,
......
import { retry, RetryableError } from './retry' import { CanceledError, retry, RetryableError } from './retry'
describe('retry', () => { describe('retry', () => {
function makeFn<T>(fails: number, result: T, retryable = true): () => Promise<T> { function makeFn<T>(fails: number, result: T, retryable = true): () => Promise<T> {
...@@ -32,7 +32,7 @@ describe('retry', () => { ...@@ -32,7 +32,7 @@ describe('retry', () => {
it('cancel causes promise to reject', async () => { it('cancel causes promise to reject', async () => {
const { promise, cancel } = retry(makeFn(2, 'abc'), { n: 3, minWait: 100, maxWait: 100 }) const { promise, cancel } = retry(makeFn(2, 'abc'), { n: 3, minWait: 100, maxWait: 100 })
cancel() cancel()
await expect(promise).rejects.toThrow('Cancelled') await expect(promise).rejects.toThrow(expect.any(CanceledError))
}) })
it('cancel no-op after complete', async () => { it('cancel no-op after complete', async () => {
......
...@@ -6,21 +6,15 @@ function waitRandom(min: number, max: number): Promise<void> { ...@@ -6,21 +6,15 @@ function waitRandom(min: number, max: number): Promise<void> {
return wait(min + Math.round(Math.random() * Math.max(0, max - min))) return wait(min + Math.round(Math.random() * Math.max(0, max - min)))
} }
/** /** Thrown if the function is canceled before resolving. */
* This error is thrown if the function is cancelled before completing export class CanceledError extends Error {
*/ name = 'CanceledError'
class CancelledError extends Error { message = 'Retryable was canceled'
public isCancelledError = true as const
constructor() {
super('Cancelled')
}
} }
/** /** May be thrown to force a retry. */
* Throw this error if the function should retry
*/
export class RetryableError extends Error { export class RetryableError extends Error {
public isRetryableError = true as const name = 'RetryableError'
} }
export interface RetryOptions { export interface RetryOptions {
...@@ -30,7 +24,7 @@ export interface RetryOptions { ...@@ -30,7 +24,7 @@ export interface RetryOptions {
} }
/** /**
* Retries the function that returns the promise until the promise successfully resolves up to n retries * Retries a function until its returned promise successfully resolves, up to n times.
* @param fn function to retry * @param fn function to retry
* @param n how many times to retry * @param n how many times to retry
* @param minWait min wait between retries in ms * @param minWait min wait between retries in ms
...@@ -59,7 +53,7 @@ export function retry<T>( ...@@ -59,7 +53,7 @@ export function retry<T>(
if (completed) { if (completed) {
break break
} }
if (n <= 0 || !error.isRetryableError) { if (n <= 0 || !(error instanceof RetryableError)) {
reject(error) reject(error)
completed = true completed = true
break break
...@@ -74,7 +68,7 @@ export function retry<T>( ...@@ -74,7 +68,7 @@ export function retry<T>(
cancel: () => { cancel: () => {
if (completed) return if (completed) return
completed = true completed = true
rejectCancelled(new CancelledError()) rejectCancelled(new CanceledError())
}, },
} }
} }
import { TransactionReceipt } from '@ethersproject/abstract-provider' import { TransactionReceipt } from '@ethersproject/abstract-provider'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { SupportedChainId } from 'constants/chains' import { SupportedChainId } from 'constants/chains'
import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp'
import useBlockNumber, { useFastForwardBlockNumber } from 'lib/hooks/useBlockNumber' import useBlockNumber, { useFastForwardBlockNumber } from 'lib/hooks/useBlockNumber'
import ms from 'ms.macro' import ms from 'ms.macro'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { useTransactionRemover } from 'state/transactions/hooks' import { useTransactionRemover } from 'state/transactions/hooks'
import { TransactionDetails } from 'state/transactions/types' import { TransactionDetails } from 'state/transactions/types'
import { retry, RetryableError, RetryOptions } from './retry' import { CanceledError, retry, RetryableError, RetryOptions } from './retry'
interface Transaction { interface Transaction {
addedTime: number addedTime: number
...@@ -53,6 +54,7 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd ...@@ -53,6 +54,7 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
const lastBlockNumber = useBlockNumber() const lastBlockNumber = useBlockNumber()
const fastForwardBlockNumber = useFastForwardBlockNumber() const fastForwardBlockNumber = useFastForwardBlockNumber()
const removeTransaction = useTransactionRemover() const removeTransaction = useTransactionRemover()
const blockTimestamp = useCurrentBlockTimestamp()
const getReceipt = useCallback( const getReceipt = useCallback(
(hash: string) => { (hash: string) => {
...@@ -63,16 +65,17 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd ...@@ -63,16 +65,17 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
provider.getTransactionReceipt(hash).then(async (receipt) => { provider.getTransactionReceipt(hash).then(async (receipt) => {
if (receipt === null) { if (receipt === null) {
if (account) { if (account) {
const transactionCount = await provider.getTransactionCount(account)
const tx = pendingTransactions[hash] const tx = pendingTransactions[hash]
// We check for the presence of a nonce because we haven't always saved them, // Remove transactions past their deadline or - if there is no deadline - older than 6 hours.
// so this code may run against old store state where nonce is undefined. if (tx.deadline) {
if (tx.nonce && tx.nonce < transactionCount) { // Deadlines are expressed as seconds since epoch, as they are used on-chain.
// We remove pending transactions from redux if they are no longer the latest nonce. if (blockTimestamp && tx.deadline < blockTimestamp.toNumber()) {
removeTransaction(hash)
}
} else if (tx.addedTime + ms`6h` < Date.now()) {
removeTransaction(hash) removeTransaction(hash)
} }
} }
console.debug(`Retrying tranasaction receipt for ${hash}`)
throw new RetryableError() throw new RetryableError()
} }
return receipt return receipt
...@@ -80,7 +83,7 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd ...@@ -80,7 +83,7 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
retryOptions retryOptions
) )
}, },
[account, chainId, pendingTransactions, provider, removeTransaction] [account, blockTimestamp, chainId, pendingTransactions, provider, removeTransaction]
) )
useEffect(() => { useEffect(() => {
...@@ -92,17 +95,12 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd ...@@ -92,17 +95,12 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
const { promise, cancel } = getReceipt(hash) const { promise, cancel } = getReceipt(hash)
promise promise
.then((receipt) => { .then((receipt) => {
if (receipt) { fastForwardBlockNumber(receipt.blockNumber)
fastForwardBlockNumber(receipt.blockNumber) onReceipt({ chainId, hash, receipt })
onReceipt({ chainId, hash, receipt })
} else {
onCheck({ chainId, hash, blockNumber: lastBlockNumber })
}
}) })
.catch((error) => { .catch((error) => {
if (!error.isCancelledError) { if (error instanceof CanceledError) return
console.warn(`Failed to get transaction receipt for ${hash}`, error) onCheck({ chainId, hash, blockNumber: lastBlockNumber })
}
}) })
return cancel return cancel
}) })
......
...@@ -21,26 +21,53 @@ describe('application reducer', () => { ...@@ -21,26 +21,53 @@ describe('application reducer', () => {
}) })
}) })
describe('addPopup', () => { describe('popupList', () => {
it('adds the popup to list with a generated id', () => { describe('addPopup', () => {
store.dispatch(addPopup({ content: { txn: { hash: 'abc' } } })) it('adds the popup to list with a generated id', () => {
const list = store.getState().popupList store.dispatch(addPopup({ content: { txn: { hash: 'abc' } } }))
expect(list).toHaveLength(1) const list = store.getState().popupList
expect(typeof list[0].key).toEqual('string') expect(list).toEqual([
expect(list[0].show).toEqual(true) {
expect(list[0].content).toEqual({ txn: { hash: 'abc' } }) key: expect.any(String),
expect(list[0].removeAfterMs).toEqual(10000) show: true,
content: { txn: { hash: 'abc' } },
removeAfterMs: 10000,
},
])
})
it('replaces any existing popups with the same key', () => {
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'abc' } } }))
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'def' } } }))
const list = store.getState().popupList
expect(list).toEqual([
{
key: 'abc',
show: true,
content: { txn: { hash: 'def' } },
removeAfterMs: 10000,
},
])
})
}) })
it('replaces any existing popups with the same key', () => { describe('removePopup', () => {
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'abc' } } })) beforeEach(() => {
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'def' } } })) store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'abc' } } }))
const list = store.getState().popupList })
expect(list).toHaveLength(1)
expect(list[0].key).toEqual('abc') it('hides the popup', () => {
expect(list[0].show).toEqual(true) store.dispatch(removePopup({ key: 'abc' }))
expect(list[0].content).toEqual({ txn: { hash: 'def' } }) const list = store.getState().popupList
expect(list[0].removeAfterMs).toEqual(10000) expect(list).toEqual([
{
key: 'abc',
show: false,
content: { txn: { hash: 'abc' } },
removeAfterMs: 10000,
},
])
})
}) })
}) })
...@@ -66,16 +93,4 @@ describe('application reducer', () => { ...@@ -66,16 +93,4 @@ describe('application reducer', () => {
expect(store.getState().chainId).toEqual(1) expect(store.getState().chainId).toEqual(1)
}) })
}) })
describe('removePopup', () => {
beforeEach(() => {
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'abc' } } }))
})
it('hides the popup', () => {
expect(store.getState().popupList[0].show).toBe(true)
store.dispatch(removePopup({ key: 'abc' }))
expect(store.getState().popupList).toHaveLength(1)
expect(store.getState().popupList[0].show).toBe(false)
})
})
}) })
...@@ -68,20 +68,23 @@ const applicationSlice = createSlice({ ...@@ -68,20 +68,23 @@ const applicationSlice = createSlice({
state.openModal = action.payload state.openModal = action.payload
}, },
addPopup(state, { payload: { content, key, removeAfterMs = DEFAULT_TXN_DISMISS_MS } }) { addPopup(state, { payload: { content, key, removeAfterMs = DEFAULT_TXN_DISMISS_MS } }) {
state.popupList = (key ? state.popupList.filter((popup) => popup.key !== key) : state.popupList).concat([ key = key || nanoid()
state.popupList = [
...state.popupList.filter((popup) => popup.key !== key),
{ {
key: key || nanoid(), key,
show: true, show: true,
content, content,
removeAfterMs, removeAfterMs,
}, },
]) ]
}, },
removePopup(state, { payload: { key } }) { removePopup(state, { payload: { key } }) {
state.popupList.forEach((p) => { state.popupList = state.popupList.map((popup) => {
if (p.key === key) { if (popup.key === key) {
p.show = false popup.show = false
} }
return popup
}) })
}, },
}, },
......
...@@ -9,12 +9,16 @@ import { addTransaction, removeTransaction } from './reducer' ...@@ -9,12 +9,16 @@ import { addTransaction, removeTransaction } from './reducer'
import { TransactionDetails, TransactionInfo, TransactionType } from './types' import { TransactionDetails, TransactionInfo, TransactionType } from './types'
// helper that can take a ethers library transaction response and add it to the list of transactions // helper that can take a ethers library transaction response and add it to the list of transactions
export function useTransactionAdder(): (response: TransactionResponse, info: TransactionInfo) => void { export function useTransactionAdder(): (
response: TransactionResponse,
info: TransactionInfo,
deadline?: number
) => void {
const { chainId, account } = useWeb3React() const { chainId, account } = useWeb3React()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return useCallback( return useCallback(
(response: TransactionResponse, info: TransactionInfo) => { (response: TransactionResponse, info: TransactionInfo, deadline?: number) => {
if (!account) return if (!account) return
if (!chainId) return if (!chainId) return
...@@ -22,7 +26,7 @@ export function useTransactionAdder(): (response: TransactionResponse, info: Tra ...@@ -22,7 +26,7 @@ export function useTransactionAdder(): (response: TransactionResponse, info: Tra
if (!hash) { if (!hash) {
throw Error('No transaction hash found.') throw Error('No transaction hash found.')
} }
dispatch(addTransaction({ hash, from: account, info, chainId, nonce })) dispatch(addTransaction({ hash, from: account, info, chainId, nonce, deadline }))
}, },
[account, chainId, dispatch] [account, chainId, dispatch]
) )
......
...@@ -4,8 +4,6 @@ import { SupportedChainId } from 'constants/chains' ...@@ -4,8 +4,6 @@ import { SupportedChainId } from 'constants/chains'
import { updateVersion } from '../global/actions' import { updateVersion } from '../global/actions'
import { TransactionDetails, TransactionInfo } from './types' import { TransactionDetails, TransactionInfo } from './types'
const now = () => new Date().getTime()
// TODO(WEB-2053): update this to be a map of account -> chainId -> txHash -> TransactionDetails // TODO(WEB-2053): update this to be a map of account -> chainId -> txHash -> TransactionDetails
// to simplify usage, once we're able to invalidate localstorage // to simplify usage, once we're able to invalidate localstorage
export interface TransactionState { export interface TransactionState {
...@@ -20,6 +18,7 @@ interface AddTransactionPayload { ...@@ -20,6 +18,7 @@ interface AddTransactionPayload {
hash: string hash: string
info: TransactionInfo info: TransactionInfo
nonce: number nonce: number
deadline?: number
} }
export const initialState: TransactionState = {} export const initialState: TransactionState = {}
...@@ -30,13 +29,13 @@ const transactionSlice = createSlice({ ...@@ -30,13 +29,13 @@ const transactionSlice = createSlice({
reducers: { reducers: {
addTransaction( addTransaction(
transactions, transactions,
{ payload: { chainId, from, hash, info, nonce } }: { payload: AddTransactionPayload } { payload: { chainId, from, hash, info, nonce, deadline } }: { payload: AddTransactionPayload }
) { ) {
if (transactions[chainId]?.[hash]) { if (transactions[chainId]?.[hash]) {
throw Error('Attempted to add existing transaction.') throw Error('Attempted to add existing transaction.')
} }
const txs = transactions[chainId] ?? {} const txs = transactions[chainId] ?? {}
txs[hash] = { hash, info, from, addedTime: now(), nonce } txs[hash] = { hash, info, from, addedTime: Date.now(), nonce, deadline }
transactions[chainId] = txs transactions[chainId] = txs
}, },
clearAllTransactions(transactions, { payload: { chainId } }) { clearAllTransactions(transactions, { payload: { chainId } }) {
...@@ -65,7 +64,7 @@ const transactionSlice = createSlice({ ...@@ -65,7 +64,7 @@ const transactionSlice = createSlice({
return return
} }
tx.receipt = receipt tx.receipt = receipt
tx.confirmedTime = now() tx.confirmedTime = Date.now()
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
......
...@@ -203,6 +203,7 @@ export interface TransactionDetails { ...@@ -203,6 +203,7 @@ export interface TransactionDetails {
lastCheckedBlockNumber?: number lastCheckedBlockNumber?: number
addedTime: number addedTime: number
confirmedTime?: number confirmedTime?: number
deadline?: number
from: string from: string
info: TransactionInfo info: TransactionInfo
nonce?: number nonce?: number
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment