Skip to content

Commit

Permalink
[1689 - Quote endpoint] useOrderValidTo hook (gnosis/cowswap#1728)
Browse files Browse the repository at this point in the history
* 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 (gnosis/cowswap#1758)

* update Error objects to include new fee > sell error

* [1689 - Quote endpoint] update gp v2 contracts 1.1.2 & set env check (gnosis/cowswap#1759)

* add default Gp_Api_Status to env

* update to 1.1.2 gp-v2-contracts

yarn lock [email protected]

* [1689 - Quote Endpoint] Add new quote getting methods in API and Price (fix types) (gnosis/cowswap#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 (gnosis/cowswap#1773)

* change getPriceQuote and getFeeQuote

- getQuote - one endpoint

* [1689 - Quote endpoint] Fix Cypress Fee test (gnosis/cowswap#1775)

* setup fee test for new endpoint

* add mock quote data and fix command for stubs
  • Loading branch information
W3stside authored Nov 9, 2021
1 parent 4cdd960 commit 9e24e44
Show file tree
Hide file tree
Showing 19 changed files with 244 additions and 99 deletions.
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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",
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
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 @@ -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
Loading

0 comments on commit 9e24e44

Please sign in to comment.