diff --git a/src/hooks/useUniswapXSwapCallback.ts b/src/hooks/useUniswapXSwapCallback.ts index fb467279..5799e415 100644 --- a/src/hooks/useUniswapXSwapCallback.ts +++ b/src/hooks/useUniswapXSwapCallback.ts @@ -1,31 +1,32 @@ -import { BigNumber } from '@ethersproject/bignumber' -import * as Sentry from '@sentry/react' -import { CustomUserProperties, SwapEventName } from '@uniswap/analytics-events' -import { Percent } from '@uniswap/sdk-core' -import { DutchOrder, DutchOrderBuilder } from '@uniswap/uniswapx-sdk' -import { useWeb3React } from '@web3-react/core' -import { sendAnalyticsEvent, useTrace } from 'analytics' -import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' -import { getConnection } from 'connection' -import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' -import { useCallback } from 'react' -import { DutchOrderTrade, TradeFillType } from 'state/routing/types' - -import { SignatureExpiredError, UserRejectedRequestError } from 'utils/errors' -import { signTypedData } from 'utils/signing' -import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' -import { getWalletMeta } from 'utils/walletMeta' +/* eslint-disable */ +// @ts-nocheck +import { BigNumber } from '@ethersproject/bignumber'; +import * as Sentry from '@sentry/react'; +import { CustomUserProperties, SwapEventName } from '@uniswap/analytics-events'; +import { Percent } from '@uniswap/sdk-core'; +import { DutchOrder, DutchOrderBuilder } from '@uniswap/uniswapx-sdk'; +import { useWeb3React } from '@web3-react/core'; +import { useCallback } from 'react'; + +import { sendAnalyticsEvent, useTrace } from 'analytics'; +import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'; +import { getConnection } from 'connection'; +import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics'; +import { DutchOrderTrade, TradeFillType } from 'state/routing/types'; +import { SignatureExpiredError, UserRejectedRequestError } from 'utils/errors'; +import { signTypedData } from 'utils/signing'; +import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage'; +import { getWalletMeta } from 'utils/walletMeta'; type DutchAuctionOrderError = { errorCode?: number; detail?: string } type DutchAuctionOrderSuccess = { hash: string } type DutchAuctionOrderResponse = DutchAuctionOrderError | DutchAuctionOrderSuccess -const isErrorResponse = (res: Response, order: DutchAuctionOrderResponse): order is DutchAuctionOrderError => - res.status < 200 || res.status > 202 +const isErrorResponse = (res: Response, order: DutchAuctionOrderResponse): order is DutchAuctionOrderError => res.status < 200 || res.status > 202; -const UNISWAP_API_URL = process.env.REACT_APP_UNISWAP_API_URL +const UNISWAP_API_URL = process.env.REACT_APP_UNISWAP_API_URL; if (UNISWAP_API_URL === undefined) { - throw new Error(`UNISWAP_API_URL must be a defined environment variable`) + throw new Error('UNISWAP_API_URL must be a defined environment variable'); } // getUpdatedNonce queries the UniswapX service for the most up-to-date nonce for a user. @@ -34,33 +35,131 @@ if (UNISWAP_API_URL === undefined) { // async function getUpdatedNonce(swapper: string, chainId: number): Promise { try { - const res = await fetch(`${UNISWAP_API_URL}/nonce?address=${swapper}&chainId=${chainId}`) - const { nonce } = await res.json() - return BigNumber.from(nonce) + const res = await fetch(`${UNISWAP_API_URL}/nonce?address=${swapper}&chainId=${chainId}`); + const { nonce } = await res.json(); + return BigNumber.from(nonce); } catch (e) { - Sentry.withScope(function (scope) { - scope.setTag('method', 'getUpdatedNonce') - scope.setLevel('warning') - Sentry.captureException(e) - }) - return null + Sentry.withScope((scope) => { + scope.setTag('method', 'getUpdatedNonce'); + scope.setLevel('warning'); + Sentry.captureException(e); + }); + return null; } } -export function useUniswapXSwapCallback({ - trade, +export function useUniswapXSwapCallback({ trade, allowedSlippage, - fiatValues, -}: { + fiatValues }: { trade?: DutchOrderTrade fiatValues: { amountIn?: number; amountOut?: number; feeUsd?: number } allowedSlippage: Percent }) { - const { account, provider, connector } = useWeb3React() - const analyticsContext = useTrace() + const { account, provider, connector } = useWeb3React(); + const analyticsContext = useTrace(); + + const { data } = useCachedPortfolioBalancesQuery({ account }); + const portfolioBalanceUsd = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value; + + return useCallback( + async () => { + if (!account) { throw new Error('missing account'); } + if (!provider) { throw new Error('missing provider'); } + if (!trade) { throw new Error('missing trade'); } + + const signDutchOrder = async (): Promise<{ signature: string; updatedOrder: DutchOrder }> => { + try { + const updatedNonce = await getUpdatedNonce(account, trade.order.chainId); + + const startTime = Math.floor(Date.now() / 1000) + trade.startTimeBufferSecs; + + const endTime = startTime + trade.auctionPeriodSecs; + + const deadline = endTime + trade.deadlineBufferSecs; + + // Set timestamp and account based values when the user clicks 'swap' to make them as recent as possible + const updatedOrder = DutchOrderBuilder.fromOrder(trade.order) + .decayStartTime(startTime) + .decayEndTime(endTime) + .deadline(deadline) + .swapper(account) + .nonFeeRecipient(account, trade.swapFee?.recipient) + // if fetching the nonce fails for any reason, default to existing nonce from the Swap quote. + .nonce(updatedNonce ?? trade.order.info.nonce) + .build(); + + const { domain, types, values } = updatedOrder.permitData(); + + const signature = await signTypedData(provider.getSigner(account), domain, types, values); + if (deadline < Math.floor(Date.now() / 1000)) { + throw new SignatureExpiredError(); + } + return { signature, updatedOrder }; + } catch (swapError) { + if (swapError instanceof SignatureExpiredError) { + throw swapError; + } + if (didUserReject(swapError)) { + throw new UserRejectedRequestError(swapErrorToUserReadableMessage(swapError)); + } + throw new Error(swapErrorToUserReadableMessage(swapError)); + } + }; + + const beforeSign = Date.now(); + const { signature, updatedOrder } = await signDutchOrder(); + + sendAnalyticsEvent(SwapEventName.SWAP_SIGNED, { + ...formatSwapSignedAnalyticsEventProperties({ + trade, + allowedSlippage, + fiatValues, + timeToSignSinceRequestMs: Date.now() - beforeSign, + portfolioBalanceUsd, + }), + ...analyticsContext, + // TODO (WEB-2993): remove these after debugging missing user properties. + [CustomUserProperties.WALLET_ADDRESS]: account, + [CustomUserProperties.WALLET_TYPE]: getConnection(connector).getName(), + [CustomUserProperties.PEER_WALLET_AGENT]: provider ? getWalletMeta(provider)?.agent : undefined, + }); + + const res = await fetch(`${UNISWAP_API_URL}/order`, { + method: 'POST', + body: JSON.stringify({ + encodedOrder: updatedOrder.serialize(), + signature, + chainId: updatedOrder.chainId, + quoteId: trade.quoteId, + }), + }); + + const body = (await res.json()) as DutchAuctionOrderResponse; - const { data } = useCachedPortfolioBalancesQuery({ account }) - const portfolioBalanceUsd = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value + // TODO(UniswapX): For now, `errorCode` is not always present in the response, so we have to fallback + // check for status code and perform this type narrowing. + if (isErrorResponse(res, body)) { + sendAnalyticsEvent('UniswapX Order Post Error', { + ...formatSwapSignedAnalyticsEventProperties({ + trade, + allowedSlippage, + fiatValues, + portfolioBalanceUsd, + }), + ...analyticsContext, + errorCode: body.errorCode, + detail: body.detail, + }); + // TODO(UniswapX): Provide a similar utility to `swapErrorToUserReadableMessage` once + // backend team provides a list of error codes and potential messages + throw new Error(`${body.errorCode ?? body.detail ?? 'Unknown error'}`); + } - return () => {}; + return { + type: TradeFillType.UniswapX as const, + response: { orderHash: body.hash, deadline: updatedOrder.info.deadline }, + }; + }, + [account, provider, trade, allowedSlippage, fiatValues, portfolioBalanceUsd, analyticsContext, connector], + ); } diff --git a/src/hooks/useUniversalRouter.ts b/src/hooks/useUniversalRouter.ts index 0ded235f..3a7e2a0f 100644 --- a/src/hooks/useUniversalRouter.ts +++ b/src/hooks/useUniversalRouter.ts @@ -1,31 +1,32 @@ -import { BigNumber } from '@ethersproject/bignumber' -import { t } from '@lingui/macro' -import { CustomUserProperties, SwapEventName } from '@uniswap/analytics-events' -import { Percent } from '@uniswap/sdk-core' -import { FlatFeeOptions, SwapRouter, UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk' -import { FeeOptions, toHex } from '@uniswap/v3-sdk' -import { useWeb3React } from '@web3-react/core' -import { sendAnalyticsEvent, useTrace } from 'analytics' -import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' -import { getConnection } from 'connection' -import useBlockNumber from 'lib/hooks/useBlockNumber' -import { formatCommonPropertiesForTrade, formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' -import { useCallback } from 'react' -import { ClassicTrade, TradeFillType } from 'state/routing/types' -import { useUserSlippageTolerance } from 'state/user/hooks' +/* eslint-disable */ +// @ts-nocheck +import { BigNumber } from '@ethersproject/bignumber'; +import { t } from '@lingui/macro'; +import { CustomUserProperties, SwapEventName } from '@uniswap/analytics-events'; +import { Percent } from '@uniswap/sdk-core'; +import { FlatFeeOptions, SwapRouter, UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk'; +import { FeeOptions, toHex } from '@uniswap/v3-sdk'; +import { useWeb3React } from '@web3-react/core'; +import { useCallback } from 'react'; -import { calculateGasMargin } from 'utils/calculateGasMargin' -import { UserRejectedRequestError, WrongChainError } from 'utils/errors' -import isZero from 'utils/isZero' -import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' -import { getWalletMeta } from 'utils/walletMeta' - -import { PermitSignature } from './usePermitAllowance' +import { sendAnalyticsEvent, useTrace } from 'analytics'; +import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'; +import { getConnection } from 'connection'; +import useBlockNumber from 'lib/hooks/useBlockNumber'; +import { formatCommonPropertiesForTrade, formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics'; +import { ClassicTrade, TradeFillType } from 'state/routing/types'; +import { useUserSlippageTolerance } from 'state/user/hooks'; +import { calculateGasMargin } from 'utils/calculateGasMargin'; +import { UserRejectedRequestError, WrongChainError } from 'utils/errors'; +import isZero from 'utils/isZero'; +import { didUserReject, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage'; +import { getWalletMeta } from 'utils/walletMeta'; +import { PermitSignature } from './usePermitAllowance'; /** 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.`) + super(t`Your swap is expected to fail.`); } } @@ -36,8 +37,8 @@ class GasEstimationError extends Error { 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.` - ) + t`Your swap was modified through your wallet. If this was a mistake, please cancel immediately or risk losing your funds.`, + ); } } @@ -52,14 +53,122 @@ interface SwapOptions { export function useUniversalRouterSwapCallback( trade: ClassicTrade | undefined, fiatValues: { amountIn?: number; amountOut?: number; feeUsd?: number }, - options: SwapOptions + options: SwapOptions, ) { - const { account, chainId, provider, connector } = useWeb3React() - const analyticsContext = useTrace() - const blockNumber = useBlockNumber() - const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto' - const { data } = useCachedPortfolioBalancesQuery({ account }) - const portfolioBalanceUsd = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value + const { account, chainId, provider, connector } = useWeb3React(); + const analyticsContext = useTrace(); + const blockNumber = useBlockNumber(); + const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto'; + const { data } = useCachedPortfolioBalancesQuery({ account }); + const portfolioBalanceUsd = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value; + + return useCallback(async () => { + 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 connectedChainId = await provider.getSigner().getChainId(); + if (chainId !== connectedChainId) { throw new WrongChainError(); } + + // universal-router-sdk reconstructs V2Trade objects, so rather than updating the trade amounts to account for tax, we adjust the slippage tolerance as a workaround + // TODO(WEB-2725): update universal-router-sdk to not reconstruct trades + const taxAdjustedSlippageTolerance = options.slippageTolerance.add(trade.totalTaxRate); + + const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, { + slippageTolerance: taxAdjustedSlippageTolerance, + deadlineOrPreviousBlockhash: options.deadline?.toString(), + inputTokenPermit: options.permit, + fee: options.feeOptions, + flatFee: options.flatFeeOptions, + }); + + 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) { + sendAnalyticsEvent(SwapEventName.SWAP_ESTIMATE_GAS_CALL_FAILED, { + ...formatCommonPropertiesForTrade(trade, options.slippageTolerance), + ...analyticsContext, + client_block_number: blockNumber, + tx, + error: gasError, + isAutoSlippage, + }); + console.warn(gasError); + throw new GasEstimationError(); + } + const gasLimit = calculateGasMargin(gasEstimate); + const beforeSign = Date.now(); + const response = await provider + .getSigner() + .sendTransaction({ ...tx, gasLimit }) + .then((response) => { + sendAnalyticsEvent(SwapEventName.SWAP_SIGNED, { + ...formatSwapSignedAnalyticsEventProperties({ + trade, + timeToSignSinceRequestMs: Date.now() - beforeSign, + allowedSlippage: options.slippageTolerance, + fiatValues, + txHash: response.hash, + portfolioBalanceUsd, + }), + ...analyticsContext, + // TODO (WEB-2993): remove these after debugging missing user properties. + [CustomUserProperties.WALLET_ADDRESS]: account, + [CustomUserProperties.WALLET_TYPE]: getConnection(connector).getName(), + [CustomUserProperties.PEER_WALLET_AGENT]: provider ? getWalletMeta(provider)?.agent : undefined, + }); + if (tx.data !== response.data) { + sendAnalyticsEvent(SwapEventName.SWAP_MODIFIED_IN_WALLET, { + txHash: response.hash, + ...analyticsContext, + }); + + if (!response.data || response.data.length === 0 || response.data === '0x') { + throw new ModifiedSwapError(); + } + } + return response; + }); + return { + type: TradeFillType.Classic as const, + response, + }; + } catch (swapError: unknown) { + if (swapError instanceof ModifiedSwapError) { throw swapError; } + + // Cancellations are not failures, and must be accounted for as 'cancelled'. + if (didUserReject(swapError)) { + // This error type allows us to distinguish between user rejections and other errors later too. + throw new UserRejectedRequestError(swapErrorToUserReadableMessage(swapError)); + } - return () => {} + throw new Error(swapErrorToUserReadableMessage(swapError)); + } + }, [ + account, + chainId, + provider, + trade, + options.slippageTolerance, + options.deadline, + options.permit, + options.feeOptions, + options.flatFeeOptions, + analyticsContext, + blockNumber, + isAutoSlippage, + fiatValues, + portfolioBalanceUsd, + connector, + ]); }