refactor: track txs (#3185)
* feat: track approval txs * refactor: update transactions * chore: add ms to deps * test: rm stale test * fix: comment usage of trade for optimized trade
This commit is contained in:
parent
1f89a46a3f
commit
c7633d910b
@ -89,7 +89,6 @@
|
||||
"graphql-request": "^3.4.0",
|
||||
"inter-ui": "^3.13.1",
|
||||
"jest-styled-components": "^7.0.5",
|
||||
"ms.macro": "^2.0.0",
|
||||
"polyfill-object.fromentries": "^1.0.1",
|
||||
"prettier": "^2.2.1",
|
||||
"qs": "^6.9.4",
|
||||
@ -187,6 +186,7 @@
|
||||
"jotai": "^1.3.7",
|
||||
"jsbi": "^3.1.4",
|
||||
"make-plural": "^7.0.0",
|
||||
"ms.macro": "^2.0.0",
|
||||
"multicodec": "^3.0.1",
|
||||
"multihashes": "^4.0.2",
|
||||
"node-vibrant": "^3.2.1-alpha.1",
|
||||
|
@ -1,68 +1,8 @@
|
||||
import { tokens } from '@uniswap/default-token-list'
|
||||
import { CurrencyAmount } from '@uniswap/sdk-core'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
import { nativeOnChain } from 'constants/tokens'
|
||||
import { useUpdateAtom } from 'jotai/utils'
|
||||
import JSBI from 'jsbi'
|
||||
import { swapTransactionAtom } from 'lib/state/swap'
|
||||
import { useEffect } from 'react'
|
||||
import { useSelect } from 'react-cosmos/fixture'
|
||||
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { Modal } from '../Dialog'
|
||||
import { StatusDialog } from './Status'
|
||||
|
||||
const ETH = nativeOnChain(SupportedChainId.MAINNET)
|
||||
const UNI = (function () {
|
||||
const token = tokens.find(({ symbol }) => symbol === 'UNI')
|
||||
invariant(token)
|
||||
return new WrappedTokenInfo(token)
|
||||
})()
|
||||
|
||||
function Fixture() {
|
||||
const setTransaction = useUpdateAtom(swapTransactionAtom)
|
||||
|
||||
const [state] = useSelect('state', {
|
||||
options: ['PENDING', 'ERROR', 'SUCCESS'],
|
||||
})
|
||||
useEffect(() => {
|
||||
setTransaction({
|
||||
input: CurrencyAmount.fromRawAmount(ETH, JSBI.BigInt(1)),
|
||||
output: CurrencyAmount.fromRawAmount(UNI, JSBI.BigInt(42)),
|
||||
receipt: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}, [setTransaction])
|
||||
useEffect(() => {
|
||||
switch (state) {
|
||||
case 'PENDING':
|
||||
setTransaction({
|
||||
input: CurrencyAmount.fromRawAmount(ETH, JSBI.BigInt(1)),
|
||||
output: CurrencyAmount.fromRawAmount(UNI, JSBI.BigInt(42)),
|
||||
receipt: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
case 'ERROR':
|
||||
setTransaction((tx) => {
|
||||
invariant(tx)
|
||||
tx.status = new Error(
|
||||
'Swap failed: Unknown error: "Error: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent pulvinar, risus eu pretium condimentum, tellus dui fermentum turpis, id gravida metus justo ac lorem. Etiam vitae dapibus eros, nec elementum ipsum. Duis condimentum, felis vel tempor ultricies, eros diam tempus odio, at tempor urna odio id massa. Aliquam laoreet turpis justo, auctor accumsan est pellentesque at. Integer et dolor feugiat, sodales tortor non, cursus augue. Phasellus id suscipit justo, in ultricies tortor. Aenean libero nibh, egestas sit amet vehicula sit amet, tempor ac ligula. Cras at tempor lectus. Mauris sollicitudin est velit, nec consectetur lorem dapibus ut. Praesent magna ex, faucibus ac fermentum malesuada, molestie at ex. Phasellus bibendum lorem nec dolor dignissim eleifend. Nam dignissim varius velit, at volutpat justo pretium id."'
|
||||
)
|
||||
tx.elapsedMs = Date.now() - tx.timestamp
|
||||
})
|
||||
break
|
||||
case 'SUCCESS':
|
||||
setTransaction((tx) => {
|
||||
invariant(tx)
|
||||
tx.status = true
|
||||
tx.elapsedMs = Date.now() - tx.timestamp
|
||||
})
|
||||
break
|
||||
}
|
||||
}, [setTransaction, state])
|
||||
return <StatusDialog onClose={() => void 0} />
|
||||
return null
|
||||
// TODO(zzmp): Mock <StatusDialog tx={} onClose={() => void 0} />
|
||||
}
|
||||
|
||||
export default (
|
||||
|
@ -1,16 +1,14 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import ErrorDialog, { StatusHeader } from 'lib/components/Error/ErrorDialog'
|
||||
import useInterval from 'lib/hooks/useInterval'
|
||||
import { CheckCircle, Clock, Spinner } from 'lib/icons'
|
||||
import { SwapTransaction, swapTransactionAtom } from 'lib/state/swap'
|
||||
import { SwapTransactionInfo, Transaction } from 'lib/state/transactions'
|
||||
import styled, { ThemedText } from 'lib/theme'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import ActionButton from '../../ActionButton'
|
||||
import Column from '../../Column'
|
||||
import Row from '../../Row'
|
||||
import Summary from '../Summary'
|
||||
|
||||
const errorMessage = (
|
||||
<Trans>
|
||||
@ -24,17 +22,17 @@ const TransactionRow = styled(Row)`
|
||||
flex-direction: row-reverse;
|
||||
`
|
||||
|
||||
function ElapsedTime({ tx }: { tx: SwapTransaction | null }) {
|
||||
function ElapsedTime({ tx }: { tx: Transaction<SwapTransactionInfo> }) {
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
useInterval(
|
||||
() => {
|
||||
if (tx?.elapsedMs) {
|
||||
setElapsedMs(tx.elapsedMs)
|
||||
} else if (tx?.timestamp) {
|
||||
setElapsedMs(Date.now() - tx.timestamp)
|
||||
if (tx.info.response.timestamp) {
|
||||
setElapsedMs(tx.info.response.timestamp - tx.addedTime)
|
||||
} else {
|
||||
setElapsedMs(Date.now() - tx.addedTime)
|
||||
}
|
||||
},
|
||||
elapsedMs === tx?.elapsedMs ? null : 1000
|
||||
elapsedMs === tx.info.response.timestamp ? null : 1000
|
||||
)
|
||||
const toElapsedTime = useCallback((ms: number) => {
|
||||
let sec = Math.floor(ms / 1000)
|
||||
@ -63,22 +61,25 @@ const EtherscanA = styled.a`
|
||||
text-decoration: none;
|
||||
`
|
||||
|
||||
interface TransactionStatusProps extends StatusProps {
|
||||
tx: SwapTransaction | null
|
||||
interface TransactionStatusProps {
|
||||
tx: Transaction<SwapTransactionInfo>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function TransactionStatus({ tx, onClose }: TransactionStatusProps) {
|
||||
const Icon = useMemo(() => {
|
||||
return tx?.status ? CheckCircle : Spinner
|
||||
}, [tx?.status])
|
||||
return tx.receipt?.status ? CheckCircle : Spinner
|
||||
}, [tx.receipt?.status])
|
||||
const heading = useMemo(() => {
|
||||
return tx?.status ? <Trans>Transaction submitted</Trans> : <Trans>Transaction pending</Trans>
|
||||
}, [tx?.status])
|
||||
return tx.receipt?.status ? <Trans>Transaction submitted</Trans> : <Trans>Transaction pending</Trans>
|
||||
}, [tx.receipt?.status])
|
||||
return (
|
||||
<Column flex padded gap={0.75} align="stretch" style={{ height: '100%' }}>
|
||||
<StatusHeader icon={Icon} iconColor={tx?.status && 'success'}>
|
||||
<StatusHeader icon={Icon} iconColor={tx.receipt?.status ? 'success' : undefined}>
|
||||
<ThemedText.Subhead1>{heading}</ThemedText.Subhead1>
|
||||
{tx ? <Summary input={tx.input} output={tx.output} /> : <div style={{ height: '1.25em' }} />}
|
||||
{/* TODO(zzmp): Display actual transaction.
|
||||
<Summary input={tx.info.inputCurrency} output={tx.info.outputCurrency} />
|
||||
*/}
|
||||
</StatusHeader>
|
||||
<TransactionRow flex>
|
||||
<ThemedText.ButtonSmall>
|
||||
@ -95,15 +96,14 @@ function TransactionStatus({ tx, onClose }: TransactionStatusProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function TransactionStatusDialog({ onClose }: StatusProps) {
|
||||
const tx = useAtomValue(swapTransactionAtom)
|
||||
|
||||
return tx?.status instanceof Error ? (
|
||||
<ErrorDialog header={errorMessage} error={tx.status} action={<Trans>Dismiss</Trans>} onAction={onClose} />
|
||||
export default function TransactionStatusDialog({ tx, onClose }: TransactionStatusProps) {
|
||||
return tx.receipt?.status === 0 ? (
|
||||
<ErrorDialog
|
||||
header={errorMessage}
|
||||
error={new Error('TODO(zzmp)')}
|
||||
action={<Trans>Dismiss</Trans>}
|
||||
onAction={onClose}
|
||||
/>
|
||||
) : (
|
||||
<TransactionStatus tx={tx} onClose={onClose} />
|
||||
)
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { useSwapInfo } from 'lib/hooks/swap'
|
||||
import useSwapApproval, { ApprovalState, useSwapApprovalOptimizedTrade } from 'lib/hooks/swap/useSwapApproval'
|
||||
import { useAddTransaction } from 'lib/hooks/transactions'
|
||||
import { useIsPendingApproval } from 'lib/hooks/transactions'
|
||||
import { Field } from 'lib/state/swap'
|
||||
import { TransactionType } from 'lib/state/transactions'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import ActionButton from '../ActionButton'
|
||||
import Dialog from '../Dialog'
|
||||
import { StatusDialog } from './Status'
|
||||
import { SummaryDialog } from './Summary'
|
||||
|
||||
interface SwapButtonProps {
|
||||
@ -26,17 +28,26 @@ export default function SwapButton({ disabled }: SwapButtonProps) {
|
||||
setActiveTrade((activeTrade) => activeTrade && trade.trade)
|
||||
}, [trade])
|
||||
|
||||
// TODO(zzmp): Track pending approval
|
||||
const useIsPendingApproval = () => false
|
||||
|
||||
// TODO(zzmp): Return an optimized trade directly from useSwapInfo.
|
||||
const optimizedTrade = useSwapApprovalOptimizedTrade(trade.trade, allowedSlippage, useIsPendingApproval)
|
||||
const optimizedTrade =
|
||||
// Use trade.trade if there is no swap optimized trade. This occurs if approvals are still pending.
|
||||
useSwapApprovalOptimizedTrade(trade.trade, allowedSlippage, useIsPendingApproval) || trade.trade
|
||||
const [approval, getApproval] = useSwapApproval(optimizedTrade, allowedSlippage, useIsPendingApproval)
|
||||
|
||||
const addTransaction = useAddTransaction()
|
||||
const addApprovalTransaction = useCallback(() => {
|
||||
getApproval().then((transaction) => {
|
||||
if (transaction) {
|
||||
addTransaction({ type: TransactionType.APPROVAL, ...transaction })
|
||||
}
|
||||
})
|
||||
}, [addTransaction, getApproval])
|
||||
|
||||
const actionProps = useMemo(() => {
|
||||
if (disabled) return { disabled: true }
|
||||
|
||||
if (inputCurrencyAmount && inputCurrencyBalance?.greaterThan(inputCurrencyAmount)) {
|
||||
// TODO(zzmp): Update UI for pending approvals.
|
||||
if (approval === ApprovalState.PENDING) {
|
||||
return { disabled: true }
|
||||
} else if (approval === ApprovalState.NOT_APPROVED) {
|
||||
@ -62,7 +73,7 @@ export default function SwapButton({ disabled }: SwapButtonProps) {
|
||||
<ActionButton
|
||||
color="interactive"
|
||||
onClick={() => setActiveTrade(trade.trade)}
|
||||
onUpdate={getApproval}
|
||||
onUpdate={addApprovalTransaction}
|
||||
{...actionProps}
|
||||
>
|
||||
<Trans>Review swap</Trans>
|
||||
@ -72,11 +83,11 @@ export default function SwapButton({ disabled }: SwapButtonProps) {
|
||||
<SummaryDialog trade={activeTrade} allowedSlippage={allowedSlippage} onConfirm={onConfirm} />
|
||||
</Dialog>
|
||||
)}
|
||||
{false && (
|
||||
{/* TODO(zzmp): Pass the completed tx, possibly at a different level of the DOM.
|
||||
<Dialog color="dialog">
|
||||
<StatusDialog onClose={() => void 0} />
|
||||
</Dialog>
|
||||
)}
|
||||
*/}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DEFAULT_LOCALE, SupportedLocale } from 'constants/locales'
|
||||
import { Provider as AtomProvider } from 'jotai'
|
||||
import { TransactionsUpdater } from 'lib/hooks/transactions'
|
||||
import { BlockUpdater } from 'lib/hooks/useBlockNumber'
|
||||
import { UNMOUNTING } from 'lib/hooks/useUnmount'
|
||||
import { Provider as I18nProvider } from 'lib/i18n'
|
||||
@ -73,6 +74,7 @@ function Updaters() {
|
||||
<>
|
||||
<BlockUpdater />
|
||||
<MulticallUpdater />
|
||||
<TransactionsUpdater />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
92
src/lib/hooks/transactions/index.tsx
Normal file
92
src/lib/hooks/transactions/index.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { Token } from '@uniswap/sdk-core'
|
||||
import { useAtomValue, useUpdateAtom } from 'jotai/utils'
|
||||
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
|
||||
import { Transaction, TransactionInfo, transactionsAtom, TransactionType } from 'lib/state/transactions'
|
||||
import ms from 'ms.macro'
|
||||
import { useCallback } from 'react'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import useBlockNumber from '../useBlockNumber'
|
||||
import Updater from './updater'
|
||||
|
||||
function isTransactionRecent(transaction: Transaction) {
|
||||
return Date.now() - transaction.addedTime < ms`1d`
|
||||
}
|
||||
|
||||
export function usePendingTransactions() {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const txs = useAtomValue(transactionsAtom)
|
||||
return (chainId ? txs[chainId] : null) ?? {}
|
||||
}
|
||||
|
||||
export function useAddTransaction() {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const blockNumber = useBlockNumber()
|
||||
const updateTxs = useUpdateAtom(transactionsAtom)
|
||||
|
||||
return useCallback(
|
||||
(info: TransactionInfo) => {
|
||||
invariant(chainId)
|
||||
const txChainId = chainId
|
||||
const { hash } = info.response
|
||||
|
||||
updateTxs((chainTxs) => {
|
||||
const txs = chainTxs[txChainId] || {}
|
||||
txs[hash] = { addedTime: new Date().getTime(), lastCheckedBlockNumber: blockNumber, info }
|
||||
chainTxs[chainId] = txs
|
||||
})
|
||||
},
|
||||
[blockNumber, chainId, updateTxs]
|
||||
)
|
||||
}
|
||||
|
||||
export function useIsPendingApproval(token?: Token, spender?: string) {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const txs = useAtomValue(transactionsAtom)
|
||||
if (!chainId || !token || !spender) return false
|
||||
|
||||
const chainTxs = txs[chainId]
|
||||
if (!chainTxs) return false
|
||||
|
||||
return Object.values(chainTxs).some(
|
||||
(tx) =>
|
||||
tx &&
|
||||
tx.receipt === undefined &&
|
||||
tx.info.type === TransactionType.APPROVAL &&
|
||||
tx.info.tokenAddress === token.address &&
|
||||
tx.info.spenderAddress === spender &&
|
||||
isTransactionRecent(tx)
|
||||
)
|
||||
}
|
||||
|
||||
export function TransactionsUpdater() {
|
||||
const pendingTransactions = usePendingTransactions()
|
||||
|
||||
const updateTxs = useUpdateAtom(transactionsAtom)
|
||||
const onCheck = useCallback(
|
||||
({ chainId, hash, blockNumber }) => {
|
||||
updateTxs((txs) => {
|
||||
const tx = txs[chainId]?.[hash]
|
||||
if (tx) {
|
||||
tx.lastCheckedBlockNumber = tx.lastCheckedBlockNumber
|
||||
? Math.max(tx.lastCheckedBlockNumber, blockNumber)
|
||||
: blockNumber
|
||||
}
|
||||
})
|
||||
},
|
||||
[updateTxs]
|
||||
)
|
||||
const onReceipt = useCallback(
|
||||
({ chainId, hash, receipt }) => {
|
||||
updateTxs((txs) => {
|
||||
const tx = txs[chainId]?.[hash]
|
||||
if (tx) {
|
||||
tx.receipt = receipt
|
||||
}
|
||||
})
|
||||
},
|
||||
[updateTxs]
|
||||
)
|
||||
|
||||
return <Updater pendingTransactions={pendingTransactions} onCheck={onCheck} onReceipt={onReceipt} />
|
||||
}
|
101
src/lib/hooks/transactions/updater.tsx
Normal file
101
src/lib/hooks/transactions/updater.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { TransactionReceipt } from '@ethersproject/abstract-provider'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
import useActiveWeb3React from 'hooks/useActiveWeb3React'
|
||||
import useBlockNumber, { useFastForwardBlockNumber } from 'lib/hooks/useBlockNumber'
|
||||
import ms from 'ms.macro'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { retry, RetryableError, RetryOptions } from 'utils/retry'
|
||||
|
||||
interface Transaction {
|
||||
addedTime: number
|
||||
receipt?: unknown
|
||||
lastCheckedBlockNumber?: number
|
||||
}
|
||||
|
||||
export function shouldCheck(lastBlockNumber: number, tx: Transaction): 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) / ms`1m`
|
||||
if (minutesPending > 60) {
|
||||
// every 10 blocks if pending longer than an hour
|
||||
return blocksSinceCheck > 9
|
||||
} else if (minutesPending > 5) {
|
||||
// every 3 blocks if pending longer than 5 minutes
|
||||
return blocksSinceCheck > 2
|
||||
} else {
|
||||
// otherwise every block
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const RETRY_OPTIONS_BY_CHAIN_ID: { [chainId: number]: RetryOptions } = {
|
||||
[SupportedChainId.ARBITRUM_ONE]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
[SupportedChainId.ARBITRUM_RINKEBY]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
[SupportedChainId.OPTIMISTIC_KOVAN]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
[SupportedChainId.OPTIMISM]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
}
|
||||
const DEFAULT_RETRY_OPTIONS: RetryOptions = { n: 1, minWait: 0, maxWait: 0 }
|
||||
|
||||
interface UpdaterProps {
|
||||
pendingTransactions: { [hash: string]: Transaction }
|
||||
onCheck: (tx: { chainId: number; hash: string; blockNumber: number }) => void
|
||||
onReceipt: (tx: { chainId: number; hash: string; receipt: TransactionReceipt }) => void
|
||||
}
|
||||
|
||||
export default function Updater({ pendingTransactions, onCheck, onReceipt }: UpdaterProps): null {
|
||||
const { chainId, library } = useActiveWeb3React()
|
||||
|
||||
const lastBlockNumber = useBlockNumber()
|
||||
const fastForwardBlockNumber = useFastForwardBlockNumber()
|
||||
|
||||
const getReceipt = useCallback(
|
||||
(hash: string) => {
|
||||
if (!library || !chainId) throw new Error('No library or chainId')
|
||||
const retryOptions = RETRY_OPTIONS_BY_CHAIN_ID[chainId] ?? DEFAULT_RETRY_OPTIONS
|
||||
return retry(
|
||||
() =>
|
||||
library.getTransactionReceipt(hash).then((receipt) => {
|
||||
if (receipt === null) {
|
||||
console.debug(`Retrying tranasaction receipt for ${hash}`)
|
||||
throw new RetryableError()
|
||||
}
|
||||
return receipt
|
||||
}),
|
||||
retryOptions
|
||||
)
|
||||
},
|
||||
[chainId, library]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chainId || !library || !lastBlockNumber) return
|
||||
|
||||
const cancels = Object.keys(pendingTransactions)
|
||||
.filter((hash) => shouldCheck(lastBlockNumber, pendingTransactions[hash]))
|
||||
.map((hash) => {
|
||||
const { promise, cancel } = getReceipt(hash)
|
||||
promise
|
||||
.then((receipt) => {
|
||||
if (receipt) {
|
||||
onReceipt({ chainId, hash, receipt })
|
||||
} else {
|
||||
onCheck({ chainId, hash, blockNumber: lastBlockNumber })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!error.isCancelledError) {
|
||||
console.warn(`Failed to get transaction receipt for ${hash}`, error)
|
||||
}
|
||||
})
|
||||
return cancel
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancels.forEach((cancel) => cancel())
|
||||
}
|
||||
}, [chainId, library, lastBlockNumber, getReceipt, fastForwardBlockNumber, onReceipt, onCheck, pendingTransactions])
|
||||
|
||||
return null
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
|
||||
import { Currency } from '@uniswap/sdk-core'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
import { nativeOnChain } from 'constants/tokens'
|
||||
import { atomWithImmer } from 'jotai/immer'
|
||||
@ -26,14 +26,3 @@ export const swapAtom = atomWithImmer<Swap>({
|
||||
export const independentFieldAtom = pickAtom(swapAtom, 'independentField')
|
||||
export const integratorFeeAtom = pickAtom(swapAtom, 'integratorFee')
|
||||
export const amountAtom = pickAtom(swapAtom, 'amount')
|
||||
|
||||
export interface SwapTransaction {
|
||||
input: CurrencyAmount<Currency>
|
||||
output: CurrencyAmount<Currency>
|
||||
receipt: string
|
||||
timestamp: number
|
||||
elapsedMs?: number
|
||||
status?: true | Error
|
||||
}
|
||||
|
||||
export const swapTransactionAtom = atomWithImmer<SwapTransaction | null>(null)
|
||||
|
53
src/lib/state/transactions.ts
Normal file
53
src/lib/state/transactions.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider'
|
||||
import { TradeType } from '@uniswap/sdk-core'
|
||||
import { atomWithImmer } from 'jotai/immer'
|
||||
|
||||
export enum TransactionType {
|
||||
APPROVAL,
|
||||
SWAP,
|
||||
}
|
||||
|
||||
interface BaseTransactionInfo {
|
||||
type: TransactionType
|
||||
response: TransactionResponse
|
||||
}
|
||||
|
||||
export interface ApprovalTransactionInfo extends BaseTransactionInfo {
|
||||
type: TransactionType.APPROVAL
|
||||
tokenAddress: string
|
||||
spenderAddress: string
|
||||
}
|
||||
|
||||
export interface SwapTransactionInfo extends BaseTransactionInfo {
|
||||
type: TransactionType.SWAP
|
||||
tradeType: TradeType
|
||||
inputCurrencyAddress: string
|
||||
outputCurrencyAddress: string
|
||||
}
|
||||
|
||||
export interface InputSwapTransactionInfo extends SwapTransactionInfo {
|
||||
tradeType: TradeType.EXACT_INPUT
|
||||
inputCurrencyAmount: string
|
||||
expectedOutputCurrencyAmount: string
|
||||
minimumOutputCurrencyAmount: string
|
||||
}
|
||||
|
||||
export interface OutputSwapTransactionInfo extends SwapTransactionInfo {
|
||||
tradeType: TradeType.EXACT_OUTPUT
|
||||
outputCurrencyAmount: string
|
||||
expectedInputCurrencyAmount: string
|
||||
maximumInputCurrencyAmount: string
|
||||
}
|
||||
|
||||
export type TransactionInfo = ApprovalTransactionInfo | SwapTransactionInfo
|
||||
|
||||
export interface Transaction<T extends TransactionInfo = TransactionInfo> {
|
||||
addedTime: number
|
||||
lastCheckedBlockNumber?: number
|
||||
receipt?: TransactionReceipt
|
||||
info: T
|
||||
}
|
||||
|
||||
export const transactionsAtom = atomWithImmer<{
|
||||
[chainId: string]: { [hash: string]: Transaction }
|
||||
}>({})
|
@ -1,139 +1,55 @@
|
||||
import { DEFAULT_TXN_DISMISS_MS, L2_TXN_DISMISS_MS } from 'constants/misc'
|
||||
import useActiveWeb3React from 'hooks/useActiveWeb3React'
|
||||
import useBlockNumber, { useFastForwardBlockNumber } from 'lib/hooks/useBlockNumber'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import LibUpdater from 'lib/hooks/transactions/updater'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useAppDispatch, useAppSelector } from 'state/hooks'
|
||||
|
||||
import { L2_CHAIN_IDS, SupportedChainId } from '../../constants/chains'
|
||||
import { retry, RetryableError, RetryOptions } from '../../utils/retry'
|
||||
import { L2_CHAIN_IDS } from '../../constants/chains'
|
||||
import { useAddPopup } from '../application/hooks'
|
||||
import { checkedTransaction, finalizeTransaction } from './actions'
|
||||
|
||||
interface TxInterface {
|
||||
addedTime: number
|
||||
receipt?: Record<string, any>
|
||||
lastCheckedBlockNumber?: number
|
||||
}
|
||||
|
||||
export function shouldCheck(lastBlockNumber: number, tx: TxInterface): 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
|
||||
}
|
||||
}
|
||||
|
||||
const RETRY_OPTIONS_BY_CHAIN_ID: { [chainId: number]: RetryOptions } = {
|
||||
[SupportedChainId.ARBITRUM_ONE]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
[SupportedChainId.ARBITRUM_RINKEBY]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
[SupportedChainId.OPTIMISTIC_KOVAN]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
[SupportedChainId.OPTIMISM]: { n: 10, minWait: 250, maxWait: 1000 },
|
||||
}
|
||||
const DEFAULT_RETRY_OPTIONS: RetryOptions = { n: 1, minWait: 0, maxWait: 0 }
|
||||
|
||||
export default function Updater(): null {
|
||||
const { chainId, library } = useActiveWeb3React()
|
||||
|
||||
const lastBlockNumber = useBlockNumber()
|
||||
const fastForwardBlockNumber = useFastForwardBlockNumber()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const state = useAppSelector((state) => state.transactions)
|
||||
|
||||
const transactions = useMemo(() => (chainId ? state[chainId] ?? {} : {}), [chainId, state])
|
||||
|
||||
// show popup on confirm
|
||||
export default function Updater() {
|
||||
const { chainId } = useActiveWeb3React()
|
||||
const addPopup = useAddPopup()
|
||||
|
||||
// speed up popup dismisall time if on L2
|
||||
const isL2 = Boolean(chainId && L2_CHAIN_IDS.includes(chainId))
|
||||
|
||||
const getReceipt = useCallback(
|
||||
(hash: string) => {
|
||||
if (!library || !chainId) throw new Error('No library or chainId')
|
||||
const retryOptions = RETRY_OPTIONS_BY_CHAIN_ID[chainId] ?? DEFAULT_RETRY_OPTIONS
|
||||
return retry(
|
||||
() =>
|
||||
library.getTransactionReceipt(hash).then((receipt) => {
|
||||
if (receipt === null) {
|
||||
console.debug('Retrying for hash', hash)
|
||||
throw new RetryableError()
|
||||
}
|
||||
return receipt
|
||||
}),
|
||||
retryOptions
|
||||
const dispatch = useAppDispatch()
|
||||
const onCheck = useCallback(
|
||||
({ chainId, hash, blockNumber }) => dispatch(checkedTransaction({ chainId, hash, blockNumber })),
|
||||
[dispatch]
|
||||
)
|
||||
const onReceipt = useCallback(
|
||||
({ chainId, hash, receipt }) => {
|
||||
dispatch(
|
||||
finalizeTransaction({
|
||||
chainId,
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
addPopup(
|
||||
{
|
||||
txn: { hash },
|
||||
},
|
||||
hash,
|
||||
isL2 ? L2_TXN_DISMISS_MS : DEFAULT_TXN_DISMISS_MS
|
||||
)
|
||||
},
|
||||
[chainId, library]
|
||||
[addPopup, dispatch, isL2]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chainId || !library || !lastBlockNumber) return
|
||||
const state = useAppSelector((state) => state.transactions)
|
||||
const pendingTransactions = useMemo(() => (chainId ? state[chainId] ?? {} : {}), [chainId, state])
|
||||
|
||||
const cancels = Object.keys(transactions)
|
||||
.filter((hash) => shouldCheck(lastBlockNumber, transactions[hash]))
|
||||
.map((hash) => {
|
||||
const { promise, cancel } = getReceipt(hash)
|
||||
promise
|
||||
.then((receipt) => {
|
||||
if (receipt) {
|
||||
dispatch(
|
||||
finalizeTransaction({
|
||||
chainId,
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
addPopup(
|
||||
{
|
||||
txn: {
|
||||
hash,
|
||||
},
|
||||
},
|
||||
hash,
|
||||
isL2 ? L2_TXN_DISMISS_MS : DEFAULT_TXN_DISMISS_MS
|
||||
)
|
||||
|
||||
// the receipt was fetched before the block, fast forward to that block to trigger balance updates
|
||||
if (receipt.blockNumber > lastBlockNumber) {
|
||||
fastForwardBlockNumber(receipt.blockNumber)
|
||||
}
|
||||
} else {
|
||||
dispatch(checkedTransaction({ chainId, hash, blockNumber: lastBlockNumber }))
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!error.isCancelledError) {
|
||||
console.error(`Failed to check transaction hash: ${hash}`, error)
|
||||
}
|
||||
})
|
||||
return cancel
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancels.forEach((cancel) => cancel())
|
||||
}
|
||||
}, [chainId, library, transactions, lastBlockNumber, dispatch, addPopup, getReceipt, isL2, fastForwardBlockNumber])
|
||||
|
||||
return null
|
||||
return <LibUpdater pendingTransactions={pendingTransactions} onCheck={onCheck} onReceipt={onReceipt} />
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user