Skip to content
This repository has been archived by the owner on Jun 24, 2022. It is now read-only.

[1689 - Quote Endpoint] Add new quote getting methods in API and Price (fix types) #1772

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 57 additions & 36 deletions cypress-custom/integration/fee.test.ts
Original file line number Diff line number Diff line change
@@ -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<FeeQuoteParams, 'chainId'>) =>
`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
Expand Down Expand Up @@ -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)
Expand All @@ -74,23 +111,15 @@ 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', () => {
// GIVEN: input token Fee expiration is always 6 hours from now
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)
Expand All @@ -116,36 +145,28 @@ 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)
})
})
})
})

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', () => {
// Stub responses from fee endpoint
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
Expand Down
2 changes: 1 addition & 1 deletion cypress-custom/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 50 additions & 25 deletions src/custom/api/gnosisProtocol/api.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -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<Record<ChainId, string>> {
if (isLocal || isDev || isPr || isBarn) {
Expand Down Expand Up @@ -194,7 +196,10 @@ const UNHANDLED_METADATA_ERROR: MetadataApiErrorObject = {
description: MetadataApiErrorCodeDetails.UNHANDLED_GET_ERROR,
}

async function _handleQuoteResponse(response: Response, params?: FeeQuoteParams) {
async function _handleQuoteResponse<T = any, P extends QuoteQuery = QuoteQuery>(
response: Response,
params?: P
): Promise<T> {
if (!response.ok) {
const errorObj: ApiErrorObject = await response.json()

Expand All @@ -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 } },
})
}

Expand All @@ -224,7 +229,45 @@ async function _handleQuoteResponse(response: Response, params?: FeeQuoteParams)
}
}

export async function getPriceQuote(params: PriceQuoteParams): Promise<PriceInformation | null> {
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<SimpleGetQuoteResponse>(response)
}

export async function getPriceQuoteLegacy(params: PriceQuoteParams): Promise<PriceInformation | null> {
const { baseToken, quoteToken, amount, kind, chainId } = params
console.log(`[api:${API_NAME}] Get price from API`, params)

Expand All @@ -240,25 +283,7 @@ export async function getPriceQuote(params: PriceQuoteParams): Promise<PriceInfo
throw new QuoteError(UNHANDLED_QUOTE_ERROR)
})

return _handleQuoteResponse(response)
}

export async function getFeeQuote(params: FeeQuoteParams): Promise<FeeInformation> {
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<PriceInformation | null>(response)
}

export async function getOrder(chainId: ChainId, orderId: string): Promise<OrderMetaData | null> {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/custom/api/gnosisProtocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
2 changes: 1 addition & 1 deletion src/custom/hooks/useGetGpApiStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
export type GpQuoteStatus = 'COWSWAP' | 'LEGACY'
// TODO: use actual API call
export async function checkGpQuoteApiStatus(): Promise<GpQuoteStatus> {
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`

Expand Down
16 changes: 10 additions & 6 deletions src/custom/hooks/useRefetchPriceCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ 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
error: unknown
addUnsupportedToken: (params: AddGpUnsupportedTokenParams) => void
}

export const getBestQuoteResolveOnlyLastCall = onlyResolvesLast<QuoteResult>(getBestQuote)

export function handleQuoteError({ quoteData, error, addUnsupportedToken }: HandleQuoteErrorParams): QuoteError {
if (isValidOperatorError(error)) {
switch (error.type) {
Expand Down Expand Up @@ -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,
Expand All @@ -140,9 +141,11 @@ export function useRefetchQuoteCallback() {
getNewQuote(quoteParams)
}

const getBestQuoteResolveOnlyLastCall = onlyResolvesLast<QuoteResult>(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)
Expand Down Expand Up @@ -204,13 +207,14 @@ export function useRefetchQuoteCallback() {
}
},
[
gpApiStatus,
isUnsupportedTokenGp,
updateQuote,
refreshQuote,
getNewQuote,
removeGpUnsupportedToken,
setQuoteError,
addUnsupportedToken,
getNewQuote,
refreshQuote,
setQuoteError,
]
)
}
Loading