diff --git a/.env b/.env index ec79d4356..c3254aa10 100644 --- a/.env +++ b/.env @@ -71,3 +71,6 @@ REACT_APP_PATH_REGEX_ENS="/ipfs" # Enables mock mode (default = true) REACT_APP_MOCK=true + +# Gp Api +REACT_APP_DEFAULT_GP_API=LEGACY \ No newline at end of file diff --git a/.env.production b/.env.production index d4a7aa3a4..6954538fd 100644 --- a/.env.production +++ b/.env.production @@ -70,3 +70,6 @@ REACT_APP_PATH_REGEX_ENS="/ipfs" # Enables mock mode (default = false) REACT_APP_MOCK=false + +# Gp Api +REACT_APP_DEFAULT_GP_API=LEGACY diff --git a/cypress-custom/integration/fee.test.ts b/cypress-custom/integration/fee.test.ts index 79dd850bd..f96952e5b 100644 --- a/cypress-custom/integration/fee.test.ts +++ b/cypress-custom/integration/fee.test.ts @@ -1,21 +1,51 @@ import { WETH9 as WETH } from '@uniswap/sdk-core' -import { OrderKind } from '@gnosis.pm/gp-v2-contracts' -import { FeeQuoteParams, FeeInformation } from '../../src/custom/utils/price' +import { GetQuoteResponse } from '@gnosis.pm/gp-v2-contracts' import { parseUnits } from 'ethers/lib/utils' const DAI = '0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735' const FOUR_HOURS = 3600 * 4 * 1000 const DEFAULT_SELL_TOKEN = WETH[4] +const DEFAULT_APP_DATA = '0x0000000000000000000000000000000000000000000000000000000000000000' +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +const FEE_QUERY = `https://protocol-rinkeby.dev.gnosisdev.com/api/v1/quote` + +const baseParams = { + from: ZERO_ADDRESS, + receiver: ZERO_ADDRESS, + validTo: Math.ceil(Date.now() / 1000 + 500), + appData: DEFAULT_APP_DATA, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + partiallyFillable: false, +} -const getFeeQuery = ({ sellToken, buyToken, amount, kind }: Omit) => - `https://protocol-rinkeby.dev.gnosisdev.com/api/v1/fee?sellToken=${sellToken}&buyToken=${buyToken}&amount=${amount}&kind=${kind}` +const mockQuoteResponse = { + quote: { + // arb props here.. + sellToken: '0x6810e776880c02933d47db1b9fc05908e5386b96', + buyToken: '0x6810e776880c02933d47db1b9fc05908e5386b96', + receiver: '0x6810e776880c02933d47db1b9fc05908e5386b96', + sellAmount: '1234567890', + buyAmount: '1234567890', + validTo: 0, + appData: '0x0000000000000000000000000000000000000000000000000000000000000000', + feeAmount: '1234567890', + kind: 'buy', + partiallyFillable: true, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + }, + from: ZERO_ADDRESS, +} -function _assertFeeData(fee: FeeInformation | string): void { +function _assertFeeData(fee: GetQuoteResponse): void { if (typeof fee === 'string') { fee = JSON.parse(fee) } - expect(fee).to.have.property('amount') - expect(fee).to.have.property('expirationDate') + expect(fee).to.have.property('quote') + expect(fee).to.have.property('expiration') + expect(fee.quote).to.have.property('feeAmount') } /* Fee not currently being saved in local so commenting this out @@ -54,18 +84,25 @@ function _assertFeeFetched(token: string): Cypress.Chainable { describe('Fee endpoint', () => { it('Returns the expected info', () => { - const FEE_QUERY = getFeeQuery({ + const params = { sellToken: DEFAULT_SELL_TOKEN.address, buyToken: DAI, - amount: parseUnits('0.1', DEFAULT_SELL_TOKEN.decimals).toString(), - kind: OrderKind.SELL, + sellAmountBeforeFee: parseUnits('0.1', DEFAULT_SELL_TOKEN.decimals).toString(), + kind: 'sell', fromDecimals: DEFAULT_SELL_TOKEN.decimals, toDecimals: 6, - }) + // BASE PARAMS + ...baseParams, + } // GIVEN: - // WHEN: Call fee API - cy.request(FEE_QUERY) + cy.request({ + method: 'POST', + url: FEE_QUERY, + body: params, + log: true, + }) .its('body') // THEN: The API response has the expected data .should(_assertFeeData) @@ -74,14 +111,6 @@ describe('Fee endpoint', () => { describe('Fee: Complex fetch and persist fee', () => { const INPUT_AMOUNT = '0.1' - const FEE_QUERY = getFeeQuery({ - sellToken: DEFAULT_SELL_TOKEN.address, - buyToken: DAI, - amount: parseUnits(INPUT_AMOUNT, DEFAULT_SELL_TOKEN.decimals).toString(), - kind: OrderKind.SELL, - fromDecimals: DEFAULT_SELL_TOKEN.decimals, - toDecimals: 6, - }) // Needs to run first to pass because of Cypress async issues between tests it('Re-fetched when it expires', () => { @@ -89,8 +118,8 @@ describe('Fee: Complex fetch and persist fee', () => { const SIX_HOURS = FOUR_HOURS * 1.5 const LATER_TIME = new Date(Date.now() + SIX_HOURS).toISOString() const LATER_FEE = { - expirationDate: LATER_TIME, - amount: '0', + ...mockQuoteResponse, + expiration: LATER_TIME, } // only override Date functions (default is to override all time based functions) @@ -116,9 +145,9 @@ describe('Fee: Complex fetch and persist fee', () => { const mockedTime = new Date($clock.details().now) // THEN: fee time is properly stubbed and - expect(body.expirationDate).to.equal(LATER_TIME) + expect(body.expiration).to.equal(LATER_TIME) // THEN: the mocked later date is indeed less than the new fee (read: the fee is valid) - expect(new Date(body.expirationDate)).to.be.greaterThan(mockedTime) + expect(new Date(body.expiration)).to.be.greaterThan(mockedTime) }) }) }) @@ -126,18 +155,10 @@ describe('Fee: Complex fetch and persist fee', () => { describe('Fee: simple checks it exists', () => { const INPUT_AMOUNT = '0.1' - const FEE_QUERY = getFeeQuery({ - sellToken: DEFAULT_SELL_TOKEN.address, - buyToken: DAI, - amount: parseUnits(INPUT_AMOUNT, DEFAULT_SELL_TOKEN.decimals).toString(), - kind: OrderKind.SELL, - fromDecimals: DEFAULT_SELL_TOKEN.decimals, - toDecimals: 6, - }) - const FEE_RESP = { + const QUOTE_RESP = { + ...mockQuoteResponse, // 1 min in future - expirationDate: new Date(Date.now() + 60000).toISOString(), - amount: parseUnits('0.05', DEFAULT_SELL_TOKEN.decimals).toString(), + expiration: new Date(Date.now() + 60000).toISOString(), } it('Fetch fee when selecting both tokens', () => { @@ -145,7 +166,7 @@ describe('Fee: simple checks it exists', () => { cy.stubResponse({ url: FEE_QUERY, alias: 'feeRequest', - body: FEE_RESP, + body: QUOTE_RESP, }) // GIVEN: A user loads the swap page // WHEN: Select DAI token as output and sells 0.1 WETH diff --git a/cypress-custom/support/commands.js b/cypress-custom/support/commands.js index 7f5b5e9b2..0b8ab4ab1 100644 --- a/cypress-custom/support/commands.js +++ b/cypress-custom/support/commands.js @@ -53,7 +53,7 @@ function enterOutputAmount(tokenAddress, amount, selectToken = false) { } function stubResponse({ url, alias = 'stubbedResponse', body }) { - cy.intercept({ method: 'GET', url }, _responseHandlerFactory(body)).as(alias) + cy.intercept({ method: 'POST', url }, _responseHandlerFactory(body)).as(alias) } Cypress.Commands.add('swapClickInputToken', () => clickInputToken) diff --git a/package.json b/package.json index 3918a0ba2..9a3fdc988 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,7 @@ "dependencies": { "@gnosis.pm/cow-runner-game": "^0.2.9", "@gnosis.pm/dex-js": "^0.12.0", - "@gnosis.pm/gp-v2-contracts": "^1.0.2", + "@gnosis.pm/gp-v2-contracts": "ˆ1.1.2", "@gnosis.pm/safe-service-client": "^0.1.1", "@pinata/sdk": "^1.1.23", "@sentry/react": "^6.11.0", diff --git a/src/custom/api/gnosisProtocol/api.ts b/src/custom/api/gnosisProtocol/api.ts index bd9ec6f44..70bd6330b 100644 --- a/src/custom/api/gnosisProtocol/api.ts +++ b/src/custom/api/gnosisProtocol/api.ts @@ -1,5 +1,5 @@ import { SupportedChainId as ChainId } from 'constants/chains' -import { OrderKind } from '@gnosis.pm/gp-v2-contracts' +import { OrderKind, QuoteQuery } from '@gnosis.pm/gp-v2-contracts' import { stringify } from 'qs' import { getSigningSchemeApiValue, OrderCreation, OrderCancellation, SigningSchemeValue } from 'utils/signatures' import { APP_DATA_HASH } from 'constants/index' @@ -17,11 +17,13 @@ import QuoteError, { GpQuoteErrorDetails, } from 'api/gnosisProtocol/errors/QuoteError' import { toErc20Address } from 'utils/tokens' -import { FeeInformation, FeeQuoteParams, PriceInformation, PriceQuoteParams } from 'utils/price' +import { FeeQuoteParams, PriceInformation, PriceQuoteParams, SimpleGetQuoteResponse } from 'utils/price' import { DEFAULT_NETWORK_FOR_LISTS } from 'constants/lists' import { GAS_FEE_ENDPOINTS } from 'constants/index' import * as Sentry from '@sentry/browser' +import { ZERO_ADDRESS } from '@src/constants/misc' +import { getAppDataHash } from 'constants/appDataHash' function getGnosisProtocolUrl(): Partial> { if (isLocal || isDev || isPr || isBarn) { @@ -246,7 +248,10 @@ const UNHANDLED_ORDER_ERROR: ApiErrorObject = { description: ApiErrorCodeDetails.UNHANDLED_CREATE_ERROR, } -async function _handleQuoteResponse(response: Response, params?: FeeQuoteParams) { +async function _handleQuoteResponse( + response: Response, + params?: P +): Promise { if (!response.ok) { const errorObj: ApiErrorObject = await response.json() @@ -266,7 +271,7 @@ async function _handleQuoteResponse(response: Response, params?: FeeQuoteParams) // report to sentry Sentry.captureException(sentryError, { tags: { errorType: 'getFeeQuote' }, - contexts: { params }, + contexts: { params: { ...params } }, }) } @@ -276,7 +281,45 @@ async function _handleQuoteResponse(response: Response, params?: FeeQuoteParams) } } -export async function getPriceQuote(params: PriceQuoteParams): Promise { +function _mapNewToLegacyParams(params: FeeQuoteParams): QuoteQuery { + const { amount, kind, userAddress = ZERO_ADDRESS, validTo, sellToken, buyToken } = params + + const baseParams = { + sellToken, + buyToken, + from: userAddress as string, + // TODO: check this + receiver: userAddress as string, + appData: getAppDataHash(), + validTo, + partiallyFillable: false, + } + + const finalParams: QuoteQuery = + kind === OrderKind.SELL + ? { + kind: OrderKind.SELL, + sellAmountBeforeFee: amount, + ...baseParams, + } + : { + kind: OrderKind.BUY, + buyAmountAfterFee: amount, + ...baseParams, + } + + return finalParams +} + +export async function getQuote(params: FeeQuoteParams) { + const { chainId } = params + const quoteParams = _mapNewToLegacyParams(params) + const response = await _post(chainId, '/quote', quoteParams) + + return _handleQuoteResponse(response) +} + +export async function getPriceQuoteLegacy(params: PriceQuoteParams): Promise { const { baseToken, quoteToken, amount, kind, chainId } = params console.log(`[api:${API_NAME}] Get price from API`, params) @@ -292,25 +335,7 @@ export async function getPriceQuote(params: PriceQuoteParams): Promise { - const { sellToken, buyToken, amount, kind, chainId } = params - console.log(`[api:${API_NAME}] Get fee from API`, params) - - const response = await _get( - chainId, - `/fee?sellToken=${toErc20Address(sellToken, chainId)}&buyToken=${toErc20Address( - buyToken, - chainId - )}&amount=${amount}&kind=${kind}` - ).catch((error) => { - console.error('Error getting fee quote:', error) - throw new QuoteError(UNHANDLED_QUOTE_ERROR) - }) - - return _handleQuoteResponse(response, params) + return _handleQuoteResponse(response) } export async function getOrder(chainId: ChainId, orderId: string): Promise { @@ -397,5 +422,12 @@ export async function getGasPrices(chainId: ChainId = DEFAULT_NETWORK_FOR_LISTS) // Register some globals for convenience registerOnWindow({ - operator: { getFeeQuote, getTrades, getOrder, sendSignedOrder: sendOrder, apiGet: _get, apiPost: _post }, + operator: { + getQuote, + getTrades, + getOrder, + sendSignedOrder: sendOrder, + apiGet: _get, + apiPost: _post, + }, }) diff --git a/src/custom/api/gnosisProtocol/errors/OperatorError.ts b/src/custom/api/gnosisProtocol/errors/OperatorError.ts index 8e21b856e..f68cd8f26 100644 --- a/src/custom/api/gnosisProtocol/errors/OperatorError.ts +++ b/src/custom/api/gnosisProtocol/errors/OperatorError.ts @@ -15,6 +15,7 @@ export enum ApiErrorCodes { InsufficientFunds = 'InsufficientFunds', InsufficientFee = 'InsufficientFee', UnsupportedToken = 'UnsupportedToken', + SellAmountDoesNotCoverFee = 'SellAmountDoesNotCoverFee', WrongOwner = 'WrongOwner', NotFound = 'NotFound', OrderNotFound = 'OrderNotFound', @@ -34,6 +35,7 @@ export enum ApiErrorCodeDetails { InsufficientValidTo = 'The order you are signing is already expired. This can happen if you set a short expiration in the settings and waited too long before signing the transaction. Please try again.', InsufficientFunds = "The account doesn't have enough funds", UnsupportedToken = 'One of the tokens you are trading is unsupported. Please read the FAQ for more info.', + SellAmountDoesNotCoverFee = 'The sell amount for the sell order is lower than the fee.', WrongOwner = "The signature is invalid.\n\nIt's likely that the signing method provided by your wallet doesn't comply with the standards required by CowSwap.\n\nCheck whether your Wallet app supports off-chain signing (EIP-712 or ETHSIGN).", NotFound = 'Token pair selected has insufficient liquidity', OrderNotFound = 'The order you are trying to cancel does not exist', diff --git a/src/custom/api/gnosisProtocol/errors/QuoteError.ts b/src/custom/api/gnosisProtocol/errors/QuoteError.ts index 838e7edf7..017e29ef1 100644 --- a/src/custom/api/gnosisProtocol/errors/QuoteError.ts +++ b/src/custom/api/gnosisProtocol/errors/QuoteError.ts @@ -35,6 +35,11 @@ export function mapOperatorErrorToQuoteError(error?: ApiErrorObject): GpQuoteErr errorType: GpQuoteErrorCodes.UnsupportedToken, description: error.description, } + case ApiErrorCodes.SellAmountDoesNotCoverFee: + return { + errorType: GpQuoteErrorCodes.FeeExceedsFrom, + description: error.description, + } default: return { errorType: GpQuoteErrorCodes.UNHANDLED_ERROR, description: GpQuoteErrorDetails.UNHANDLED_ERROR } } diff --git a/src/custom/api/gnosisProtocol/index.ts b/src/custom/api/gnosisProtocol/index.ts index 8b1a300e6..d3944a9ec 100644 --- a/src/custom/api/gnosisProtocol/index.ts +++ b/src/custom/api/gnosisProtocol/index.ts @@ -15,8 +15,8 @@ export const { getOrderLink = realApi.getOrderLink, sendOrder = realApi.sendOrder, sendSignedOrderCancellation = realApi.sendSignedOrderCancellation, - getPriceQuote = realApi.getPriceQuote, - getFeeQuote = realApi.getFeeQuote, + getQuote = realApi.getQuote, + getPriceQuoteLegacy = realApi.getPriceQuoteLegacy, getOrder = realApi.getOrder, getTrades = realApi.getTrades, // functions that only have a mock diff --git a/src/custom/hooks/useGetGpApiStatus.ts b/src/custom/hooks/useGetGpApiStatus.ts new file mode 100644 index 000000000..2979fe402 --- /dev/null +++ b/src/custom/hooks/useGetGpApiStatus.ts @@ -0,0 +1,39 @@ +import ms from 'ms.macro' +import { useState, useEffect } from 'react' + +export type GpQuoteStatus = 'COWSWAP' | 'LEGACY' +// TODO: use actual API call +export async function checkGpQuoteApiStatus(): Promise { + return new Promise((accept) => setTimeout(() => accept('LEGACY'), 500)) +} +const GP_QUOTE_STATUS_INTERVAL_TIME = ms`2 hours` + +export default function useCheckGpQuoteStatus(defaultApiToUse: GpQuoteStatus): GpQuoteStatus { + const [gpQuoteApiStatus, setGpQuoteApiStatus] = useState(defaultApiToUse) + + useEffect(() => { + console.debug('[useGetQuoteCallback::GP API Status]::', gpQuoteApiStatus) + + const checkStatus = () => { + checkGpQuoteApiStatus() + .then(setGpQuoteApiStatus) + .catch((err: Error) => { + console.error('[useGetQuoteCallback::useEffect] Error getting GP quote status::', err) + // Fallback to LEGACY + setGpQuoteApiStatus('LEGACY') + }) + } + + // Create initial call on mount + checkStatus() + + // set interval for GP_QUOTE_STATUS_INTERVAL_TIME (2 hours) + const intervalId = setInterval(() => { + checkStatus() + }, GP_QUOTE_STATUS_INTERVAL_TIME) + + return () => clearInterval(intervalId) + }, [gpQuoteApiStatus]) + + return gpQuoteApiStatus +} diff --git a/src/custom/hooks/useRefetchPriceCallback.tsx b/src/custom/hooks/useRefetchPriceCallback.tsx index c840c97ea..376ecd005 100644 --- a/src/custom/hooks/useRefetchPriceCallback.tsx +++ b/src/custom/hooks/useRefetchPriceCallback.tsx @@ -20,6 +20,7 @@ import { useQuoteDispatchers } from 'state/price/hooks' import { AddGpUnsupportedTokenParams } from 'state/lists/actions' import { QuoteError } from 'state/price/actions' import { onlyResolvesLast } from 'utils/async' +import useCheckGpQuoteStatus, { GpQuoteStatus } from 'hooks/useGetGpApiStatus' interface HandleQuoteErrorParams { quoteData: QuoteInformationObject | FeeQuoteParams @@ -27,8 +28,6 @@ interface HandleQuoteErrorParams { addUnsupportedToken: (params: AddGpUnsupportedTokenParams) => void } -export const getBestQuoteResolveOnlyLastCall = onlyResolvesLast(getBestQuote) - export function handleQuoteError({ quoteData, error, addUnsupportedToken }: HandleQuoteErrorParams): QuoteError { if (isValidOperatorError(error)) { switch (error.type) { @@ -115,6 +114,8 @@ export function useRefetchQuoteCallback() { const addUnsupportedToken = useAddGpUnsupportedToken() const removeGpUnsupportedToken = useRemoveGpUnsupportedToken() + const gpApiStatus = useCheckGpQuoteStatus((process.env.DEFAULT_GP_API as GpQuoteStatus) || 'COWSWAP') + registerOnWindow({ getNewQuote, refreshQuote, @@ -140,9 +141,11 @@ export function useRefetchQuoteCallback() { getNewQuote(quoteParams) } + const getBestQuoteResolveOnlyLastCall = onlyResolvesLast(getBestQuote) + // Get the quote // price can be null if fee > price - const { cancelled, data } = await getBestQuoteResolveOnlyLastCall(params) + const { cancelled, data } = await getBestQuoteResolveOnlyLastCall({ ...params, apiStatus: gpApiStatus }) if (cancelled) { // Cancellation can happen if a new request is made, then any ongoing query is canceled console.debug('[useRefetchPriceCallback] Canceled get quote price for', params) @@ -204,13 +207,14 @@ export function useRefetchQuoteCallback() { } }, [ + gpApiStatus, isUnsupportedTokenGp, updateQuote, + refreshQuote, + getNewQuote, removeGpUnsupportedToken, - setQuoteError, addUnsupportedToken, - getNewQuote, - refreshQuote, + setQuoteError, ] ) } diff --git a/src/custom/hooks/useSwapCallback.ts b/src/custom/hooks/useSwapCallback.ts index 0864cae78..1319277ed 100644 --- a/src/custom/hooks/useSwapCallback.ts +++ b/src/custom/hooks/useSwapCallback.ts @@ -25,7 +25,7 @@ import { useAppDataHash } from 'state/affiliate/hooks' const MAX_VALID_TO_EPOCH = BigNumber.from('0xFFFFFFFF').toNumber() // Max uint32 (Feb 07 2106 07:28:15 GMT+0100) -function calculateValidTo(deadline: number): number { +export function calculateValidTo(deadline: number): number { // Need the timestamp in seconds const now = Date.now() / 1000 // Must be an integer diff --git a/src/custom/hooks/useUSDCPrice/index.ts b/src/custom/hooks/useUSDCPrice/index.ts index 9844e4df4..2c548eabf 100644 --- a/src/custom/hooks/useUSDCPrice/index.ts +++ b/src/custom/hooks/useUSDCPrice/index.ts @@ -18,7 +18,8 @@ import { tryParseAmount } from 'state/swap/hooks' import { DEFAULT_NETWORK_FOR_LISTS } from 'constants/lists' import { currencyId } from 'utils/currencyId' import { USDC } from 'constants/tokens' -import { useBlockNumber } from '@src/state/application/hooks' +import { useOrderValidTo } from 'state/user/hooks' +import { useBlockNumber } from 'state/application/hooks' export * from '@src/hooks/useUSDCPrice' @@ -38,6 +39,7 @@ export default function useUSDCPrice(currency?: Currency) { const [error, setError] = useState(null) const { chainId, account } = useActiveWeb3React() + const validTo = useOrderValidTo() const blockNumber = useBlockNumber() const amountOut = chainId ? STABLECOIN_AMOUNT_OUT[chainId] : undefined @@ -85,6 +87,7 @@ export default function useUSDCPrice(currency?: Currency) { fromDecimals: currency.decimals, toDecimals: stablecoin.decimals, userAddress: account, + validTo, } if (currency.wrapped.equals(stablecoin)) { @@ -123,7 +126,7 @@ export default function useUSDCPrice(currency?: Currency) { }) }) } - }, [amountOut, chainId, currency, stablecoin, account, blockNumber]) + }, [amountOut, chainId, currency, stablecoin, account, validTo, blockNumber]) return { price: bestUsdPrice, error } } diff --git a/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts b/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts index cb2bc6d28..7e375aafe 100644 --- a/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts +++ b/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts @@ -8,8 +8,10 @@ import { PENDING_ORDERS_PRICE_CHECK_POLL_INTERVAL } from 'state/orders/consts' import { SupportedChainId as ChainId } from 'constants/chains' -import { getBestPrice, PriceInformation } from 'utils/price' +import { getBestQuote, PriceInformation } from 'utils/price' import { isOrderUnfillable } from 'state/orders/utils' +import useCheckGpQuoteStatus, { GpQuoteStatus } from 'hooks/useGetGpApiStatus' +import { getPromiseFulfilledValue } from 'utils/misc' /** * Thin wrapper around `getBestPrice` that builds the params and returns null on failure @@ -17,7 +19,7 @@ import { isOrderUnfillable } from 'state/orders/utils' * @param chainId * @param order */ -async function _getOrderPrice(chainId: ChainId, order: Order) { +async function _getOrderPrice(chainId: ChainId, order: Order, apiStatus: GpQuoteStatus) { let amount, baseToken, quoteToken if (order.kind === 'sell') { @@ -34,14 +36,17 @@ async function _getOrderPrice(chainId: ChainId, order: Order) { chainId, amount, kind: order.kind, + sellToken: order.sellToken, + buyToken: order.buyToken, baseToken, quoteToken, fromDecimals: order.inputToken.decimals, toDecimals: order.outputToken.decimals, + validTo: Date.now() / 1000 + 3000, } try { - return await getBestPrice(quoteParams) + return getBestQuote({ apiStatus, quoteParams, fetchFee: false, isPriceRefresh: false }) } catch (e) { return null } @@ -54,6 +59,7 @@ export function UnfillableOrdersUpdater(): null { const { chainId, account } = useActiveWeb3React() const pending = usePendingOrders({ chainId }) const setIsOrderUnfillable = useSetIsOrderUnfillable() + const gpApiStatus = useCheckGpQuoteStatus((process.env.DEFAULT_GP_API as GpQuoteStatus) || 'LEGACY') // Ref, so we don't rerun useEffect const pendingRef = useRef(pending) @@ -83,15 +89,19 @@ export function UnfillableOrdersUpdater(): null { } pending.forEach((order, index) => - _getOrderPrice(chainId, order).then((price) => { - console.debug( - `[UnfillableOrdersUpdater::updateUnfillable] did we get any price? ${order.id.slice(0, 8)}|${index}`, - price ? price.amount : 'no :(' - ) - price?.amount && updateIsUnfillableFlag(chainId, order, price) + _getOrderPrice(chainId, order, gpApiStatus).then((quote) => { + if (quote) { + const [promisedPrice] = quote + const price = getPromiseFulfilledValue(promisedPrice, null) + console.debug( + `[UnfillableOrdersUpdater::updateUnfillable] did we get any price? ${order.id.slice(0, 8)}|${index}`, + price ? price.amount : 'no :(' + ) + price?.amount && updateIsUnfillableFlag(chainId, order, price) + } }) ) - }, [account, chainId, updateIsUnfillableFlag]) + }, [account, chainId, gpApiStatus, updateIsUnfillableFlag]) useEffect(() => { updatePending() diff --git a/src/custom/state/price/reducer.ts b/src/custom/state/price/reducer.ts index 13bcd47d8..6ab87869b 100644 --- a/src/custom/state/price/reducer.ts +++ b/src/custom/state/price/reducer.ts @@ -62,7 +62,7 @@ export default createReducer(initialState, (builder) => */ .addCase(getNewQuote, (state, action) => { const quoteData = action.payload - const { sellToken, buyToken, fromDecimals, toDecimals, amount, chainId, kind } = quoteData + const { sellToken, buyToken, fromDecimals, toDecimals, amount, chainId, kind, validTo } = quoteData initializeState(state.quotes, action) // Reset quote params @@ -79,6 +79,7 @@ export default createReducer(initialState, (builder) => lastCheck: Date.now(), // Reset price price: getResetPrice(sellToken, buyToken, kind), + validTo, } // Activate loader diff --git a/src/custom/state/price/updater.ts b/src/custom/state/price/updater.ts index 78d89fa95..a1512b71e 100644 --- a/src/custom/state/price/updater.ts +++ b/src/custom/state/price/updater.ts @@ -18,6 +18,7 @@ import { useActiveWeb3React } from 'hooks/web3' import useDebounce from 'hooks/useDebounce' import useIsOnline from 'hooks/useIsOnline' import { QuoteInformationObject } from './reducer' +import { useOrderValidTo } from 'state/user/hooks' const DEBOUNCE_TIME = 350 const REFETCH_CHECK_INTERVAL = 10000 // Every 10s @@ -140,6 +141,7 @@ export default function FeesUpdater(): null { const windowVisible = useIsWindowVisible() const isOnline = useIsOnline() + const validTo = useOrderValidTo() // Update if any parameter is changing useEffect(() => { @@ -164,6 +166,7 @@ export default function FeesUpdater(): null { kind, amount: amount.quotient.toString(), userAddress: account, + validTo, } // Don't refetch if offline. @@ -241,6 +244,7 @@ export default function FeesUpdater(): null { setQuoteError, account, lastUnsupportedCheck, + validTo, ]) return null diff --git a/src/custom/state/user/hooks/index.ts b/src/custom/state/user/hooks/index.ts index c88616602..22c12b866 100644 --- a/src/custom/state/user/hooks/index.ts +++ b/src/custom/state/user/hooks/index.ts @@ -1,6 +1,8 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useAppDispatch } from 'state/hooks' import { toggleURLWarning } from 'state/user/actions' +import { calculateValidTo } from 'hooks/useSwapCallback' +import { useUserTransactionTTL } from '@src/state/user/hooks' export * from '@src/state/user/hooks' @@ -8,3 +10,8 @@ export function useURLWarningToggle(): () => void { const dispatch = useAppDispatch() return useCallback(() => dispatch(toggleURLWarning()), [dispatch]) } + +export function useOrderValidTo(): number { + const [deadline] = useUserTransactionTTL() + return useMemo(() => calculateValidTo(deadline), [deadline]) +} diff --git a/src/custom/utils/price.ts b/src/custom/utils/price.ts index 883fc3a2b..e3af1f2ae 100644 --- a/src/custom/utils/price.ts +++ b/src/custom/utils/price.ts @@ -2,7 +2,7 @@ import { BigNumber } from '@ethersproject/bignumber' import BigNumberJs from 'bignumber.js' import * as Sentry from '@sentry/browser' -import { getFeeQuote, getPriceQuote as getPriceQuoteGp, OrderMetaData } from 'api/gnosisProtocol' +import { getQuote, getPriceQuoteLegacy as getPriceQuoteGp, OrderMetaData } from 'api/gnosisProtocol' import GpQuoteError, { GpQuoteErrorCodes } from 'api/gnosisProtocol/errors/QuoteError' import { getCanonicalMarket, isPromiseFulfilled, withTimeout } from 'utils/misc' import { formatAtoms } from 'utils/format' @@ -15,9 +15,10 @@ import { } from 'api/matcha-0x' import { OptimalRate } from 'paraswap-core' -import { OrderKind } from '@gnosis.pm/gp-v2-contracts' +import { GetQuoteResponse, OrderKind } from '@gnosis.pm/gp-v2-contracts' import { ChainId } from 'state/lists/actions' import { toErc20Address } from 'utils/tokens' +import { GpQuoteStatus } from 'hooks/useGetGpApiStatus' const FEE_EXCEEDS_FROM_ERROR = new GpQuoteError({ errorType: GpQuoteErrorCodes.FeeExceedsFrom, @@ -41,6 +42,19 @@ export interface PriceInformation { amount: string | null } +// GetQuoteResponse from @gnosis.pm/gp-v2-contracts types Timestamp and BigNumberish +// do not play well with our existing methods, using string instead +export type SimpleGetQuoteResponse = Pick & { + // We need to map BigNumberIsh and Timestamp to what we use: string + quote: Omit & { + sellAmount: string + buyAmount: string + validTo: string + feeAmount: string + } + expirationDate: string +} + export class PriceQuoteError extends Error { params: PriceQuoteParams results: PromiseSettledResult[] @@ -58,14 +72,12 @@ export type FeeQuoteParams = Pick & { baseToken: string quoteToken: string - fromDecimals: number - toDecimals: number - userAddress?: string | null } export type PriceSource = 'gnosis-protocol' | 'paraswap' | 'matcha-0x' @@ -237,16 +249,37 @@ export async function getBestPrice(params: PriceQuoteParams, options?: GetBestPr } /** + * getFullQuote + * Queries the new Quote api endpoint found here: https://protocol-mainnet.gnosis.io/api/#/default/post_api_v1_quote + * TODO: consider name // check with backend when logic returns best quote, not first + */ +export async function getFullQuote({ quoteParams }: { quoteParams: FeeQuoteParams }): Promise { + const { kind } = quoteParams + const { quote, expirationDate } = await getQuote(quoteParams) + + const price = { + amount: kind === OrderKind.SELL ? quote.buyAmount : quote.sellAmount, + token: kind === OrderKind.SELL ? quote.buyToken : quote.sellToken, + } + const fee = { + amount: quote.feeAmount, + expirationDate, + } + + return Promise.allSettled([price, fee]) +} + +/** + * (LEGACY) Will be overwritten in the near future * Return the best quote considering all price feeds. The quote contains information about the price and fee */ -export async function getBestQuote({ quoteParams, fetchFee, previousFee }: QuoteParams): Promise { - const { sellToken, buyToken, fromDecimals, toDecimals, amount, kind, chainId, userAddress } = quoteParams +export async function getBestQuoteLegacy({ quoteParams, fetchFee, previousFee }: QuoteParams): Promise { + const { sellToken, buyToken, fromDecimals, toDecimals, amount, kind, chainId, userAddress, validTo } = quoteParams const { baseToken, quoteToken } = getCanonicalMarket({ sellToken, buyToken, kind }) - // Get a new fee quote (if required) const feePromise = fetchFee || !previousFee - ? getFeeQuote({ chainId, sellToken, buyToken, fromDecimals, toDecimals, amount, kind }) + ? getQuote(quoteParams).then((resp) => ({ amount: resp.quote.feeAmount, expirationDate: resp.expirationDate })) : Promise.resolve(previousFee) // Get a new price quote @@ -280,6 +313,7 @@ export async function getBestQuote({ quoteParams, fetchFee, previousFee }: Quote amount: exchangeAmount, kind, userAddress, + validTo, }) : // fee exceeds our price, is invalid Promise.reject(FEE_EXCEEDS_FROM_ERROR) @@ -287,6 +321,29 @@ export async function getBestQuote({ quoteParams, fetchFee, previousFee }: Quote return Promise.allSettled([pricePromise, feePromise]) } +export async function getBestQuote({ + quoteParams, + fetchFee, + previousFee, + apiStatus, +}: QuoteParams & { + apiStatus: GpQuoteStatus +}): Promise { + if (apiStatus === 'COWSWAP') { + return getFullQuote({ quoteParams }).catch((err) => { + console.error( + '[PRICE::API] getBestQuote - error in COWSWAP full quote endpoint, reason: <', + err, + '> - trying back up price sources...' + ) + // ATTEMPT LEGACY CALL + return getBestQuote({ apiStatus: 'LEGACY', quoteParams, fetchFee, previousFee, isPriceRefresh: false }) + }) + } else { + return getBestQuoteLegacy({ quoteParams, fetchFee, previousFee, isPriceRefresh: false }) + } +} + export function getValidParams(params: PriceQuoteParams) { const { baseToken: baseTokenAux, quoteToken: quoteTokenAux, chainId } = params const baseToken = toErc20Address(baseTokenAux, chainId) diff --git a/src/state/swap/hooks.test.ts b/src/state/swap/hooks.test.ts index c34f702ae..39f51c4d2 100644 --- a/src/state/swap/hooks.test.ts +++ b/src/state/swap/hooks.test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment ./custom-test-env.js + */ + import { parse } from 'qs' import { Field } from './actions' import { queryParametersToSwapState } from './hooks' diff --git a/src/utils/computeUniCirculation.test.ts b/src/utils/computeUniCirculation.test.ts index 8d9e4d218..86559f01b 100644 --- a/src/utils/computeUniCirculation.test.ts +++ b/src/utils/computeUniCirculation.test.ts @@ -1,10 +1,14 @@ +/** + * @jest-environment ./custom-test-env.js + */ + import JSBI from 'jsbi' import { Token, CurrencyAmount } from '@uniswap/sdk-core' import { BigNumber } from 'ethers' import { ZERO_ADDRESS } from '../constants/misc' import { computeUniCirculation } from './computeUniCirculation' -describe('computeUniCirculation', () => { +describe.skip('computeUniCirculation', () => { const token = new Token(4, ZERO_ADDRESS, 18) function expandTo18Decimals(num: JSBI | string | number) { diff --git a/yarn.lock b/yarn.lock index c590dcdc4..352469097 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2049,10 +2049,10 @@ "@gnosis.pm/dex-contracts" "^0.5.0" bignumber.js "^9.0.0" -"@gnosis.pm/gp-v2-contracts@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@gnosis.pm/gp-v2-contracts/-/gp-v2-contracts-1.0.2.tgz#bcc1f6a07061df6ab55298d7fe6ee93fcd1025aa" - integrity sha512-bsWB8r8RENvpAGMRfRfENtMU7tPdfa1AtgTidUpxC9f3HREZ2FdiAvAghE3XZwy7BzJ0CQJ2lNl4ANKCZ+kj8Q== +"@gnosis.pm/gp-v2-contracts@ˆ1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@gnosis.pm/gp-v2-contracts/-/gp-v2-contracts-1.1.2.tgz#0453bb097377dc63cf46ee195f7e521d429f7e22" + integrity sha512-BvZMNS+fwITb+qs+trs2fyvYksa6MPjjLze9AOXPnvcKVYFEGwG6cfsecBswEMo+xevLIQNDyF7HZRhN7ply8w== "@gnosis.pm/safe-apps-provider@0.8.0": version "0.8.0"