From a03bca907d804e239bb481f0c1763a11f4c23358 Mon Sep 17 00:00:00 2001 From: David W3stside Date: Fri, 29 Oct 2021 10:12:54 +0100 Subject: [PATCH 1/3] useGetGpApiStatus hook (mocked) 1. checks api to use 2. mocked right now --- src/custom/hooks/useGetGpApiStatus.ts | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/custom/hooks/useGetGpApiStatus.ts diff --git a/src/custom/hooks/useGetGpApiStatus.ts b/src/custom/hooks/useGetGpApiStatus.ts new file mode 100644 index 000000000..48a446f9e --- /dev/null +++ b/src/custom/hooks/useGetGpApiStatus.ts @@ -0,0 +1,39 @@ +import ms from 'ms.macro' +import { useState, useEffect } from 'react' + +type GpQuoteStatus = 'COWSWAP' | 'LEGACY' +// TODO: use actual API call +export async function checkGpQuoteApiStatus(): Promise { + return new Promise((accept) => setTimeout(() => accept('COWSWAP'), 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 +} From 7adcf0829b615be21b1ee33b33f81a9679c552d2 Mon Sep 17 00:00:00 2001 From: David W3stside Date: Tue, 2 Nov 2021 11:42:23 +0000 Subject: [PATCH 2/3] export type --- src/custom/hooks/useGetGpApiStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/custom/hooks/useGetGpApiStatus.ts b/src/custom/hooks/useGetGpApiStatus.ts index 48a446f9e..098da835a 100644 --- a/src/custom/hooks/useGetGpApiStatus.ts +++ b/src/custom/hooks/useGetGpApiStatus.ts @@ -1,7 +1,7 @@ import ms from 'ms.macro' import { useState, useEffect } from 'react' -type GpQuoteStatus = 'COWSWAP' | 'LEGACY' +export type GpQuoteStatus = 'COWSWAP' | 'LEGACY' // TODO: use actual API call export async function checkGpQuoteApiStatus(): Promise { return new Promise((accept) => setTimeout(() => accept('COWSWAP'), 500)) From e621c37a4765f458b3c341dea0e2b764065a2be8 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 9 Nov 2021 15:14:03 +0000 Subject: [PATCH 3/3] [1689 - Quote endpoint] useOrderValidTo hook (#1728) * useOrderValidTo hook - syntactic sugar for getting validTo time (unix u32) from deadline TTL * [1689 - Quote endpoint] update Error objects to include new fee > sell error (#1758) * update Error objects to include new fee > sell error * [1689 - Quote endpoint] update gp v2 contracts 1.1.2 & set env check (#1759) * add default Gp_Api_Status to env * update to 1.1.2 gp-v2-contracts yarn lock gp-v2-contracts@1.1.2 * [1689 - Quote Endpoint] Add new quote getting methods in API and Price (fix types) (#1772) * utils/price: add new quote logic * gnosisProtocol API - add new quote logic 1. map old params to new 2. add post logic * add legacy/new to index * use gpStatus and new api method in useRefetch and Unfillable * set mock to LEGACY (temp) * add validTo to necessary places * [1689 - Quote Endpoint] getQuote > replace getFeeQuote and getPriceQuote (#1773) * change getPriceQuote and getFeeQuote - getQuote - one endpoint * [1689 - Quote endpoint] Fix Cypress Fee test (#1775) * setup fee test for new endpoint * add mock quote data and fix command for stubs --- .env | 3 + .env.production | 3 + cypress-custom/integration/fee.test.ts | 93 ++++++++++++------- cypress-custom/support/commands.js | 2 +- package.json | 2 +- src/custom/api/gnosisProtocol/api.ts | 75 ++++++++++----- .../gnosisProtocol/errors/OperatorError.ts | 2 + .../api/gnosisProtocol/errors/QuoteError.ts | 5 + src/custom/api/gnosisProtocol/index.ts | 4 +- src/custom/hooks/useGetGpApiStatus.ts | 2 +- src/custom/hooks/useRefetchPriceCallback.tsx | 16 ++-- src/custom/hooks/useSwapCallback.ts | 2 +- src/custom/hooks/useUSDCPrice/index.ts | 5 +- .../updaters/UnfillableOrdersUpdater.ts | 30 ++++-- src/custom/state/price/reducer.ts | 3 +- src/custom/state/price/updater.ts | 4 + src/custom/state/user/hooks/index.ts | 9 +- src/custom/utils/price.ts | 75 +++++++++++++-- yarn.lock | 8 +- 19 files changed, 244 insertions(+), 99 deletions(-) diff --git a/.env b/.env index 9c84080e7..b6f0cf2c8 100644 --- a/.env +++ b/.env @@ -69,3 +69,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 b80044a85..19f83f8e0 100644 --- a/.env.production +++ b/.env.production @@ -69,3 +69,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 97f5a2039..95f686a4a 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,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 39ce59f27..a90f358b3 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 { getSigningSchemeApiValue, OrderCreation, OrderCancellation, SigningSchemeValue } from 'utils/signatures' import { APP_DATA_HASH } from 'constants/index' import { registerOnWindow } from 'utils/misc' @@ -16,7 +16,7 @@ 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 { AppDataDoc } from 'utils/metadata' import MetadataError, { MetadataApiErrorCodeDetails, @@ -27,6 +27,8 @@ import MetadataError, { 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) { @@ -194,7 +196,10 @@ const UNHANDLED_METADATA_ERROR: MetadataApiErrorObject = { description: MetadataApiErrorCodeDetails.UNHANDLED_GET_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() @@ -214,7 +219,7 @@ async function _handleQuoteResponse(response: Response, params?: FeeQuoteParams) // report to sentry Sentry.captureException(sentryError, { tags: { errorType: 'getFeeQuote' }, - contexts: { params }, + contexts: { params: { ...params } }, }) } @@ -224,7 +229,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) @@ -240,25 +283,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 { @@ -381,7 +406,7 @@ export async function getGasPrices(chainId: ChainId = DEFAULT_NETWORK_FOR_LISTS) // Register some globals for convenience registerOnWindow({ operator: { - getFeeQuote, + getQuote, getAppDataDoc, getOrder, sendSignedOrder: sendOrder, 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 c1026d938..70c63b91f 100644 --- a/src/custom/api/gnosisProtocol/index.ts +++ b/src/custom/api/gnosisProtocol/index.ts @@ -17,8 +17,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, // functions that only have a mock } = useMock ? { ...mockApi } : { ...realApi } diff --git a/src/custom/hooks/useGetGpApiStatus.ts b/src/custom/hooks/useGetGpApiStatus.ts index 098da835a..2979fe402 100644 --- a/src/custom/hooks/useGetGpApiStatus.ts +++ b/src/custom/hooks/useGetGpApiStatus.ts @@ -4,7 +4,7 @@ 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('COWSWAP'), 500)) + return new Promise((accept) => setTimeout(() => accept('LEGACY'), 500)) } const GP_QUOTE_STATUS_INTERVAL_TIME = ms`2 hours` 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 b4f6af121..eb0cdc538 100644 --- a/src/custom/hooks/useSwapCallback.ts +++ b/src/custom/hooks/useSwapCallback.ts @@ -24,7 +24,7 @@ import { Web3Provider } from '@ethersproject/providers' 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 2ba3ab1f4..dc0737cb1 100644 --- a/src/custom/hooks/useUSDCPrice/index.ts +++ b/src/custom/hooks/useUSDCPrice/index.ts @@ -18,6 +18,7 @@ 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 { useOrderValidTo } from 'state/user/hooks' export * from '@src/hooks/useUSDCPrice' @@ -37,6 +38,7 @@ export default function useUSDCPrice(currency?: Currency) { const [error, setError] = useState(null) const { chainId, account } = useActiveWeb3React() + const validTo = useOrderValidTo() const amountOut = chainId ? STABLECOIN_AMOUNT_OUT[chainId] : undefined const stablecoin = amountOut?.currency @@ -83,6 +85,7 @@ export default function useUSDCPrice(currency?: Currency) { fromDecimals: currency.decimals, toDecimals: stablecoin.decimals, userAddress: account, + validTo, } if (currency.wrapped.equals(stablecoin)) { @@ -121,7 +124,7 @@ export default function useUSDCPrice(currency?: Currency) { }) }) } - }, [amountOut, chainId, currency, stablecoin, account]) + }, [amountOut, chainId, currency, stablecoin, account, validTo]) return { price: bestUsdPrice, error } } diff --git a/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts b/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts index 995a55e0a..cc8275b92 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 } = 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) @@ -75,15 +81,19 @@ export function UnfillableOrdersUpdater(): null { } pendingRef.current.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) + } }) ) - }, [chainId, updateIsUnfillableFlag]) + }, [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/yarn.lock b/yarn.lock index 706735282..60b2f05f6 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"