Commit 7115729e authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

feat: trace swap.send (#6147)

* feat: trace swap.send

* docs: comments

* test: sentry transaction trace

* fix: tag as non-widget

* fix: nits

* refactor: brackets

* fix: type TraceTags

* docs: traceTransaction

* chore: transaction->span

* docs: even more docs

* fix: is_widget
parent 6618135e
......@@ -10,13 +10,31 @@ import { FeeOptions, toHex } from '@uniswap/v3-sdk'
import { useWeb3React } from '@web3-react/core'
import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics'
import { useCallback } from 'react'
import { trace } from 'tracing'
import { calculateGasMargin } from 'utils/calculateGasMargin'
import isZero from 'utils/isZero'
import { swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage'
import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage'
import { PermitSignature } from './usePermitAllowance'
class InvalidSwapError extends Error {}
/** Thrown when gas estimation fails. This class of error usually requires an emulator to determine the root cause. */
class GasEstimationError extends Error {
constructor() {
super(t`Your swap is expected to fail.`)
}
}
/**
* Thrown when the user modifies the transaction in-wallet before submitting it.
* In-wallet calldata modification nullifies any safeguards (eg slippage) from the interface, so we recommend reverting them immediately.
*/
class ModifiedSwapError extends Error {
constructor() {
super(
t`Your swap was modified through your wallet. If this was a mistake, please cancel immediately or risk losing your funds.`
)
}
}
interface SwapOptions {
slippageTolerance: Percent
......@@ -33,55 +51,70 @@ export function useUniversalRouterSwapCallback(
const { account, chainId, provider } = useWeb3React()
return useCallback(async (): Promise<TransactionResponse> => {
try {
if (!account) throw new Error('missing account')
if (!chainId) throw new Error('missing chainId')
if (!provider) throw new Error('missing provider')
if (!trade) throw new Error('missing trade')
return trace(
'swap.send',
async ({ setTraceData, setTraceStatus, setTraceError }) => {
try {
if (!account) throw new Error('missing account')
if (!chainId) throw new Error('missing chainId')
if (!provider) throw new Error('missing provider')
if (!trade) throw new Error('missing trade')
const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, {
slippageTolerance: options.slippageTolerance,
deadlineOrPreviousBlockhash: options.deadline?.toString(),
inputTokenPermit: options.permit,
fee: options.feeOptions,
})
const tx = {
from: account,
to: UNIVERSAL_ROUTER_ADDRESS(chainId),
data,
// TODO: universal-router-sdk returns a non-hexlified value.
...(value && !isZero(value) ? { value: toHex(value) } : {}),
}
setTraceData('slippageTolerance', options.slippageTolerance.toFixed(2))
const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, {
slippageTolerance: options.slippageTolerance,
deadlineOrPreviousBlockhash: options.deadline?.toString(),
inputTokenPermit: options.permit,
fee: options.feeOptions,
})
const tx = {
from: account,
to: UNIVERSAL_ROUTER_ADDRESS(chainId),
data,
// TODO(https://github.com/Uniswap/universal-router-sdk/issues/113): universal-router-sdk returns a non-hexlified value.
...(value && !isZero(value) ? { value: toHex(value) } : {}),
}
let gasEstimate: BigNumber
try {
gasEstimate = await provider.estimateGas(tx)
} catch (gasError) {
console.warn(gasError)
throw new Error('Your swap is expected to fail')
}
const gasLimit = calculateGasMargin(gasEstimate)
const response = await provider
.getSigner()
.sendTransaction({ ...tx, gasLimit })
.then((response) => {
sendAnalyticsEvent(
SwapEventName.SWAP_SIGNED,
formatSwapSignedAnalyticsEventProperties({ trade, fiatValues, txHash: response.hash })
)
if (tx.data !== response.data) {
sendAnalyticsEvent(SwapEventName.SWAP_MODIFIED_IN_WALLET, { txHash: response.hash })
throw new InvalidSwapError(
t`Your swap was modified through your wallet. If this was a mistake, please cancel immediately or risk losing your funds.`
)
let gasEstimate: BigNumber
try {
gasEstimate = await provider.estimateGas(tx)
} catch (gasError) {
setTraceStatus('failed_precondition')
setTraceError(gasError)
console.warn(gasError)
throw new GasEstimationError()
}
const gasLimit = calculateGasMargin(gasEstimate)
setTraceData('gasLimit', gasLimit.toNumber())
const response = await provider
.getSigner()
.sendTransaction({ ...tx, gasLimit })
.then((response) => {
sendAnalyticsEvent(
SwapEventName.SWAP_SIGNED,
formatSwapSignedAnalyticsEventProperties({ trade, fiatValues, txHash: response.hash })
)
if (tx.data !== response.data) {
sendAnalyticsEvent(SwapEventName.SWAP_MODIFIED_IN_WALLET, { txHash: response.hash })
throw new ModifiedSwapError()
}
return response
})
return response
})
return response
} catch (swapError: unknown) {
if (swapError instanceof InvalidSwapError) throw swapError
throw new Error(swapErrorToUserReadableMessage(swapError))
}
} catch (swapError: unknown) {
if (swapError instanceof ModifiedSwapError) throw swapError
// Cancellations are not failures, and must be accounted for as 'cancelled'.
if (didUserReject(swapError)) setTraceStatus('cancelled')
// GasEstimationErrors are already traced when they are thrown.
if (!(swapError instanceof GasEstimationError)) setTraceError(swapError)
throw new Error(swapErrorToUserReadableMessage(swapError))
}
},
{ tags: { is_widget: false } }
)
}, [
account,
chainId,
......
......@@ -7,6 +7,8 @@ import { SharedEventName } from '@uniswap/analytics-events'
import { isSentryEnabled } from 'utils/env'
import { getEnvName, isProductionEnv } from 'utils/env'
export { trace } from './trace'
// Dump some metadata into the window to allow client verification.
window.GIT_COMMIT_HASH = process.env.REACT_APP_GIT_COMMIT_HASH
......
import '@sentry/tracing' // required to populate Sentry.startTransaction, which is not included in the core module
import * as Sentry from '@sentry/react'
import { Transaction } from '@sentry/tracing'
import assert from 'assert'
import { trace } from './trace'
jest.mock('@sentry/react', () => {
return {
startTransaction: jest.fn(),
}
})
const startTransaction = Sentry.startTransaction as jest.Mock
function getTransaction(index = 0): Transaction {
const transactions = startTransaction.mock.results.map(({ value }) => value)
expect(transactions).toHaveLength(index + 1)
const transaction = transactions[index]
expect(transaction).toBeDefined()
return transaction
}
describe('trace', () => {
beforeEach(() => {
const Sentry = jest.requireActual('@sentry/react')
startTransaction.mockReset().mockImplementation((context) => {
const transaction: Transaction = Sentry.startTransaction(context)
transaction.initSpanRecorder()
return transaction
})
})
it('propagates callback', async () => {
await expect(trace('test', () => Promise.resolve('resolved'))).resolves.toBe('resolved')
await expect(trace('test', () => Promise.reject('rejected'))).rejects.toBe('rejected')
})
it('records transaction', async () => {
const metadata = { data: { a: 'a', b: 2 }, tags: { is_widget: true } }
await trace('test', () => Promise.resolve(), metadata)
const transaction = getTransaction()
expect(transaction.name).toBe('test')
expect(transaction.data).toEqual({ a: 'a', b: 2 })
expect(transaction.tags).toEqual({ is_widget: true })
})
describe('defaults status', () => {
it('"ok" if resolved', async () => {
await trace('test', () => Promise.resolve())
const transaction = getTransaction()
expect(transaction.status).toBe('ok')
})
it('"internal_error" if rejected, with data.error set to rejection', async () => {
const error = new Error('Test error')
await expect(trace('test', () => Promise.reject(error))).rejects.toBe(error)
const transaction = getTransaction()
expect(transaction.status).toBe('internal_error')
expect(transaction.data).toEqual({ error })
})
})
describe('setTraceData', () => {
it('sets transaction data', async () => {
await trace('test', ({ setTraceData }) => {
setTraceData('a', 'a')
setTraceData('b', 2)
return Promise.resolve()
})
const transaction = getTransaction()
expect(transaction.data).toEqual({ a: 'a', b: 2 })
})
})
describe('setTraceTag', () => {
it('sets a transaction tag', async () => {
await trace('test', ({ setTraceTag }) => {
setTraceTag('is_widget', true)
return Promise.resolve()
})
const transaction = getTransaction()
expect(transaction.tags).toEqual({ is_widget: true })
})
})
describe('setTraceStatus', () => {
it('sets a transaction status with a string', async () => {
await trace('test', ({ setTraceStatus }) => {
setTraceStatus('cancelled')
return Promise.resolve()
})
let transaction = getTransaction(0)
expect(transaction.status).toBe('cancelled')
await expect(
trace('test', ({ setTraceStatus }) => {
setTraceStatus('failed_precondition')
return Promise.reject()
})
).rejects.toBeUndefined()
transaction = getTransaction(1)
expect(transaction.status).toBe('failed_precondition')
})
it('sets a transaction http status with a number', async () => {
await trace('test', ({ setTraceStatus }) => {
setTraceStatus(429)
return Promise.resolve()
})
const transaction = getTransaction()
expect(transaction.status).toBe('resource_exhausted')
})
})
describe('setTraceError', () => {
it('sets transaction data.error', async () => {
const error = new Error('Test error')
await expect(
trace('test', ({ setTraceError }) => {
setTraceError(error)
return Promise.reject(new Error(`Wrapped ${error.message}`))
})
).rejects.toBeDefined()
const transaction = getTransaction()
expect(transaction.data).toEqual({ error })
})
})
describe('traceChild', () => {
it('starts a span under a transaction', async () => {
await trace('test', ({ traceChild }) => {
traceChild('child', () => Promise.resolve(), { data: { e: 'e' }, tags: { is_widget: true } })
return Promise.resolve()
})
const transaction = getTransaction()
const span = transaction.spanRecorder?.spans[1]
assert(span)
expect(span.op).toBe('child')
expect(span.data).toEqual({ e: 'e' })
expect(span.tags).toEqual({ is_widget: true })
})
})
})
import * as Sentry from '@sentry/react'
import { Span, SpanStatusType } from '@sentry/tracing'
type TraceTags = {
is_widget: boolean
}
interface TraceMetadata {
/** Arbitrary data stored on a trace. */
data?: Record<string, unknown>
/** Indexed (ie searchable) tags associated with a trace. */
tags?: Partial<TraceTags>
}
// These methods are provided as an abstraction so that users will not interact with Sentry directly.
// This avoids tightly coupling Sentry to our instrumentation outside of this file, in case we swap services.
interface TraceCallbackOptions {
/**
* Traces the callback as a child of the active trace.
* @param name - The name of the child. (On Sentry, this will appear as the "op".)
* @param callback - The callback to trace. The child trace will run for the duration of the callback.
* @param metadata - Any data or tags to include in the child trace.
*/
traceChild<T>(name: string, callback: TraceCallback<T>, metadata?: TraceMetadata): Promise<T>
setTraceData(key: string, value: unknown): void
setTraceTag<K extends keyof TraceTags>(key: K, value: TraceTags[K]): void
/**
* Sets the status of a trace. If unset, the status will be set to 'ok' (or 'internal_error' if the callback throws).
* @param status - If a number is passed, the corresponding http status will be used.
*/
setTraceStatus(status: number | SpanStatusType): void
/** Sets the error data of a trace. If unset and the callback throws, the thrown error will be set. */
setTraceError(error: unknown): void
}
type TraceCallback<T> = (options: TraceCallbackOptions) => Promise<T>
/**
* Sets up TraceCallbackOptions for a Span (NB: Transaction extends Span).
* @returns a handler which will run a TraceCallback and propagate its result.
*/
function traceSpan(span?: Span) {
const traceChild = <T>(name: string, callback: TraceCallback<T>, metadata?: TraceMetadata) => {
const child = span?.startChild({ ...metadata, op: name })
return traceSpan(child)(callback)
}
const setTraceData = <K extends keyof TraceTags>(key: K, value: TraceTags[K]) => {
span?.setData(key, value)
}
const setTraceTag = (key: string, value: string | number | boolean) => {
span?.setTag(key, value)
}
const setTraceStatus = (status: number | SpanStatusType) => {
if (typeof status === 'number') {
span?.setHttpStatus(status)
} else {
span?.setStatus(status)
}
}
const setTraceError = (error: unknown) => {
span?.setData('error', error)
}
return async function boundTrace<T>(callback: TraceCallback<T>): Promise<T> {
try {
return await callback({ traceChild, setTraceData, setTraceTag, setTraceStatus, setTraceError })
} catch (error) {
// Do not overwrite any custom status or error data that was already set.
if (!span?.status) span?.setStatus('internal_error')
if (!span?.data.error) span?.setData('error', error)
throw error
} finally {
// If no status was reported, assume that it was 'ok'. Otherwise, it will default to 'unknown'.
if (!span?.status) span?.setStatus('ok')
span?.finish()
}
}
}
/**
* Traces the callback, adding any metadata to the trace.
* @param name - The name of your trace.
* @param callback - The callback to trace. The trace will run for the duration of the callback.
* @param metadata - Any data or tags to include in the trace.
*/
export async function trace<T>(name: string, callback: TraceCallback<T>, metadata?: TraceMetadata): Promise<T> {
const transaction = Sentry.startTransaction({ name, data: metadata?.data, tags: metadata?.tags })
return traceSpan(transaction)(callback)
}
// eslint-disable-next-line no-restricted-imports
import { t } from '@lingui/macro'
/**
* This is hacking out the revert reason from the ethers provider thrown error however it can.
* This object seems to be undocumented by ethers.
* @param error an error from the ethers provider
*/
export function swapErrorToUserReadableMessage(error: any): string {
let reason: string | undefined
if (error.code) {
switch (error.code) {
case 4001:
return t`Transaction rejected`
}
}
console.warn('Swap error:', error)
function getReason(error: any): string | undefined {
let reason: string | undefined
while (error) {
reason = error.reason ?? error.message ?? reason
error = error.error ?? error.data?.originalError
}
return reason
}
// The 4001 error code doesn't capture the case where users reject a transaction for all wallets,
// so we need to parse the reason for these special cases:
export function didUserReject(error: any): boolean {
const reason = getReason(error)
if (
error?.code === 4001 ||
// ethers v5.7.0 wrapped error
error?.code === 'ACTION_REJECTED' ||
// For Rainbow :
......@@ -32,20 +20,35 @@ export function swapErrorToUserReadableMessage(error: any): string {
// For Frame:
reason?.match(/declined/i) ||
// For SafePal:
reason?.match(/cancelled by user/i) ||
reason?.match(/cancell?ed by user/i) ||
// For Trust:
reason?.match(/user cancell?ed/i) ||
// For Coinbase:
reason?.match(/user denied/i) ||
// For Fireblocks
reason?.match(/user rejected/i)
) {
return true
}
return false
}
/**
* This is hacking out the revert reason from the ethers provider thrown error however it can.
* This object seems to be undocumented by ethers.
* @param error - An error from the ethers provider
*/
export function swapErrorToUserReadableMessage(error: any): string {
if (didUserReject(error)) {
return t`Transaction rejected`
}
let reason = getReason(error)
if (reason?.indexOf('execution reverted: ') === 0) reason = reason.substr('execution reverted: '.length)
switch (reason) {
case 'UniswapV2Router: EXPIRED':
return t`The transaction could not be sent because the deadline has passed. Please check that your transaction deadline is not too low.`
return t`This transaction could not be sent because the deadline has passed. Please check that your transaction deadline is not too low.`
case 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT':
case 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT':
return t`This transaction will not succeed either due to price movement or fee on transfer. Try increasing your slippage tolerance.`
......@@ -63,6 +66,7 @@ export function swapErrorToUserReadableMessage(error: any): string {
return t`The output token cannot be transferred. There may be an issue with the output token. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3.`
default:
if (reason?.indexOf('undefined is not an object') !== -1) {
console.error(error, reason)
return t`An error occurred when trying to execute this swap. You may need to increase your slippage tolerance. If that does not work, there may be an incompatibility with the token you are trading. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3.`
}
return t`${reason ? reason : 'Unknown error'}. Try increasing your slippage tolerance.
......
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