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

[1689 - Quote endpoint] useGetGpApiStatus hook (mocked) #1727

Merged
merged 4 commits into from
Nov 15, 2021
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
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 57 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 { stringify } from 'qs'
import { getSigningSchemeApiValue, OrderCreation, OrderCancellation, SigningSchemeValue } from 'utils/signatures'
import { APP_DATA_HASH } from 'constants/index'
Expand All @@ -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<Record<ChainId, string>> {
if (isLocal || isDev || isPr || isBarn) {
Expand Down Expand Up @@ -246,7 +248,10 @@ const UNHANDLED_ORDER_ERROR: ApiErrorObject = {
description: ApiErrorCodeDetails.UNHANDLED_CREATE_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 @@ -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 } },
})
}

Expand All @@ -276,7 +281,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 @@ -292,25 +335,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 @@ -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,
},
})
2 changes: 2 additions & 0 deletions src/custom/api/gnosisProtocol/errors/OperatorError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum ApiErrorCodes {
InsufficientFunds = 'InsufficientFunds',
InsufficientFee = 'InsufficientFee',
UnsupportedToken = 'UnsupportedToken',
SellAmountDoesNotCoverFee = 'SellAmountDoesNotCoverFee',
WrongOwner = 'WrongOwner',
NotFound = 'NotFound',
OrderNotFound = 'OrderNotFound',
Expand All @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/custom/api/gnosisProtocol/errors/QuoteError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
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 @@ -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
Expand Down
Loading