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' ...@@ -10,13 +10,31 @@ import { FeeOptions, toHex } from '@uniswap/v3-sdk'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics'
import { useCallback } from 'react' import { useCallback } from 'react'
import { trace } from 'tracing'
import { calculateGasMargin } from 'utils/calculateGasMargin' import { calculateGasMargin } from 'utils/calculateGasMargin'
import isZero from 'utils/isZero' import isZero from 'utils/isZero'
import { swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage'
import { PermitSignature } from './usePermitAllowance' 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 { interface SwapOptions {
slippageTolerance: Percent slippageTolerance: Percent
...@@ -33,12 +51,16 @@ export function useUniversalRouterSwapCallback( ...@@ -33,12 +51,16 @@ export function useUniversalRouterSwapCallback(
const { account, chainId, provider } = useWeb3React() const { account, chainId, provider } = useWeb3React()
return useCallback(async (): Promise<TransactionResponse> => { return useCallback(async (): Promise<TransactionResponse> => {
return trace(
'swap.send',
async ({ setTraceData, setTraceStatus, setTraceError }) => {
try { try {
if (!account) throw new Error('missing account') if (!account) throw new Error('missing account')
if (!chainId) throw new Error('missing chainId') if (!chainId) throw new Error('missing chainId')
if (!provider) throw new Error('missing provider') if (!provider) throw new Error('missing provider')
if (!trade) throw new Error('missing trade') if (!trade) throw new Error('missing trade')
setTraceData('slippageTolerance', options.slippageTolerance.toFixed(2))
const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, { const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, {
slippageTolerance: options.slippageTolerance, slippageTolerance: options.slippageTolerance,
deadlineOrPreviousBlockhash: options.deadline?.toString(), deadlineOrPreviousBlockhash: options.deadline?.toString(),
...@@ -49,7 +71,7 @@ export function useUniversalRouterSwapCallback( ...@@ -49,7 +71,7 @@ export function useUniversalRouterSwapCallback(
from: account, from: account,
to: UNIVERSAL_ROUTER_ADDRESS(chainId), to: UNIVERSAL_ROUTER_ADDRESS(chainId),
data, data,
// TODO: universal-router-sdk returns a non-hexlified value. // TODO(https://github.com/Uniswap/universal-router-sdk/issues/113): universal-router-sdk returns a non-hexlified value.
...(value && !isZero(value) ? { value: toHex(value) } : {}), ...(value && !isZero(value) ? { value: toHex(value) } : {}),
} }
...@@ -57,10 +79,13 @@ export function useUniversalRouterSwapCallback( ...@@ -57,10 +79,13 @@ export function useUniversalRouterSwapCallback(
try { try {
gasEstimate = await provider.estimateGas(tx) gasEstimate = await provider.estimateGas(tx)
} catch (gasError) { } catch (gasError) {
setTraceStatus('failed_precondition')
setTraceError(gasError)
console.warn(gasError) console.warn(gasError)
throw new Error('Your swap is expected to fail') throw new GasEstimationError()
} }
const gasLimit = calculateGasMargin(gasEstimate) const gasLimit = calculateGasMargin(gasEstimate)
setTraceData('gasLimit', gasLimit.toNumber())
const response = await provider const response = await provider
.getSigner() .getSigner()
.sendTransaction({ ...tx, gasLimit }) .sendTransaction({ ...tx, gasLimit })
...@@ -71,17 +96,25 @@ export function useUniversalRouterSwapCallback( ...@@ -71,17 +96,25 @@ export function useUniversalRouterSwapCallback(
) )
if (tx.data !== response.data) { if (tx.data !== response.data) {
sendAnalyticsEvent(SwapEventName.SWAP_MODIFIED_IN_WALLET, { txHash: response.hash }) sendAnalyticsEvent(SwapEventName.SWAP_MODIFIED_IN_WALLET, { txHash: response.hash })
throw new InvalidSwapError( throw new ModifiedSwapError()
t`Your swap was modified through your wallet. If this was a mistake, please cancel immediately or risk losing your funds.`
)
} }
return response return response
}) })
return response return response
} catch (swapError: unknown) { } catch (swapError: unknown) {
if (swapError instanceof InvalidSwapError) throw swapError 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)) throw new Error(swapErrorToUserReadableMessage(swapError))
} }
},
{ tags: { is_widget: false } }
)
}, [ }, [
account, account,
chainId, chainId,
......
...@@ -7,6 +7,8 @@ import { SharedEventName } from '@uniswap/analytics-events' ...@@ -7,6 +7,8 @@ import { SharedEventName } from '@uniswap/analytics-events'
import { isSentryEnabled } from 'utils/env' import { isSentryEnabled } from 'utils/env'
import { getEnvName, isProductionEnv } from 'utils/env' import { getEnvName, isProductionEnv } from 'utils/env'
export { trace } from './trace'
// Dump some metadata into the window to allow client verification. // Dump some metadata into the window to allow client verification.
window.GIT_COMMIT_HASH = process.env.REACT_APP_GIT_COMMIT_HASH 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' 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) { while (error) {
reason = error.reason ?? error.message ?? reason reason = error.reason ?? error.message ?? reason
error = error.error ?? error.data?.originalError 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, export function didUserReject(error: any): boolean {
// so we need to parse the reason for these special cases: const reason = getReason(error)
if ( if (
error?.code === 4001 ||
// ethers v5.7.0 wrapped error // ethers v5.7.0 wrapped error
error?.code === 'ACTION_REJECTED' || error?.code === 'ACTION_REJECTED' ||
// For Rainbow : // For Rainbow :
...@@ -32,20 +20,35 @@ export function swapErrorToUserReadableMessage(error: any): string { ...@@ -32,20 +20,35 @@ export function swapErrorToUserReadableMessage(error: any): string {
// For Frame: // For Frame:
reason?.match(/declined/i) || reason?.match(/declined/i) ||
// For SafePal: // 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: // For Coinbase:
reason?.match(/user denied/i) || reason?.match(/user denied/i) ||
// For Fireblocks // For Fireblocks
reason?.match(/user rejected/i) 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` return t`Transaction rejected`
} }
let reason = getReason(error)
if (reason?.indexOf('execution reverted: ') === 0) reason = reason.substr('execution reverted: '.length) if (reason?.indexOf('execution reverted: ') === 0) reason = reason.substr('execution reverted: '.length)
switch (reason) { switch (reason) {
case 'UniswapV2Router: EXPIRED': 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: INSUFFICIENT_OUTPUT_AMOUNT':
case 'UniswapV2Router: EXCESSIVE_INPUT_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.` 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 { ...@@ -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.` 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: default:
if (reason?.indexOf('undefined is not an object') !== -1) { 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`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. 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