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

[1689 - Quote endpoint] New Quote endpoint for price & fee #1835

Merged
merged 27 commits into from
Dec 16, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1a1f748
[1689 - Quote endpoint] Consolidate waterfall PRs:
W3stside Nov 15, 2021
cce8f8e
fix package name and update yarn
W3stside Nov 15, 2021
6050397
add receiver to quote params
W3stside Nov 16, 2021
bf38a0e
better GP API default
W3stside Nov 16, 2021
9b25295
removed unused import
W3stside Nov 16, 2021
8e5800b
rename gpQuoteStatus to gpPriceStrategy
W3stside Nov 16, 2021
7890cb5
comments
W3stside Nov 16, 2021
bf0cc66
comment
W3stside Nov 16, 2021
885315c
pass new params to useQuoteAndSwap/useFallbackPi
W3stside Dec 7, 2021
c104eff
better types/param name
W3stside Dec 7, 2021
abf291b
round validTo UP @alfetopito
W3stside Dec 7, 2021
27c93a6
get correct ETH address
W3stside Dec 7, 2021
dda3e9f
create validTo adjuster. return more from useOrderValidTo
W3stside Dec 9, 2021
27bb626
MINIMUM_VALID_TO = 120
W3stside Dec 13, 2021
25b0439
paths, fixed type, and renaming stuff
W3stside Dec 13, 2021
8b90b2b
set the validTo in the callback
W3stside Dec 13, 2021
3bc870c
export resolveLast best quote call
W3stside Dec 14, 2021
ea35c94
use resolveLast for quote promise
W3stside Dec 14, 2021
96a3268
add manual price setting for dev
W3stside Dec 14, 2021
9bdc639
logs
W3stside Dec 14, 2021
46fd450
pass order validTo thanks !@alfetopito
W3stside Dec 14, 2021
eb33b36
timestamp
W3stside Dec 14, 2021
69f4ba0
add param to stubResponse cypress
W3stside Dec 15, 2021
8fa4c9f
fix incorrect version name
W3stside Dec 15, 2021
08dda9f
yarn lock update
W3stside Dec 15, 2021
9de4f57
Bumpbing bignumber.js and dex-js to the same version
Dec 15, 2021
7c07e9d
Removed bignumber.js from dependency as it's only used on dex-js
Dec 15, 2021
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
96 changes: 59 additions & 37 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,27 +111,19 @@ 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)
cy.stubResponse({ url: FEE_QUERY, alias: 'feeRequest', body: LATER_FEE })
cy.stubResponse({ method: 'POST', url: FEE_QUERY, alias: 'feeRequest', body: LATER_FEE })

// GIVEN: user visits app, selects 0.1 WETH as sell, DAI as buy
// and goes AFK
Expand All @@ -116,36 +145,29 @@ 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({
method: 'POST',
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
12 changes: 11 additions & 1 deletion cypress-custom/support/commands.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ declare namespace Cypress {
*
* @example cy.stubResponse({ url: '/api/v1/someEndpoint/', alias: 'endpoint', body: { foo: 'foo' } })
*/
stubResponse({ url, alias, body }: { url: string; alias?: string; body?: any }): Chainable<Subject>
stubResponse({
method,
url,
alias,
body,
}: {
method: 'GET' | 'POST' | 'DELETE'
url: string
alias?: string
body?: any
}): Chainable<Subject>
}
}
4 changes: 2 additions & 2 deletions cypress-custom/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ function enterOutputAmount(tokenAddress, amount, selectToken = false) {
cy.get('#swap-currency-input .token-amount-output').type(amount.toString(), { force: true, delay: 400 })
}

function stubResponse({ url, alias = 'stubbedResponse', body }) {
cy.intercept({ method: 'GET', url }, _responseHandlerFactory(body)).as(alias)
function stubResponse({ method, url, alias = 'stubbedResponse', body }) {
cy.intercept({ method, 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 @@ -194,7 +194,7 @@
"dependencies": {
"@gnosis.pm/cow-runner-game": "^0.2.9",
"@gnosis.pm/dex-js": "^0.13.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 'constants/misc'
import { getAppDataHash } from 'constants/appDataHash'

function getGnosisProtocolUrl(): Partial<Record<ChainId, string>> {
if (isLocal || isDev || isPr || isBarn) {
Expand Down Expand Up @@ -247,7 +249,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 @@ -267,7 +272,7 @@ async function _handleQuoteResponse(response: Response, params?: FeeQuoteParams)
// report to sentry
Sentry.captureException(sentryError, {
tags: { errorType: 'getFeeQuote' },
contexts: { params },
contexts: { params: { ...params } },
})
}

Expand All @@ -277,7 +282,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, receiver, validTo, sellToken, buyToken, chainId } = params
const fallbackAddress = userAddress || ZERO_ADDRESS

const baseParams = {
sellToken: toErc20Address(sellToken, chainId),
buyToken: toErc20Address(buyToken, chainId),
from: fallbackAddress,
receiver: receiver || fallbackAddress,
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 @@ -293,25 +336,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 @@ -418,5 +443,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,
},
})
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 @@ -43,6 +43,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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { darken } from 'polished'
import { useSetUserSlippageTolerance, useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks'
import { L2_CHAIN_IDS } from 'constants/chains'
import { useActiveWeb3React } from 'hooks/web3'
import { INPUT_OUTPUT_EXPLANATION } from 'constants/index'
import { INPUT_OUTPUT_EXPLANATION, MINIMUM_ORDER_VALID_TO_TIME_SECONDS } from 'constants/index'

enum SlippageError {
InvalidInput = 'InvalidInput',
Expand Down Expand Up @@ -142,7 +142,7 @@ export default function TransactionSettings({ placeholderSlippage }: Transaction
} else {
try {
const parsed: number = Math.floor(Number.parseFloat(value) * 60)
if (!Number.isInteger(parsed) || parsed < 60 || parsed > 180 * 60) {
if (!Number.isInteger(parsed) || parsed < MINIMUM_ORDER_VALID_TO_TIME_SECONDS || parsed > 180 * 60) {
setDeadlineError(DeadlineError.InvalidInput)
} else {
setDeadline(parsed)
Expand Down
6 changes: 6 additions & 0 deletions src/custom/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const PENDING_ORDERS_BUFFER = 60 * 1000 // 60s
export const CANCELLED_ORDERS_PENDING_TIME = 5 * 60 * 1000 // 5min
export const PRICE_API_TIMEOUT_MS = 10000 // 10s
export const GP_ORDER_UPDATE_INTERVAL = 30 * 1000 // 30s
export const MINIMUM_ORDER_VALID_TO_TIME_SECONDS = 120

export const WETH_LOGO_URI =
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png'
Expand Down Expand Up @@ -112,3 +113,8 @@ export const AMOUNT_OF_ORDERS_TO_FETCH = 100

// last wallet provider key used in local storage
export const STORAGE_KEY_LAST_PROVIDER = 'lastProvider'

// Default price strategy to use for getting app prices
// COWSWAP = new quote endpoint
// LEGACY = price racing logic (checking 0x, gp, paraswap, etc)
export const DEFAULT_GP_PRICE_STRATEGY = 'LEGACY'
Loading