From 05f90276d280ca7a84eea1459b887b04463d27ec Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 8 Jun 2021 00:28:35 -0700 Subject: [PATCH 01/23] Add trade quote utility --- package.json | 4 + src/Set.ts | 9 + src/api/index.ts | 2 + src/api/utils/index.ts | 1 + src/api/utils/tradeQuote/coingecko.ts | 237 +++++++++++++++ src/api/utils/tradeQuote/gasOracle.ts | 88 ++++++ src/api/utils/tradeQuote/index.ts | 4 + src/api/utils/tradeQuote/tradequote.ts | 404 +++++++++++++++++++++++++ src/api/utils/tradeQuote/zeroex.ts | 121 ++++++++ src/assertions/CommonAssertions.ts | 8 + src/types/common.ts | 1 + src/types/index.ts | 1 + src/types/utils.ts | 129 ++++++++ test/api/TradeQuoteAPI.spec.ts | 113 +++++++ test/fixtures/tradeQuote.ts | 119 ++++++++ yarn.lock | 29 ++ 16 files changed, 1270 insertions(+) create mode 100644 src/api/utils/index.ts create mode 100644 src/api/utils/tradeQuote/coingecko.ts create mode 100644 src/api/utils/tradeQuote/gasOracle.ts create mode 100644 src/api/utils/tradeQuote/index.ts create mode 100644 src/api/utils/tradeQuote/tradequote.ts create mode 100644 src/api/utils/tradeQuote/zeroex.ts create mode 100644 src/types/utils.ts create mode 100644 test/api/TradeQuoteAPI.spec.ts create mode 100644 test/fixtures/tradeQuote.ts diff --git a/package.json b/package.json index 52cc5602..ab8e225e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build-ts": "tsc -p tsconfig.json", "build-dist": "tsc -p tsconfig.dist.json", "test": "jest --runInBand", + "test:verbose": "jest --runInBand --silent=false", "test:watch": "jest --watch --runInBand", "tslint": "tslint -c tslint.json -p tsconfig.json", "precommit": "lint-staged", @@ -63,11 +64,14 @@ "@types/jest": "^26.0.5", "@types/web3": "^1.2.2", "abi-decoder": "^2.3.0", + "axios": "^0.21.1", "bignumber.js": "^9.0.0", "dotenv": "^8.2.0", "ethereum-types": "^3.2.0", "ethereumjs-util": "^7.0.3", "ethers": "^5.0.3", + "graph-results-pager": "^1.0.3", + "js-big-decimal": "^1.3.4", "jsonschema": "^1.2.6", "lodash": "^4.17.19", "truffle": "^5.1.35", diff --git a/src/Set.ts b/src/Set.ts index f45c9f42..c982e813 100644 --- a/src/Set.ts +++ b/src/Set.ts @@ -29,6 +29,7 @@ import { NavIssuanceAPI, PriceOracleAPI, DebtIssuanceAPI, + TradeQuoteAPI, } from './api/index'; const ethersProviders = require('ethers').providers; @@ -102,6 +103,13 @@ class Set { */ public blockchain: BlockchainAPI; + + /** + * An instance of the TradeQuoteAPI class. Contains interfaces for + * getting a trade quote from 0x exchange API on Ethereum or Polygon networks + */ + public tradeQuote: TradeQuoteAPI; + /** * Instantiates a new Set instance that provides the public interface to the Set.js library */ @@ -128,6 +136,7 @@ class Set { this.priceOracle = new PriceOracleAPI(ethersProvider, config.masterOracleAddress); this.debtIssuance = new DebtIssuanceAPI(ethersProvider, config.debtIssuanceModuleAddress); this.blockchain = new BlockchainAPI(ethersProvider, assertions); + this.tradeQuote = new TradeQuoteAPI(this.setToken, config.zeroExApiKey); } } diff --git a/src/api/index.ts b/src/api/index.ts index 7be5285f..57153097 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,6 +8,7 @@ import TradeAPI from './TradeAPI'; import NavIssuanceAPI from './NavIssuanceAPI'; import PriceOracleAPI from './PriceOracleAPI'; import DebtIssuanceAPI from './DebtIssuanceAPI'; +import { TradeQuoteAPI } from './utils'; export { BlockchainAPI, @@ -20,4 +21,5 @@ export { NavIssuanceAPI, PriceOracleAPI, DebtIssuanceAPI, + TradeQuoteAPI }; \ No newline at end of file diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts new file mode 100644 index 00000000..318b8e05 --- /dev/null +++ b/src/api/utils/index.ts @@ -0,0 +1 @@ +export { TradeQuoteAPI } from './tradeQuote'; diff --git a/src/api/utils/tradeQuote/coingecko.ts b/src/api/utils/tradeQuote/coingecko.ts new file mode 100644 index 00000000..85a232f0 --- /dev/null +++ b/src/api/utils/tradeQuote/coingecko.ts @@ -0,0 +1,237 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +const pageResults = require('graph-results-pager'); + +import axios from 'axios'; +import Assertions from '../../../assertions'; + +import { + CoinGeckoCoinPrices, + CoinGeckoTokenData, + SushiswapTokenData, + CoinGeckoTokenMap, + CoinPricesParams, + PolygonMappedTokenData +} from '../../../types'; + +/** + * These currency codes can be used for the vs_currencies parameter of the service's + * fetchCoinPrices method + * + * @type {number} + */ +export const USD_CURRENCY_CODE = 'usd'; +export const ETH_CURRENCY_CODE = 'eth'; + +/** + * @title CoinGeckoDataService + * @author Set Protocol + * + * A utility library for fetching token metadata and coin prices from Coingecko for Ethereum + * and Polygon chains + */ +export class CoinGeckoDataService { + chainId: number; + private tokenList: CoinGeckoTokenData[] | undefined; + private tokenMap: CoinGeckoTokenMap | undefined; + private assert: Assertions; + + constructor(chainId: number) { + this.assert = new Assertions(); + this.assert.common.isSupportedChainId(chainId); + this.chainId = chainId; + } + + /** + * Gets address-to-price map of token prices for a set of token addresses and currencies + * + * @param params CoinPricesParams: token addresses and currency codes + * @return CoinGeckoCoinPrices: Address to price map + */ + async fetchCoinPrices(params: CoinPricesParams): Promise { + const platform = this.getPlatform(); + const endpoint = `https://api.coingecko.com/api/v3/simple/token_price/${platform}?`; + const contractAddressParams = `contract_addresses=${params.contractAddresses.join(',')}`; + const vsCurrenciesParams = `vs_currencies=${params.vsCurrencies.join(',')}`; + const url = `${endpoint}${contractAddressParams}&${vsCurrenciesParams}`; + + const response = await axios.get(url); + return response.data; + } + + /** + * Gets a list of available tokens and their metadata for chain. If Ethereum, the list + * is sourced from Uniswap. If Polygon the list is sourced from Sushiswap with image assets + * derived from multiple sources including CoinGecko + * + * @return CoinGeckoTokenData: array of token data + */ + async fetchTokenList(): Promise { + if (this.tokenList !== undefined) return this.tokenList; + + switch (this.chainId) { + case 1: + this.tokenList = await this.fetchEthereumTokenList(); + break; + case 137: + this.tokenList = await this.fetchPolygonTokenList(); + break; + } + this.tokenMap = this.convertTokenListToAddressMap(this.tokenList); + + return this.tokenList!; + } + + /** + * Gets a token list (see above) formatted as an address indexed map + * + * @return CoinGeckoTokenMap: map of token addresses to token metadata + */ + async fetchTokenMap(): Promise { + if (this.tokenMap !== undefined) return this.tokenMap; + + this.tokenList = await this.fetchTokenList(); + this.tokenMap = this.convertTokenListToAddressMap(this.tokenList); + + return this.tokenMap; + } + + private async fetchEthereumTokenList(): Promise { + const url = 'https://tokens.coingecko.com/uniswap/all.json'; + const response = await axios.get(url); + return response.data.tokens; + } + + private async fetchPolygonTokenList(): Promise { + const coingeckoEthereumTokens = await this.fetchEthereumTokenList(); + const polygonMappedTokens = await this.fetchPolygonMappedTokenList(); + const sushiPolygonTokenList = await this.fetchSushiPolygonTokenList(); + const quickswapPolygonTokenList = await this.fetchQuickswapPolygonTokenList(); + + for (const token of sushiPolygonTokenList) { + const quickswapToken = quickswapPolygonTokenList.find(t => t.address.toLowerCase() === token.address); + + if (quickswapToken) { + token.logoURI = quickswapToken.logoURI; + continue; + } + + const ethereumAddress = polygonMappedTokens[token.address]; + + if (ethereumAddress !== undefined) { + const ethereumToken = coingeckoEthereumTokens.find(t => t.address.toLowerCase() === ethereumAddress); + + if (ethereumToken) { + token.logoURI = ethereumToken.logoURI; + } + } + } + + return sushiPolygonTokenList; + } + + private async fetchSushiPolygonTokenList() { + let tokens: SushiswapTokenData[] = []; + const url = 'https://api.thegraph.com/subgraphs/name/sushiswap/matic-exchange'; + const properties = [ + 'id', + 'symbol', + 'name', + 'decimals', + 'volumeUSD', + ]; + + const response = await pageResults({ + api: url, + query: { + entity: 'tokens', + properties: properties, + }, + }); + + for (const token of response) { + tokens.push({ + chainId: 137, + address: token.id, + symbol: token.symbol, + name: token.name, + decimals: parseInt(token.decimals), + volumeUSD: parseFloat(token.volumeUSD), + }); + } + + // Sort by volume and filter out untraded tokens + tokens.sort((a, b) => b.volumeUSD - a.volumeUSD); + tokens = tokens.filter(t => t.volumeUSD > 0); + + return tokens; + } + + private async fetchPolygonMappedTokenList(): Promise { + let offset = 0; + const tokens: PolygonMappedTokenData = {}; + + const url = 'https://tokenmapper.api.matic.today/api/v1/mapping?'; + const params = 'map_type=[%22POS%22]&chain_id=137&limit=200&offset='; + + while (true) { + const response = await axios.get(`${url}${params}${offset}`); + + if (response.data.message === 'success') { + for (const token of response.data.data.mapping) { + tokens[token.child_token.toLowerCase()] = token.root_token.toLowerCase(); + } + + if (response.data.data.has_next_page === true) { + offset += 200; + continue; + } + } + break; + } + + return tokens; + } + + private async fetchQuickswapPolygonTokenList(): Promise { + const url = 'https://raw.githubusercontent.com/sameepsi/' + + 'quickswap-default-token-list/master/src/tokens/mainnet.json'; + + const data = (await axios.get(url)).data; + return data; + } + + private convertTokenListToAddressMap(list: CoinGeckoTokenData[] = []): CoinGeckoTokenMap { + const tokenMap: CoinGeckoTokenMap = {}; + + for (const entry of list) { + tokenMap[entry.address] = Object.assign({}, entry); + } + + return tokenMap; + } + + private getPlatform(): string { + switch (this.chainId) { + case 1: return 'ethereum'; + case 137: return 'polygon-pos'; + default: return ''; + } + } +} diff --git a/src/api/utils/tradeQuote/gasOracle.ts b/src/api/utils/tradeQuote/gasOracle.ts new file mode 100644 index 00000000..4720cfe1 --- /dev/null +++ b/src/api/utils/tradeQuote/gasOracle.ts @@ -0,0 +1,88 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +'use strict'; + +import axios from 'axios'; +import Assertions from '../../../assertions'; + +import { + EthGasStationData, + GasOracleSpeed, +} from '../../../types'; + +/** + * @title GasOracleService + * @author Set Protocol + * + * A utility library for fetching current gas prices by speed for Ethereum and Polygon chains + */ +export class GasOracleService { + chainId: number; + private assert: Assertions; + + static AVERAGE: GasOracleSpeed = 'average'; + static FAST: GasOracleSpeed = 'fast'; + static FASTEST: GasOracleSpeed = 'fastest'; + + constructor(chainId: number) { + this.assert = new Assertions(); + this.assert.common.isSupportedChainId(chainId); + this.chainId = chainId; + } + + /** + * Returns current gas price estimate for one of 'average', 'fast', 'fastest' speeds. + * Default speed is 'fast' + * + * @param speed speed at which tx hopes to be mined / validated by platform + * @return gas price to use + */ + async fetchGasPrice(speed: GasOracleSpeed = 'fast'): Promise { + this.assert.common.includes(['average', 'fast', 'fastest'], speed, 'Unsupported speed'); + + switch (this.chainId) { + case 1: return this.getEthereumGasPrice(speed); + case 137: return this.getPolygonGasPrice(speed); + + // This case should never run because chainId is validated + // Needed to stop TS complaints about return sig + default: return 0; + } + } + + private async getEthereumGasPrice(speed: GasOracleSpeed): Promise { + const url = 'https://ethgasstation.info/json/ethgasAPI.json'; + const data: EthGasStationData = (await axios.get(url)).data; + + // EthGasStation returns gas price in x10 Gwei (divite by 10 to convert it to gwei) + switch (speed) { + case GasOracleService.AVERAGE: return data.average / 10; + case GasOracleService.FAST: return data.fast / 10; + case GasOracleService.FASTEST: return data.fastest / 10; + } + } + + private async getPolygonGasPrice(speed: GasOracleSpeed): Promise { + const url = 'https://gasstation-mainnet.matic.network'; + const data = (await axios.get(url)).data; + + switch (speed) { + case GasOracleService.AVERAGE: return data.standard; + case GasOracleService.FAST: return data.fast; + case GasOracleService.FASTEST: return data.fastest; + } + } +} diff --git a/src/api/utils/tradeQuote/index.ts b/src/api/utils/tradeQuote/index.ts new file mode 100644 index 00000000..4f02f9cd --- /dev/null +++ b/src/api/utils/tradeQuote/index.ts @@ -0,0 +1,4 @@ +export * from './tradequote'; +export * from './coingecko'; +export * from './gasOracle'; +export * from './zeroex'; diff --git a/src/api/utils/tradeQuote/tradequote.ts b/src/api/utils/tradeQuote/tradequote.ts new file mode 100644 index 00000000..05e75800 --- /dev/null +++ b/src/api/utils/tradeQuote/tradequote.ts @@ -0,0 +1,404 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +import { BigNumber, FixedNumber, utils as ethersUtils } from 'ethers'; +import BigDecimal from 'js-big-decimal'; + +import { + CoinGeckoCoinPrices, + CoinGeckoTokenMap, + QuoteOptions, + TradeQuote, + TokenResponse, +} from '../../../types/index'; + +import SetTokenAPI from '../../SetTokenAPI'; + +import { + CoinGeckoDataService, + USD_CURRENCY_CODE +} from './coingecko'; + +import { GasOracleService } from './gasOracle'; +import { ZeroExTradeQuoter } from './zeroex'; + +export const ZERO_EX_ADAPTER_NAME = 'ZeroExApiAdapterV3'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; + +const SCALE = BigNumber.from(10).pow(18); + +/** + * @title TradeQuoteAPI + * @author Set Protocol + * + * A utility library to generate trade quotes for token pairs associated with a + * set for Ethereum and Polygon chains. Uses 0xAPI to get quote and requires a valid + * 0x api key. + */ + +export class TradeQuoteAPI { + private setToken: SetTokenAPI; + private tokenMap: CoinGeckoTokenMap; + private largeTradeGasCostBase: number = 150000; + private tradeQuoteGasBuffer: number = 5; + private zeroExApiKey: string; + + constructor(setToken: SetTokenAPI, zeroExApiKey: string = '') { + this.setToken = setToken; + this.zeroExApiKey = zeroExApiKey; + } + + /** + * Generates a trade quote for a token pair in a SetToken. This method requires + * a token metadata map (passed with the options) which can be generated using + * the CoinGeckoDataService in '.api/utils/coingecko.ts'. + * + * @param options QuoteOptions: options / config to generate the quote + * @return TradeQuote: trade quote object + */ + async generate(options: QuoteOptions): Promise { + this.tokenMap = options.tokenMap; + const feePercentage = options.feePercentage || 0; + const isFirmQuote = options.isFirmQuote || false; + const chainId = options.chainId; + const exchangeAdapterName = ZERO_EX_ADAPTER_NAME; + + const { + fromTokenAddress, + toTokenAddress, + fromAddress, + } = this.sanitizeAddress(options.fromToken, options.toToken, options.fromAddress); + + const amount = this.sanitizeAmount(fromTokenAddress, options.rawAmount); + + const setOnChainDetails = await this.setToken.fetchSetDetailsAsync( + fromAddress, [fromTokenAddress, toTokenAddress] + ); + + const fromTokenRequestAmount = this.calculateFromTokenAmount( + setOnChainDetails, + fromTokenAddress, + amount + ); + + const { + fromTokenAmount, + fromUnits, + toTokenAmount, + toUnits, + calldata, + zeroExGas, + } = await this.fetchZeroExQuote( // fetchQuote (and switch...) + fromTokenAddress, + toTokenAddress, + fromTokenRequestAmount, + setOnChainDetails.manager, + (setOnChainDetails as any).totalSupply, // Typings incorrect, + chainId, + isFirmQuote, + options.slippagePercentage + ); + + // Sanity check response from quote APIs + this.validateQuoteValues( + setOnChainDetails, + fromTokenAddress, + toTokenAddress, + fromUnits, + toUnits + ); + + const gas = this.estimateGasCost(zeroExGas); + + const coinGecko = new CoinGeckoDataService(chainId); + const coinPrices = await coinGecko.fetchCoinPrices({ + contractAddresses: [this.chainCurrencyAddress(chainId), fromTokenAddress, toTokenAddress], + vsCurrencies: [ USD_CURRENCY_CODE, USD_CURRENCY_CODE, USD_CURRENCY_CODE ], + }); + + const gasOracle = new GasOracleService(chainId); + const gasPrice = await gasOracle.fetchGasPrice(); + + return { + from: fromAddress, + fromTokenAddress, + toTokenAddress, + exchangeAdapterName, + calldata, + gas: gas.toString(), + gasPrice: gasPrice.toString(), + slippagePercentage: this.formatAsPercentage(options.slippagePercentage), + fromTokenAmount: fromUnits.toString(), + toTokenAmount: toUnits.toString(), + display: { + inputAmountRaw: options.rawAmount.toString(), + inputAmount: amount.toString(), + quoteAmount: fromTokenRequestAmount.toString(), + fromTokenDisplayAmount: this.tokenDisplayAmount(fromTokenAmount, fromTokenAddress), + toTokenDisplayAmount: this.tokenDisplayAmount(toTokenAmount, toTokenAddress), + fromTokenPriceUsd: this.tokenPriceUsd(fromTokenAmount, fromTokenAddress, coinPrices), + toTokenPriceUsd: this.tokenPriceUsd(toTokenAmount, toTokenAddress, coinPrices), + toToken: this.tokenResponse(toTokenAddress), + fromToken: this.tokenResponse(fromTokenAddress), + gasCostsUsd: this.gasCostsUsd(gasPrice, gas, coinPrices, chainId), + gasCostsChainCurrency: this.gasCostsChainCurrency(gasPrice, gas, chainId), + feePercentage: this.formatAsPercentage(feePercentage), + slippage: this.calculateSlippage( + fromTokenAmount, + toTokenAmount, + fromTokenAddress, + toTokenAddress, + coinPrices + ), + }, + }; + } + + private sanitizeAddress(fromToken: Address, toToken: Address, fromAddress: Address) { + return { + fromTokenAddress: fromToken.toLowerCase(), + toTokenAddress: toToken.toLowerCase(), + fromAddress: fromAddress.toLowerCase(), + }; + } + + private sanitizeAmount(fromTokenAddress: Address, rawAmount: string): BigNumber { + const decimals = this.tokenMap[fromTokenAddress].decimals; + return ethersUtils.parseUnits(rawAmount, decimals); + } + + private async fetchZeroExQuote( + fromTokenAddress: Address, + toTokenAddress: Address, + fromTokenRequestAmount: BigNumber, + manager: Address, + setTotalSupply: BigNumber, + chainId: number, + isFirmQuote: boolean, + slippagePercentage: number + ) { + const zeroEx = new ZeroExTradeQuoter({ + chainId: chainId, + zeroExApiKey: this.zeroExApiKey, + }); + + const quote = await zeroEx.fetchTradeQuote( + fromTokenAddress, + toTokenAddress, + fromTokenRequestAmount, + manager, + isFirmQuote + ); + + const fromTokenAmount = quote.sellAmount; + + // Convert to BigDecimal to get cieling in fromUnits calculation + // This is necessary to derive the trade amount ZeroEx expects when scaling is + // done in the TradeModule contract. (ethers.FixedNumber does not work for this case) + const fromTokenAmountBD = new BigDecimal(fromTokenAmount.toString()); + const scaleBD = new BigDecimal(SCALE.toString()); + const setTotalSupplyBD = new BigDecimal(setTotalSupply.toString()); + + const fromUnitsBD = fromTokenAmountBD.multiply(scaleBD).divide(setTotalSupplyBD, 10).ceil(); + const fromUnits = BigNumber.from(fromUnitsBD.getValue()); + + const toTokenAmount = quote.buyAmount; + + // BigNumber does not do fixed point math & FixedNumber underflows w/ numbers less than 1 + // Multiply the slippage by a factor and divide the end result by same... + const percentMultiplier = 1000; + const slippageToleranceBN = percentMultiplier * this.outputSlippageTolerance(slippagePercentage); + const toTokenAmountMinusSlippage = toTokenAmount.mul(slippageToleranceBN).div(percentMultiplier); + const toUnits = toTokenAmountMinusSlippage.mul(SCALE).div(setTotalSupply); + + return { + fromTokenAmount, + fromUnits, + toTokenAmount, + toUnits, + calldata: quote.calldata, + zeroExGas: quote.gas, + }; + } + + private validateQuoteValues( + setOnChainDetails: any, + fromTokenAddress: Address, + toTokenAddress: Address, + quoteFromRemainingUnits: BigNumber, + quoteToUnits: BigNumber + ) { + // fromToken + const positionForFromToken = setOnChainDetails + .positions + .find((p: any) => p.component.toLowerCase() === fromTokenAddress.toLowerCase()); + + const currentPositionUnits = BigNumber.from(positionForFromToken.unit); + const remainingPositionUnits = currentPositionUnits.sub(quoteFromRemainingUnits); + const remainingPositionUnitsTooSmall = remainingPositionUnits.gt(0) && remainingPositionUnits.lt(50); + + if (remainingPositionUnitsTooSmall) { + throw new Error('Remaining units too small, incorrectly attempting max'); + } + + // toToken + const positionForToToken = setOnChainDetails + .positions + .find((p: any) => p.component.toLowerCase() === toTokenAddress.toLowerCase()); + + const newToPositionUnits = (positionForToToken !== undefined) + ? positionForToToken.unit.add(quoteToUnits) + : quoteToUnits; + + const newToUnitsTooSmall = newToPositionUnits.gt(0) && newToPositionUnits.lt(50); + + if (newToUnitsTooSmall) { + throw new Error('Receive units too small'); + } + } + + private calculateFromTokenAmount( + setOnChainDetails: any, + fromTokenAddress: Address, + amount: BigNumber + ): BigNumber { + const positionForFromToken = setOnChainDetails + .positions + .find((p: any) => p.component.toLowerCase() === fromTokenAddress.toLowerCase()); + + if (positionForFromToken === undefined) { + throw new Error('Invalid fromToken input'); + } + + const totalSupply = setOnChainDetails.totalSupply; + const impliedMaxNotional = positionForFromToken.unit.mul(totalSupply).div(SCALE); + const isGreaterThanMax = amount.gt(impliedMaxNotional); + const isMax = amount.eq(impliedMaxNotional); + + if (isGreaterThanMax) { + throw new Error('Amount is greater than quantity of component in Set'); + } else if (isMax) { + return impliedMaxNotional.toString(); + } else { + const amountMulScaleOverTotalSupply = amount.mul(SCALE).div(totalSupply); + return amountMulScaleOverTotalSupply.mul(totalSupply).div(SCALE); + } + } + + private tokenDisplayAmount(amount: BigNumber, address: Address): string { + return this.normalizeTokenAmount(amount, address).toString(); + } + + private tokenResponse(address: Address): TokenResponse { + const tokenEntry = this.tokenMap[address]; + return { + symbol: tokenEntry.symbol, + name: tokenEntry.name, + address, + decimals: tokenEntry.decimals, + }; + } + + private chainCurrencyAddress(chainId: number): Address { + switch (chainId) { + case 1: return '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; // WETH + case 137: return '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270'; // WMATIC + default: throw new Error(`chainId: ${chainId} is not supported`); + } + } + + private normalizeTokenAmount(amount: BigNumber, address: Address): number { + const tokenScale = BigNumber.from(10).pow(this.tokenMap[address].decimals); + return FixedNumber.from(amount).divUnsafe(FixedNumber.from(tokenScale)).toUnsafeFloat(); + } + + private tokenPriceUsd(amount: BigNumber, address: Address, coinPrices: CoinGeckoCoinPrices): string { + const coinPrice = coinPrices[address][USD_CURRENCY_CODE]; + const normalizedAmount = this.normalizeTokenAmount(amount, address) * coinPrice; + return new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(normalizedAmount); + } + + private formatAsPercentage(percentage: number): string { + return percentage.toFixed(2) + '%'; + } + + private totalGasCost(gasPrice: number, gas: number): number { + return (gasPrice / 1e9) * gas; + } + + private gasCostsUsd( + gasPrice: number, + gas: number, + coinPrices: CoinGeckoCoinPrices, + chainId: number + ): string { + const totalGasCost = this.totalGasCost(gasPrice, gas); + const chainCurrencyAddress = this.chainCurrencyAddress(chainId); + const coinPrice = coinPrices[chainCurrencyAddress][USD_CURRENCY_CODE]; + const cost = totalGasCost * coinPrice; + + // Polygon prices are low - using 4 significant digits here so something besides zero appears + const options = { + style: 'currency', + currency: 'USD', + maximumSignificantDigits: (chainId === 137) ? 4 : undefined, + }; + return new Intl.NumberFormat('en-US', options).format(cost); + } + + private gasCostsChainCurrency(gasPrice: number, gas: number, chainId: number): string { + const chainCurrency = this.chainCurrency(chainId); + const totalGasCostText = this.totalGasCost(gasPrice, gas).toFixed(7).toString(); + return `${totalGasCostText} ${chainCurrency}`; + } + + private chainCurrency(chainId: number): string { + switch (chainId) { + case 1: return 'ETH'; + case 137: return 'MATIC'; + default: return ''; + } + } + + private estimateGasCost(zeroExGas: number): number { + const gas = zeroExGas + this.largeTradeGasCostBase; + const gasCostBuffer = (100 + this.tradeQuoteGasBuffer) / 100; + return Math.floor(gas * gasCostBuffer); + } + + private calculateSlippage( + fromTokenAmount: BigNumber, + toTokenAmount: BigNumber, + fromTokenAddress: Address, + toTokenAddress: Address, + coinPrices: CoinGeckoCoinPrices + ): string { + const fromTokenPriceUsd = coinPrices[fromTokenAddress][USD_CURRENCY_CODE]; + const toTokenPriceUsd = coinPrices[toTokenAddress][USD_CURRENCY_CODE]; + + const fromTokenTotalUsd = this.normalizeTokenAmount(fromTokenAmount, fromTokenAddress) * fromTokenPriceUsd; + const toTokenTotalUsd = this.normalizeTokenAmount(toTokenAmount, toTokenAddress) * toTokenPriceUsd; + + const slippageRaw = (fromTokenTotalUsd - toTokenTotalUsd) / fromTokenTotalUsd; + return this.formatAsPercentage(slippageRaw * 100); + } + + private outputSlippageTolerance(slippagePercentage: number): number { + return (100 - slippagePercentage) / 100; + } +} \ No newline at end of file diff --git a/src/api/utils/tradeQuote/zeroex.ts b/src/api/utils/tradeQuote/zeroex.ts new file mode 100644 index 00000000..e08fa9e0 --- /dev/null +++ b/src/api/utils/tradeQuote/zeroex.ts @@ -0,0 +1,121 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +'use strict'; + +import axios from 'axios'; +import { BigNumber } from 'ethers'; +import Assertions from '../../../assertions'; + +import { + ZeroExTradeQuoterOptions, + ZeroExTradeQuote, + ZeroExQueryParams +} from '../../../types/index'; + +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; + +/** + * @title ZeroExTradeQuoter + * @author Set Protocol + * + * A utility library to call 0xAPI to get a swap quote for a token pair on Ethereum or Polygon + */ +export class ZeroExTradeQuoter { + private host: string; + private zeroExApiKey: string; + private assert: Assertions; + + private swapQuoteRoute = '/swap/v1/quote'; + private feePercentage: number = 0; + private feeRecipientAddress: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; + private affiliateAddress: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; + private excludedSources: string[] = ['Kyber', 'Eth2Dai', 'Uniswap', 'Mesh']; + private skipValidation: boolean = true; + private slippagePercentage: number = 0.02; + + constructor(options: ZeroExTradeQuoterOptions) { + this.assert = new Assertions(); + this.assert.common.isSupportedChainId(options.chainId); + this.host = this.getHostForChain(options.chainId) as string; + this.zeroExApiKey = options.zeroExApiKey; + } + + /** + * Gets a trade quote for a token pair + * + * @param sellTokenAddress address of token to sell + * @param buyTokenAddress address of token to buy + * @param sellAmount BigNumber amount of token to sell + * @param takerAddress SetToken manager address + * @param isFirm Boolean notifying 0x whether or not the query is speculative or + * precedes an firm intent to trade + * + * @return ZeroExTradeQuote: quote info + */ + async fetchTradeQuote( + sellTokenAddress: Address, + buyTokenAddress: Address, + sellAmount: BigNumber, + takerAddress: Address, + isFirm: boolean, + ): Promise { + const url = `${this.host}${this.swapQuoteRoute}`; + + const params: ZeroExQueryParams = { + sellToken: sellTokenAddress, + buyToken: buyTokenAddress, + slippagePercentage: this.slippagePercentage, + sellAmount: sellAmount.toString(), + takerAddress, + excludedSources: this.excludedSources.join(','), + skipValidation: this.skipValidation, + feeRecipient: this.feeRecipientAddress, + buyTokenPercentageFee: this.feePercentage, + affiliateAddress: this.affiliateAddress, + intentOnFilling: isFirm, + }; + + try { + const response = await axios.get(url, { + params: params, + headers: { + '0x-api-key': this.zeroExApiKey, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + + return { + guaranteedPrice: parseFloat(response.data.guaranteedPrice), + price: parseFloat(response.data.price), + sellAmount: BigNumber.from(response.data.sellAmount), + buyAmount: BigNumber.from(response.data.buyAmount), + calldata: response.data.data, + gas: parseInt(response.data.gas), + }; + } catch (error) { + throw new Error('ZeroEx quote request failed: ' + error); + } + } + + private getHostForChain(chainId: number) { + switch (chainId) { + case 1: return 'https://api.0x.org'; + case 137: return 'https://polygon.api.0x.org'; + } + } +} diff --git a/src/assertions/CommonAssertions.ts b/src/assertions/CommonAssertions.ts index 0ef9d3e2..73dd5a13 100644 --- a/src/assertions/CommonAssertions.ts +++ b/src/assertions/CommonAssertions.ts @@ -115,4 +115,12 @@ export class CommonAssertions { throw new Error(errorMessage); } } + + public isSupportedChainId(chainId: number) { + const validChainIds = [1, 137]; + + if ( !validChainIds.includes(chainId)) { + throw new Error('Unsupported chainId: ${chainId}. Must be one of ${validChainIds}'); + } + } } diff --git a/src/types/common.ts b/src/types/common.ts index 8fd5eaa2..2daadc0c 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -18,6 +18,7 @@ export interface SetJSConfig { tradeModuleAddress: Address; governanceModuleAddress: Address; debtIssuanceModuleAddress: Address; + zeroExApiKey?: string; } export type SetDetails = { diff --git a/src/types/index.ts b/src/types/index.ts index d0b93236..b16c674e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,2 @@ export * from './common'; +export * from './utils'; diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 00000000..89e707c8 --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1,129 @@ +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; +import { BigNumber } from 'ethers/lib/ethers'; + +export type CurrencyCodePriceMap = { + [key: string]: number +}; +export type CoinGeckoCoinPrices = { + [key: string]: CurrencyCodePriceMap +}; + +export type CoinGeckoTokenData = { + chainId: number, + address: string, + name: string, + symbol: string, + decimals: number, + logoURI?: string, +}; + +export type SushiswapTokenData = CoinGeckoTokenData & { + volumeUSD: number +}; + +export type CoinGeckoTokenMap = { + [key: string]: CoinGeckoTokenData +}; + +export type CoinPricesParams = { + contractAddresses: string[], + vsCurrencies: string[] +}; + +export type PolygonMappedTokenData = { + [key: string]: string, +}; + +export type QuoteOptions = { + fromToken: Address, + toToken: Address, + rawAmount: string, + fromAddress: Address, + chainId: number, + tokenMap: CoinGeckoTokenMap, + slippagePercentage: number, + isFirmQuote?: boolean, + feePercentage?: number +}; + +export type ZeroExQuote = { + fromTokenAmount: BigNumber, + fromUnits: BigNumber, + toTokenAmount: BigNumber + toUnits: BigNumber, + calldata: string, + zeroExGas: number +}; + +export type TokenResponse = { + symbol: string, + name: string, + address: Address, + decimals: number +}; + +export type TradeQuote = { + from: Address, + fromTokenAddress: Address, + toTokenAddress: Address, + exchangeAdapterName: string, + calldata: string, + gas: string, + gasPrice: string, + slippagePercentage: string, + fromTokenAmount: string, + toTokenAmount: string, + display: { + inputAmountRaw: string, + inputAmount: string, + quoteAmount: string, + fromTokenDisplayAmount: string, + toTokenDisplayAmount: string, + fromTokenPriceUsd: string, + toTokenPriceUsd: string, + toToken: TokenResponse, + fromToken: TokenResponse, + gasCostsUsd: string, + gasCostsChainCurrency: string, + feePercentage: string, + slippage: string + } +}; + +export type ZeroExTradeQuoterOptions = { + chainId: number, + zeroExApiKey: string, +}; + +export type ZeroExQueryParams = { + sellToken: Address, + buyToken: Address, + sellAmount: string, + slippagePercentage: number, + takerAddress: Address, + excludedSources: string, + skipValidation: boolean, + feeRecipient: Address, + buyTokenPercentageFee: number + affiliateAddress: Address, + intentOnFilling: boolean +}; + +export type ZeroExTradeQuote = { + guaranteedPrice: number, + price: number, + sellAmount: BigNumber, + buyAmount: BigNumber, + calldata: string, + gas: number +}; + +export type EthGasStationData = { + average: number, + fast: number, + fastest: number +}; + +export type GasOracleSpeed = 'average' | 'fast' | 'fastest'; + + diff --git a/test/api/TradeQuoteAPI.spec.ts b/test/api/TradeQuoteAPI.spec.ts new file mode 100644 index 00000000..0b1007ef --- /dev/null +++ b/test/api/TradeQuoteAPI.spec.ts @@ -0,0 +1,113 @@ +/* + Copyright 2018 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import axios from 'axios'; +import { ethers } from 'ethers'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; +import { CoinGeckoTokenMap, TradeQuote } from '@src/types'; +import SetTokenAPI from '@src/api/SetTokenAPI'; +import { TradeQuoteAPI, CoinGeckoDataService } from '@src/api/utils/tradeQuote'; +import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; +import { expect } from '@test/utils/chai'; + +const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); + +jest.mock('@src/api/SetTokenAPI', () => { + return function() { + return { + fetchSetDetailsAsync: jest.fn().mockImplementationOnce(() => { + return fixture.setDetailsResponse; + }), + }; + }; +}); + +jest.mock('axios'); + +// @ts-ignore +axios.get.mockImplementation(val => { + switch (val) { + case fixture.zeroExRequest: return fixture.zeroExReponse; + case fixture.ethGasStationRequest: return fixture.ethGasStationResponse; + case fixture.coinGeckoTokenRequest: return fixture.coinGeckoTokenResponse; + case fixture.coinGeckoPricesRequest: return fixture.coinGeckoPricesResponse; + } +}); + +describe('TradeQuoteAPI', () => { + let streamingFeeModuleAddress: Address; + let protocolViewerAddress: Address; + let setTokenCreatorAddress: Address; + let tradeQuote: TradeQuoteAPI; + let coingecko: CoinGeckoDataService; + let tokenMap: CoinGeckoTokenMap; + let setTokenAPI: SetTokenAPI; + + beforeEach(async () => { + [ + streamingFeeModuleAddress, + protocolViewerAddress, + setTokenCreatorAddress, + ] = await provider.listAccounts(); + + setTokenAPI = new SetTokenAPI( + provider, + protocolViewerAddress, + streamingFeeModuleAddress, + setTokenCreatorAddress + ); + coingecko = new CoinGeckoDataService(1); + tokenMap = await coingecko.fetchTokenMap(); + tradeQuote = new TradeQuoteAPI(setTokenAPI, 'xyz'); + }); + + describe('generate (quote)', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectRawAmount: string; + let subjectSetTokenAddress: Address; + let subjectChainId: number; + let subjectSlippagePercentage: number; + let subjectTokenMap: CoinGeckoTokenMap; + + beforeEach(async () => { + subjectFromToken = '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2'; // MKR + subjectToToken = '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e'; // YFI + subjectSetTokenAddress = '0x1494ca1f11d487c2bbe4543e90080aeba4ba3c2b'; // DPI + subjectRawAmount = '.5'; + subjectChainId = 1; + subjectSlippagePercentage = 2, + subjectTokenMap = tokenMap; + }); + + async function subject(): Promise { + return await tradeQuote.generate({ + fromToken: subjectFromToken, + toToken: subjectToToken, + rawAmount: subjectRawAmount, + fromAddress: subjectSetTokenAddress, + chainId: subjectChainId, + slippagePercentage: subjectSlippagePercentage, + tokenMap: subjectTokenMap, + }); + } + + it('should generate a trade quote for mainnet correctly', async () => { + const quote = await subject(); + expect(quote).to.be.deep.equal(fixture.setTradeQuote); + }); + }); +}); diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts new file mode 100644 index 00000000..46630c7c --- /dev/null +++ b/test/fixtures/tradeQuote.ts @@ -0,0 +1,119 @@ +import { BigNumber } from 'ethers'; + +export const tradeQuoteFixtures = { + setDetailsResponse: { + name: 'DefiPulse Index', + symbol: 'DPI', + manager: '0x0DEa6d942a2D8f594844F973366859616Dd5ea50', + positions: + [ + { + component: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + module: '0x0000000000000000000000000000000000000000', + unit: BigNumber.from('0x022281f9089b0f'), + positionState: 0, + data: '0x', + }, + { + component: '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', + module: '0x0000000000000000000000000000000000000000', + unit: BigNumber.from('0x354e308b36c16b'), + positionState: 0, + data: '0x', + }, + ], + totalSupply: BigNumber.from('0x5df56bc958049751d8fb'), + }, + + zeroExRequest: 'https://api.0x.org/swap/v1/quote', + zeroExReponse: { + data: { + price: '0.082625382321048146', + guaranteedPrice: '0.082625382321048146', + data: '0x415565b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2', + buyAmount: '41312691160507030', + sellAmount: '499999999999793729', + gas: '346000', + }, + }, + + ethGasStationRequest: 'https://ethgasstation.info/json/ethgasAPI.json', + ethGasStationResponse: { + data: { + fast: 610, + fastest: 610, + safeLow: 178, + average: 178, + }, + }, + + coinGeckoTokenRequest: 'https://tokens.coingecko.com/uniswap/all.json', + coinGeckoTokenResponse: { + data: { + tokens: [ + { chainId: 1, + address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Eth', + symbol: 'WETH', + decimals: 18, + logoURI: '' }, + { chainId: 1, + address: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', + name: 'Maker', + symbol: 'MKR', + decimals: 18, + logoURI: '' }, + { chainId: 1, + address: '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', + name: 'Yearn', + symbol: 'YFI', + decimals: 18, + logoURI: '' }], + }, + }, + + coinGeckoPricesRequest: 'https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2,0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e&vs_currencies=usd,usd,usd', + coinGeckoPricesResponse: { + data: { + '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e': { usd: 39087 }, + '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2': { usd: 3194.41 }, + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': { usd: 2493.12 }, + }, + }, + + setTradeQuote: { + from: '0x1494ca1f11d487c2bbe4543e90080aeba4ba3c2b', + fromTokenAddress: '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', + toTokenAddress: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', + exchangeAdapterName: 'ZeroExApiAdapterV3', + calldata: '0x415565b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2', + gas: '520800', + gasPrice: '61', + slippagePercentage: '2.00%', + fromTokenAmount: '1126868991563', + toTokenAmount: '91245821628', + display: { + inputAmountRaw: '.5', + inputAmount: '500000000000000000', + quoteAmount: '499999999999793729', + fromTokenDisplayAmount: '0.4999999999997937', + toTokenDisplayAmount: '0.04131269116050703', + fromTokenPriceUsd: '$1,597.20', + toTokenPriceUsd: '$1,614.79', + toToken: + { symbol: 'MKR', + name: 'Maker', + address: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', + decimals: 18 }, + fromToken: + { symbol: 'YFI', + name: 'Yearn', + address: '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', + decimals: 18 }, + gasCostsUsd: '$79.20', + gasCostsChainCurrency: '0.0317688 ETH', + feePercentage: '0.00%', + slippage: '-1.10%', + }, + }, +}; diff --git a/yarn.lock b/yarn.lock index 5a9f3d2e..dfc3aa53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1889,6 +1889,13 @@ aws4@^1.8.0: version "1.10.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.0.tgz#a17b3a8ea811060e74d47d306122400ad4497ae2" +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + babel-jest@^26.2.2: version "26.2.2" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.2.2.tgz#70f618f2d7016ed71b232241199308985462f812" @@ -3710,6 +3717,11 @@ flat@^4.1.0: dependencies: is-buffer "~2.0.3" +follow-redirects@^1.10.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43" + integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg== + for-in@^0.1.3: version "0.1.8" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" @@ -3947,6 +3959,13 @@ graceful-fs@^4.1.10, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, version "4.2.4" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" +graph-results-pager@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/graph-results-pager/-/graph-results-pager-1.0.3.tgz#2ce851ea13a16a753efb2a472aae5046cee23cd4" + integrity sha512-Dxh58jIlkhiK+siS0B45eGBc8dk1rukaJOGqLMgrst7bIYpJ52zxhHht6FhxX5JbVyXzmbXqecztr659ir0C6Q== + dependencies: + node-fetch "2.6.1" + growl@1.10.5: version "1.10.5" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" @@ -4943,6 +4962,11 @@ jest@^26.1.0: import-local "^3.0.2" jest-cli "^26.2.2" +js-big-decimal@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/js-big-decimal/-/js-big-decimal-1.3.4.tgz#212f6e18bdc332b73a2ec2e5ea322c5a7516289e" + integrity sha512-wF8j7/WuGn/mwcgo7xofgeQTmJAf8uaiNRFWaM3usTn4NSQtyggdg8FofFumuVnvftWYEmHZBtxKgRK9hlOPiw== + js-sha3@0.5.7, js-sha3@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.5.7.tgz#0d4ffd8002d5333aabaf4a23eed2f6374c9f28e7" @@ -5627,6 +5651,11 @@ node-dir@0.1.17: dependencies: minimatch "^3.0.2" +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" From d85ae3d266abfc52dfbd809b72f274028f81e820 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 8 Jun 2021 00:38:14 -0700 Subject: [PATCH 02/23] 0.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ab8e225e..f70fb12e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.1.2", + "version": "0.2.0", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From eb873b7a20cbf8a97f9d4d7abe073862d8ed35d5 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 8 Jun 2021 16:47:08 -0700 Subject: [PATCH 03/23] Add polygon quote test --- test/api/TradeQuoteAPI.spec.ts | 145 ++++++++++++++++++------- test/fixtures/tradeQuote.ts | 192 +++++++++++++++++++++++++++++++-- 2 files changed, 290 insertions(+), 47 deletions(-) diff --git a/test/api/TradeQuoteAPI.spec.ts b/test/api/TradeQuoteAPI.spec.ts index 0b1007ef..32153742 100644 --- a/test/api/TradeQuoteAPI.spec.ts +++ b/test/api/TradeQuoteAPI.spec.ts @@ -25,11 +25,17 @@ import { expect } from '@test/utils/chai'; const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); +const DPI_ETH = '0x1494ca1f11d487c2bbe4543e90080aeba4ba3c2b'; +const BUD_POLY = '0xd7dc13984d4fe87f389e50067fb3eedb3f704ea0'; + jest.mock('@src/api/SetTokenAPI', () => { return function() { return { - fetchSetDetailsAsync: jest.fn().mockImplementationOnce(() => { - return fixture.setDetailsResponse; + fetchSetDetailsAsync: jest.fn().mockImplementationOnce((setToken: Address) => { + switch (setToken) { + case DPI_ETH: return fixture.setDetailsResponseDPI; + case BUD_POLY: return fixture.setDetailsResponseBUD; + } }), }; }; @@ -40,10 +46,16 @@ jest.mock('axios'); // @ts-ignore axios.get.mockImplementation(val => { switch (val) { - case fixture.zeroExRequest: return fixture.zeroExReponse; + case fixture.zeroExRequestEth: return fixture.zeroExReponseEth; + case fixture.zeroExRequestPoly: return fixture.zeroExReponsePoly; case fixture.ethGasStationRequest: return fixture.ethGasStationResponse; - case fixture.coinGeckoTokenRequest: return fixture.coinGeckoTokenResponse; - case fixture.coinGeckoPricesRequest: return fixture.coinGeckoPricesResponse; + case fixture.maticGasStationRequest: return fixture.maticGasStationResponse; + case fixture.coinGeckoTokenRequestEth: return fixture.coinGeckoTokenResponseEth; + case fixture.coinGeckoTokenRequestPoly: return fixture.coinGeckoTokenResponsePoly; + case fixture.coinGeckoPricesRequestEth: return fixture.coinGeckoPricesResponseEth; + case fixture.coinGeckoPricesRequestPoly: return fixture.coinGeckoPricesResponsePoly; + case fixture.maticMapperRequestPoly: return fixture.maticMapperResponsePoly; + case fixture.quickswapRequestPoly: return fixture.quickswapResponsePoly; } }); @@ -51,9 +63,6 @@ describe('TradeQuoteAPI', () => { let streamingFeeModuleAddress: Address; let protocolViewerAddress: Address; let setTokenCreatorAddress: Address; - let tradeQuote: TradeQuoteAPI; - let coingecko: CoinGeckoDataService; - let tokenMap: CoinGeckoTokenMap; let setTokenAPI: SetTokenAPI; beforeEach(async () => { @@ -69,45 +78,103 @@ describe('TradeQuoteAPI', () => { streamingFeeModuleAddress, setTokenCreatorAddress ); - coingecko = new CoinGeckoDataService(1); - tokenMap = await coingecko.fetchTokenMap(); - tradeQuote = new TradeQuoteAPI(setTokenAPI, 'xyz'); }); - describe('generate (quote)', () => { - let subjectFromToken: Address; - let subjectToToken: Address; - let subjectRawAmount: string; - let subjectSetTokenAddress: Address; - let subjectChainId: number; - let subjectSlippagePercentage: number; - let subjectTokenMap: CoinGeckoTokenMap; + describe('mainnet', () => { + let tradeQuote: TradeQuoteAPI; + let coingecko: CoinGeckoDataService; + let tokenMap: CoinGeckoTokenMap; + + beforeEach(async () => { + coingecko = new CoinGeckoDataService(1); + tokenMap = await coingecko.fetchTokenMap(); + tradeQuote = new TradeQuoteAPI(setTokenAPI, 'xyz'); + }); + + describe('generate a quote', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectRawAmount: string; + let subjectSetTokenAddress: Address; + let subjectChainId: number; + let subjectSlippagePercentage: number; + let subjectTokenMap: CoinGeckoTokenMap; + + beforeEach(async () => { + subjectFromToken = '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2'; // MKR + subjectToToken = '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e'; // YFI + subjectSetTokenAddress = DPI_ETH; // DPI + subjectRawAmount = '.5'; + subjectChainId = 1; + subjectSlippagePercentage = 2, + subjectTokenMap = tokenMap; + }); + + async function subject(): Promise { + return await tradeQuote.generate({ + fromToken: subjectFromToken, + toToken: subjectToToken, + rawAmount: subjectRawAmount, + fromAddress: subjectSetTokenAddress, + chainId: subjectChainId, + slippagePercentage: subjectSlippagePercentage, + tokenMap: subjectTokenMap, + }); + } + + it('should generate a trade quote correctly', async () => { + const quote = await subject(); + expect(quote).to.be.deep.equal(fixture.setTradeQuoteEth); + }); + }); + }); + + describe('polygon', () => { + let tradeQuote: TradeQuoteAPI; + let coingecko: CoinGeckoDataService; + let tokenMap: CoinGeckoTokenMap; beforeEach(async () => { - subjectFromToken = '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2'; // MKR - subjectToToken = '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e'; // YFI - subjectSetTokenAddress = '0x1494ca1f11d487c2bbe4543e90080aeba4ba3c2b'; // DPI - subjectRawAmount = '.5'; - subjectChainId = 1; - subjectSlippagePercentage = 2, - subjectTokenMap = tokenMap; + coingecko = new CoinGeckoDataService(137); + tokenMap = await coingecko.fetchTokenMap(); + tradeQuote = new TradeQuoteAPI(setTokenAPI, 'xyz'); }); - async function subject(): Promise { - return await tradeQuote.generate({ - fromToken: subjectFromToken, - toToken: subjectToToken, - rawAmount: subjectRawAmount, - fromAddress: subjectSetTokenAddress, - chainId: subjectChainId, - slippagePercentage: subjectSlippagePercentage, - tokenMap: subjectTokenMap, + describe('generate a quote', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectRawAmount: string; + let subjectSetTokenAddress: Address; + let subjectChainId: number; + let subjectSlippagePercentage: number; + let subjectTokenMap: CoinGeckoTokenMap; + + beforeEach(async () => { + subjectFromToken = '0x2791bca1f2de4661ed88a30c99a7a9449aa84174'; // USDC + subjectToToken = '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6'; // WBTC + subjectSetTokenAddress = BUD_POLY; // BUD + subjectRawAmount = '1'; + subjectChainId = 137; + subjectSlippagePercentage = 2, + subjectTokenMap = tokenMap; }); - } - it('should generate a trade quote for mainnet correctly', async () => { - const quote = await subject(); - expect(quote).to.be.deep.equal(fixture.setTradeQuote); + async function subject(): Promise { + return await tradeQuote.generate({ + fromToken: subjectFromToken, + toToken: subjectToToken, + rawAmount: subjectRawAmount, + fromAddress: subjectSetTokenAddress, + chainId: subjectChainId, + slippagePercentage: subjectSlippagePercentage, + tokenMap: subjectTokenMap, + }); + } + + it('should generate a trade quote correctly', async () => { + const quote = await subject(); + expect(quote).to.be.deep.equal(fixture.setTradeQuotePoly); + }); }); }); }); diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 46630c7c..3123d741 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -1,7 +1,7 @@ import { BigNumber } from 'ethers'; export const tradeQuoteFixtures = { - setDetailsResponse: { + setDetailsResponseDPI: { name: 'DefiPulse Index', symbol: 'DPI', manager: '0x0DEa6d942a2D8f594844F973366859616Dd5ea50', @@ -25,8 +25,30 @@ export const tradeQuoteFixtures = { totalSupply: BigNumber.from('0x5df56bc958049751d8fb'), }, - zeroExRequest: 'https://api.0x.org/swap/v1/quote', - zeroExReponse: { + setDetailsResponseBUD: { name: 'BUD Set', + symbol: 'BUD', + manager: '0x89A3EFC92f3FAbe59F3DeAa5e5e92773EE29fA37', + modules: [ '0xE99447aBbD5A7730b26D2D16fCcB2086319e4bC3' ], + moduleStatuses: [ 0, 0 ], + positions: [ + { + component: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + module: '0x0000000000000000000000000000000000000000', + unit: BigNumber.from('0x02faf080'), + positionState: 0, + data: '0x' }, + { + component: '0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6', + module: '0x0000000000000000000000000000000000000000', + unit: BigNumber.from('0x015ad9'), + positionState: 0, + data: '0x' }, + ], + totalSupply: BigNumber.from(10).pow(18), + }, + + zeroExRequestEth: 'https://api.0x.org/swap/v1/quote', + zeroExReponseEth: { data: { price: '0.082625382321048146', guaranteedPrice: '0.082625382321048146', @@ -37,6 +59,18 @@ export const tradeQuoteFixtures = { }, }, + zeroExRequestPoly: 'https://polygon.api.0x.org/swap/v1/quote', + zeroExReponsePoly: { + data: { + price: '0.00002973', + guaranteedPrice: '0.00002973', + data: '0x415565b00000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174', + gas: '240000', + buyAmount: '2973', + sellAmount: '1000000', + }, + }, + ethGasStationRequest: 'https://ethgasstation.info/json/ethgasAPI.json', ethGasStationResponse: { data: { @@ -47,8 +81,17 @@ export const tradeQuoteFixtures = { }, }, - coinGeckoTokenRequest: 'https://tokens.coingecko.com/uniswap/all.json', - coinGeckoTokenResponse: { + maticGasStationRequest: 'https://gasstation-mainnet.matic.network', + maticGasStationResponse: { + data: { + fast: 5, + fastest: 7.5, + standard: 1, + }, + }, + + coinGeckoTokenRequestEth: 'https://tokens.coingecko.com/uniswap/all.json', + coinGeckoTokenResponseEth: { data: { tokens: [ { chainId: 1, @@ -72,8 +115,34 @@ export const tradeQuoteFixtures = { }, }, - coinGeckoPricesRequest: 'https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2,0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e&vs_currencies=usd,usd,usd', - coinGeckoPricesResponse: { + // This is actually an eth call...we use the eth list for image resources + coinGeckoTokenRequestPoly: 'https://tokens.coingecko.com/uniswap/all.json', + coinGeckoTokenResponsePoly: { + data: { + tokens: [ + { chainId: 1, + address: '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0', + name: 'Matic Token', + symbol: 'MATIC', + decimals: 18, + logoURI: '' }, + { chainId: 1, + address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 18, + logoURI: '' }, + { chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + logoURI: '' }], + }, + }, + + coinGeckoPricesRequestEth: 'https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2,0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e&vs_currencies=usd,usd,usd', + coinGeckoPricesResponseEth: { data: { '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e': { usd: 39087 }, '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2': { usd: 3194.41 }, @@ -81,7 +150,16 @@ export const tradeQuoteFixtures = { }, }, - setTradeQuote: { + coinGeckoPricesRequestPoly: 'https://api.coingecko.com/api/v3/simple/token_price/polygon-pos?contract_addresses=0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270,0x2791bca1f2de4661ed88a30c99a7a9449aa84174,0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6&vs_currencies=usd,usd,usd', + coinGeckoPricesResponsePoly: { + data: { + '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6': { usd: 33595 }, + '0x2791bca1f2de4661ed88a30c99a7a9449aa84174': { usd: 1.01 }, + '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270': { usd: 1.49 }, + }, + }, + + setTradeQuoteEth: { from: '0x1494ca1f11d487c2bbe4543e90080aeba4ba3c2b', fromTokenAddress: '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', toTokenAddress: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', @@ -116,4 +194,102 @@ export const tradeQuoteFixtures = { slippage: '-1.10%', }, }, + + quickswapRequestPoly: 'https://raw.githubusercontent.com/sameepsi/quickswap-default-token-list/master/src/tokens/mainnet.json', + quickswapResponsePoly: { + data: [{ + name: 'Wrapped BTC', + address: '0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6', + symbol: 'WBTC', + decimals: 8, + chainId: 137, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png' }, + { + name: 'USD Coin', + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + symbol: 'USDC', + decimals: 6, + chainId: 137, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }], + }, + + maticMapperRequestPoly: 'https://tokenmapper.api.matic.today/api/v1/mapping?map_type=[%22POS%22]&chain_id=137&limit=200&offset=0', + maticMapperResponsePoly: { + data: { + message: 'success', + data: { + mapping: [{ + root_token: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + child_token: '0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6', + mintable: false, + map_type: 'POS', + token_type: 'ERC20', + decimals: 8, + name: 'Wrapped BTC', + symbol: 'WBTC', + child_address_passed_by_user: true, + deleted: false, + chainId: 137, + id: 104, + }, + { root_token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + child_token: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + mintable: false, + map_type: 'POS', + token_type: 'ERC20', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + child_address_passed_by_user: true, + deleted: false, + chainId: 137, + id: 95, + }], + limit: 200, + offset: 800, + has_next_page: false, + }, + mappedCount: 831, + requestsCount: 7, + }, + }, + + setTradeQuotePoly: { + from: '0xd7dc13984d4fe87f389e50067fb3eedb3f704ea0', + fromTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + toTokenAddress: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', + exchangeAdapterName: 'ZeroExApiAdapterV3', + calldata: + '0x415565b00000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174', + gas: '409500', + gasPrice: '5', + slippagePercentage: '2.00%', + fromTokenAmount: '1000000', + toTokenAmount: '2913', + display: + { inputAmountRaw: '1', + inputAmount: '1000000', + quoteAmount: '1000000', + fromTokenDisplayAmount: '1', + toTokenDisplayAmount: '0.00002973', + fromTokenPriceUsd: '$1.01', + toTokenPriceUsd: '$1.00', + toToken: + { symbol: 'WBTC', + name: '(PoS) Wrapped BTC', + address: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', + decimals: 8 }, + fromToken: + { symbol: 'USDC', + name: 'USD Coin (PoS)', + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + decimals: 6 }, + gasCostsUsd: '$0.003051', + gasCostsChainCurrency: '0.0020475 MATIC', + feePercentage: '0.00%', + slippage: '1.11%' }, + }, }; From 3548d1c79f9ef1f8629a5747fe266a4f4e838414 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Tue, 8 Jun 2021 17:26:57 -0700 Subject: [PATCH 04/23] Use GasNow instead of EthGasStation --- src/api/utils/tradeQuote/gasOracle.ts | 12 ++++++------ src/types/utils.ts | 6 +++--- test/api/TradeQuoteAPI.spec.ts | 2 +- test/fixtures/tradeQuote.ts | 12 ++++++++++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/api/utils/tradeQuote/gasOracle.ts b/src/api/utils/tradeQuote/gasOracle.ts index 4720cfe1..cacb6359 100644 --- a/src/api/utils/tradeQuote/gasOracle.ts +++ b/src/api/utils/tradeQuote/gasOracle.ts @@ -19,7 +19,7 @@ import axios from 'axios'; import Assertions from '../../../assertions'; import { - EthGasStationData, + GasNowData, GasOracleSpeed, } from '../../../types'; @@ -64,14 +64,14 @@ export class GasOracleService { } private async getEthereumGasPrice(speed: GasOracleSpeed): Promise { - const url = 'https://ethgasstation.info/json/ethgasAPI.json'; - const data: EthGasStationData = (await axios.get(url)).data; + const url = 'https://www.gasnow.org/api/v3/gas/price'; + const data: GasNowData = (await axios.get(url)).data.data; // EthGasStation returns gas price in x10 Gwei (divite by 10 to convert it to gwei) switch (speed) { - case GasOracleService.AVERAGE: return data.average / 10; - case GasOracleService.FAST: return data.fast / 10; - case GasOracleService.FASTEST: return data.fastest / 10; + case GasOracleService.AVERAGE: return data.standard / 1e9; + case GasOracleService.FAST: return data.fast / 1e9; + case GasOracleService.FASTEST: return data.rapid / 1e9; } } diff --git a/src/types/utils.ts b/src/types/utils.ts index 89e707c8..25ea6434 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -118,10 +118,10 @@ export type ZeroExTradeQuote = { gas: number }; -export type EthGasStationData = { - average: number, +export type GasNowData = { + standard: number, fast: number, - fastest: number + rapid: number }; export type GasOracleSpeed = 'average' | 'fast' | 'fastest'; diff --git a/test/api/TradeQuoteAPI.spec.ts b/test/api/TradeQuoteAPI.spec.ts index 32153742..196e6ec5 100644 --- a/test/api/TradeQuoteAPI.spec.ts +++ b/test/api/TradeQuoteAPI.spec.ts @@ -48,7 +48,7 @@ axios.get.mockImplementation(val => { switch (val) { case fixture.zeroExRequestEth: return fixture.zeroExReponseEth; case fixture.zeroExRequestPoly: return fixture.zeroExReponsePoly; - case fixture.ethGasStationRequest: return fixture.ethGasStationResponse; + case fixture.gasNowRequest: return fixture.gasNowResponse; case fixture.maticGasStationRequest: return fixture.maticGasStationResponse; case fixture.coinGeckoTokenRequestEth: return fixture.coinGeckoTokenResponseEth; case fixture.coinGeckoTokenRequestPoly: return fixture.coinGeckoTokenResponsePoly; diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 3123d741..08fd9f3d 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -81,6 +81,18 @@ export const tradeQuoteFixtures = { }, }, + gasNowRequest: 'https://www.gasnow.org/api/v3/gas/price', + gasNowResponse: { + data: { + data: { + rapid: 61000000000, + fast: 61000000000, + standard: 17800000000, + slow: 17800000000, + }, + }, + }, + maticGasStationRequest: 'https://gasstation-mainnet.matic.network', maticGasStationResponse: { data: { From eb0116497008da0db6c59ecc03b06ad0c0001f9d Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 10:26:09 -0700 Subject: [PATCH 05/23] Make ZeroEx quoter constants TradeQuote params --- src/api/utils/tradeQuote/tradequote.ts | 37 +++++++++++++++++--------- src/api/utils/tradeQuote/zeroex.ts | 12 ++++----- src/types/utils.ts | 7 +++-- test/api/TradeQuoteAPI.spec.ts | 10 +++++-- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/api/utils/tradeQuote/tradequote.ts b/src/api/utils/tradeQuote/tradequote.ts index 05e75800..4150033e 100644 --- a/src/api/utils/tradeQuote/tradequote.ts +++ b/src/api/utils/tradeQuote/tradequote.ts @@ -27,8 +27,6 @@ import { TokenResponse, } from '../../../types/index'; -import SetTokenAPI from '../../SetTokenAPI'; - import { CoinGeckoDataService, USD_CURRENCY_CODE @@ -52,14 +50,17 @@ const SCALE = BigNumber.from(10).pow(18); */ export class TradeQuoteAPI { - private setToken: SetTokenAPI; private tokenMap: CoinGeckoTokenMap; private largeTradeGasCostBase: number = 150000; private tradeQuoteGasBuffer: number = 5; + private feeRecipient: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; + private feePercentage: number = 0; + private isFirmQuote: boolean = true; + private slippagePercentage: number = 2; + private excludedSources: string[] = ['Kyber', 'Eth2Dai', 'Uniswap', 'Mesh']; private zeroExApiKey: string; - constructor(setToken: SetTokenAPI, zeroExApiKey: string = '') { - this.setToken = setToken; + constructor(zeroExApiKey: string = '') { this.zeroExApiKey = zeroExApiKey; } @@ -73,9 +74,14 @@ export class TradeQuoteAPI { */ async generate(options: QuoteOptions): Promise { this.tokenMap = options.tokenMap; - const feePercentage = options.feePercentage || 0; - const isFirmQuote = options.isFirmQuote || false; + const chainId = options.chainId; + const feePercentage = options.feePercentage || this.feePercentage; + const isFirmQuote = (options.isFirmQuote === false) ? false : this.isFirmQuote; + const slippagePercentage = options.slippagePercentage || this.slippagePercentage; + const feeRecipient = options.feeRecipient || this.feeRecipient; + const excludedSources = options.excludedSources || this.excludedSources; + const exchangeAdapterName = ZERO_EX_ADAPTER_NAME; const { @@ -86,7 +92,7 @@ export class TradeQuoteAPI { const amount = this.sanitizeAmount(fromTokenAddress, options.rawAmount); - const setOnChainDetails = await this.setToken.fetchSetDetailsAsync( + const setOnChainDetails = await options.setToken.fetchSetDetailsAsync( fromAddress, [fromTokenAddress, toTokenAddress] ); @@ -111,7 +117,9 @@ export class TradeQuoteAPI { (setOnChainDetails as any).totalSupply, // Typings incorrect, chainId, isFirmQuote, - options.slippagePercentage + slippagePercentage, + feeRecipient, + excludedSources ); // Sanity check response from quote APIs @@ -190,7 +198,9 @@ export class TradeQuoteAPI { setTotalSupply: BigNumber, chainId: number, isFirmQuote: boolean, - slippagePercentage: number + slippagePercentage: number, + feeRecipient: Address, + excludedSources: string[] ) { const zeroEx = new ZeroExTradeQuoter({ chainId: chainId, @@ -202,7 +212,10 @@ export class TradeQuoteAPI { toTokenAddress, fromTokenRequestAmount, manager, - isFirmQuote + isFirmQuote, + (slippagePercentage / 100), + feeRecipient, + excludedSources ); const fromTokenAmount = quote.sellAmount; @@ -222,7 +235,7 @@ export class TradeQuoteAPI { // BigNumber does not do fixed point math & FixedNumber underflows w/ numbers less than 1 // Multiply the slippage by a factor and divide the end result by same... const percentMultiplier = 1000; - const slippageToleranceBN = percentMultiplier * this.outputSlippageTolerance(slippagePercentage); + const slippageToleranceBN = Math.floor(percentMultiplier * this.outputSlippageTolerance(slippagePercentage)); const toTokenAmountMinusSlippage = toTokenAmount.mul(slippageToleranceBN).div(percentMultiplier); const toUnits = toTokenAmountMinusSlippage.mul(SCALE).div(setTotalSupply); diff --git a/src/api/utils/tradeQuote/zeroex.ts b/src/api/utils/tradeQuote/zeroex.ts index e08fa9e0..50181db0 100644 --- a/src/api/utils/tradeQuote/zeroex.ts +++ b/src/api/utils/tradeQuote/zeroex.ts @@ -41,11 +41,8 @@ export class ZeroExTradeQuoter { private swapQuoteRoute = '/swap/v1/quote'; private feePercentage: number = 0; - private feeRecipientAddress: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; private affiliateAddress: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; - private excludedSources: string[] = ['Kyber', 'Eth2Dai', 'Uniswap', 'Mesh']; private skipValidation: boolean = true; - private slippagePercentage: number = 0.02; constructor(options: ZeroExTradeQuoterOptions) { this.assert = new Assertions(); @@ -72,18 +69,21 @@ export class ZeroExTradeQuoter { sellAmount: BigNumber, takerAddress: Address, isFirm: boolean, + slippagePercentage, + feeRecipient, + excludedSources ): Promise { const url = `${this.host}${this.swapQuoteRoute}`; const params: ZeroExQueryParams = { sellToken: sellTokenAddress, buyToken: buyTokenAddress, - slippagePercentage: this.slippagePercentage, + slippagePercentage: slippagePercentage, sellAmount: sellAmount.toString(), takerAddress, - excludedSources: this.excludedSources.join(','), + excludedSources: excludedSources.join(','), skipValidation: this.skipValidation, - feeRecipient: this.feeRecipientAddress, + feeRecipient: feeRecipient, buyTokenPercentageFee: this.feePercentage, affiliateAddress: this.affiliateAddress, intentOnFilling: isFirm, diff --git a/src/types/utils.ts b/src/types/utils.ts index 25ea6434..c194a76c 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -41,9 +41,12 @@ export type QuoteOptions = { fromAddress: Address, chainId: number, tokenMap: CoinGeckoTokenMap, - slippagePercentage: number, + setToken: SetTokenAPI, + slippagePercentage?: number, isFirmQuote?: boolean, - feePercentage?: number + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], }; export type ZeroExQuote = { diff --git a/test/api/TradeQuoteAPI.spec.ts b/test/api/TradeQuoteAPI.spec.ts index 196e6ec5..a62e37f6 100644 --- a/test/api/TradeQuoteAPI.spec.ts +++ b/test/api/TradeQuoteAPI.spec.ts @@ -88,7 +88,7 @@ describe('TradeQuoteAPI', () => { beforeEach(async () => { coingecko = new CoinGeckoDataService(1); tokenMap = await coingecko.fetchTokenMap(); - tradeQuote = new TradeQuoteAPI(setTokenAPI, 'xyz'); + tradeQuote = new TradeQuoteAPI('xyz'); }); describe('generate a quote', () => { @@ -99,6 +99,7 @@ describe('TradeQuoteAPI', () => { let subjectChainId: number; let subjectSlippagePercentage: number; let subjectTokenMap: CoinGeckoTokenMap; + let subjectSetToken: SetTokenAPI; beforeEach(async () => { subjectFromToken = '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2'; // MKR @@ -108,6 +109,7 @@ describe('TradeQuoteAPI', () => { subjectChainId = 1; subjectSlippagePercentage = 2, subjectTokenMap = tokenMap; + subjectSetToken = setTokenAPI; }); async function subject(): Promise { @@ -119,6 +121,7 @@ describe('TradeQuoteAPI', () => { chainId: subjectChainId, slippagePercentage: subjectSlippagePercentage, tokenMap: subjectTokenMap, + setToken: subjectSetToken, }); } @@ -137,7 +140,7 @@ describe('TradeQuoteAPI', () => { beforeEach(async () => { coingecko = new CoinGeckoDataService(137); tokenMap = await coingecko.fetchTokenMap(); - tradeQuote = new TradeQuoteAPI(setTokenAPI, 'xyz'); + tradeQuote = new TradeQuoteAPI('xyz'); }); describe('generate a quote', () => { @@ -148,6 +151,7 @@ describe('TradeQuoteAPI', () => { let subjectChainId: number; let subjectSlippagePercentage: number; let subjectTokenMap: CoinGeckoTokenMap; + let subjectSetToken: SetTokenAPI; beforeEach(async () => { subjectFromToken = '0x2791bca1f2de4661ed88a30c99a7a9449aa84174'; // USDC @@ -157,6 +161,7 @@ describe('TradeQuoteAPI', () => { subjectChainId = 137; subjectSlippagePercentage = 2, subjectTokenMap = tokenMap; + subjectSetToken = setTokenAPI; }); async function subject(): Promise { @@ -168,6 +173,7 @@ describe('TradeQuoteAPI', () => { chainId: subjectChainId, slippagePercentage: subjectSlippagePercentage, tokenMap: subjectTokenMap, + setToken: subjectSetToken, }); } From 4e8a0f14a2c8c624d63cf476219dc086fabbb2fe Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 11:49:25 -0700 Subject: [PATCH 06/23] Re-order files and integrate utils into TradeAPI --- src/Set.ts | 11 +-- src/api/TradeAPI.ts | 89 +++++++++++++++++++- src/api/index.ts | 10 ++- src/api/utils/{tradeQuote => }/coingecko.ts | 4 +- src/api/utils/{tradeQuote => }/gasOracle.ts | 4 +- src/api/utils/index.ts | 5 +- src/api/utils/tradeQuote/index.ts | 4 - src/api/utils/{tradeQuote => }/tradequote.ts | 8 +- src/api/utils/{tradeQuote => }/zeroex.ts | 4 +- src/types/utils.ts | 1 + test/api/TradeQuoteAPI.spec.ts | 14 +-- 11 files changed, 118 insertions(+), 36 deletions(-) rename src/api/utils/{tradeQuote => }/coingecko.ts (99%) rename src/api/utils/{tradeQuote => }/gasOracle.ts (97%) delete mode 100644 src/api/utils/tradeQuote/index.ts rename src/api/utils/{tradeQuote => }/tradequote.ts (99%) rename src/api/utils/{tradeQuote => }/zeroex.ts (97%) diff --git a/src/Set.ts b/src/Set.ts index c982e813..51e4b351 100644 --- a/src/Set.ts +++ b/src/Set.ts @@ -29,7 +29,6 @@ import { NavIssuanceAPI, PriceOracleAPI, DebtIssuanceAPI, - TradeQuoteAPI, } from './api/index'; const ethersProviders = require('ethers').providers; @@ -103,13 +102,6 @@ class Set { */ public blockchain: BlockchainAPI; - - /** - * An instance of the TradeQuoteAPI class. Contains interfaces for - * getting a trade quote from 0x exchange API on Ethereum or Polygon networks - */ - public tradeQuote: TradeQuoteAPI; - /** * Instantiates a new Set instance that provides the public interface to the Set.js library */ @@ -131,12 +123,11 @@ class Set { assertions ); this.system = new SystemAPI(ethersProvider, config.controllerAddress); - this.trade = new TradeAPI(ethersProvider, config.tradeModuleAddress); + this.trade = new TradeAPI(ethersProvider, config.tradeModuleAddress, config.zeroExApiKey); this.navIssuance = new NavIssuanceAPI(ethersProvider, config.navIssuanceModuleAddress); this.priceOracle = new PriceOracleAPI(ethersProvider, config.masterOracleAddress); this.debtIssuance = new DebtIssuanceAPI(ethersProvider, config.debtIssuanceModuleAddress); this.blockchain = new BlockchainAPI(ethersProvider, assertions); - this.tradeQuote = new TradeQuoteAPI(this.setToken, config.zeroExApiKey); } } diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index 68e1a9ab..f0f06ac7 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -23,8 +23,23 @@ import { TransactionOverrides } from '@setprotocol/set-protocol-v2/dist/typechai import { BigNumber } from 'ethers/lib/ethers'; import TradeModuleWrapper from '../wrappers/set-protocol-v2/TradeModuleWrapper'; +import SetTokenAPI from './SetTokenAPI'; import Assertions from '../assertions'; +import { + TradeQuoter, + CoinGeckoDataService, + GasOracleService +} from './utils'; + +import { + TradeQuote, + CoinGeckoTokenData, + CoinGeckoTokenMap, + GasOracleSpeed, + CoinGeckoCoinPrices +} from '../types'; + /** * @title TradeAPI * @author Set Protocol @@ -36,14 +51,20 @@ import Assertions from '../assertions'; export default class TradeAPI { private tradeModuleWrapper: TradeModuleWrapper; private assert: Assertions; + private provider: Provider; + private tradeQuoter: TradeQuoter; + private coinGecko: CoinGeckoDataService; + private chainId: number; public constructor( provider: Provider, tradeModuleAddress: Address, - assertions?: Assertions + zeroExApiKey?: string, ) { + this.provider = provider; this.tradeModuleWrapper = new TradeModuleWrapper(provider, tradeModuleAddress); - this.assert = assertions || new Assertions(); + this.assert = new Assertions(); + this.tradeQuoter = new TradeQuoter(zeroExApiKey); } /** @@ -113,4 +134,68 @@ export default class TradeAPI { txOpts ); } + + public async fetchTradeQuoteAsync( + fromToken: Address, + toToken: Address, + rawAmount: string, + fromAddress: Address, + setToken: SetTokenAPI, + tokenMap?: CoinGeckoTokenMap, + slippagePercentage?: number, + isFirmQuote?: boolean, + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], + ): Promise { + await this.initializeForChain(); + const _tokenMap = (tokenMap) ? tokenMap : await this.coinGecko.fetchTokenMap(); + + return this.tradeQuoter.generate({ + fromToken, + toToken, + rawAmount, + fromAddress, + chainId: this.chainId, + tokenMap: _tokenMap, + setToken, + slippagePercentage, + isFirmQuote, + feePercentage, + feeRecipient, + excludedSources, + }); + } + + public async fetchTokenList(): Promise { + await this.initializeForChain(); + return this.coinGecko.fetchTokenList(); + } + + public async fetchTokenMap(): Promise { + await this.initializeForChain(); + return this.coinGecko.fetchTokenMap(); + } + + public async fetchCoinPrices( + contractAddresses: string[], + vsCurrencies: string[] + ): Promise { + await this.initializeForChain(); + return this.coinGecko.fetchCoinPrices({contractAddresses, vsCurrencies}); + } + + public async fetchGasPrice(speed: GasOracleSpeed): Promise { + await this.initializeForChain(); + const oracle = new GasOracleService(this.chainId); + return oracle.fetchGasPrice(speed); + } + + private async initializeForChain() { + if (this.coinGecko === undefined) { + const network = await this.provider.getNetwork(); + this.chainId = network.chainId; + this.coinGecko = new CoinGeckoDataService(network.chainId); + } + } } diff --git a/src/api/index.ts b/src/api/index.ts index 57153097..d792b6ea 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,7 +8,11 @@ import TradeAPI from './TradeAPI'; import NavIssuanceAPI from './NavIssuanceAPI'; import PriceOracleAPI from './PriceOracleAPI'; import DebtIssuanceAPI from './DebtIssuanceAPI'; -import { TradeQuoteAPI } from './utils'; +import { + TradeQuoter, + CoinGeckoDataService, + GasOracleService +} from './utils'; export { BlockchainAPI, @@ -21,5 +25,7 @@ export { NavIssuanceAPI, PriceOracleAPI, DebtIssuanceAPI, - TradeQuoteAPI + TradeQuoter, + CoinGeckoDataService, + GasOracleService }; \ No newline at end of file diff --git a/src/api/utils/tradeQuote/coingecko.ts b/src/api/utils/coingecko.ts similarity index 99% rename from src/api/utils/tradeQuote/coingecko.ts rename to src/api/utils/coingecko.ts index 85a232f0..7ff5ed79 100644 --- a/src/api/utils/tradeQuote/coingecko.ts +++ b/src/api/utils/coingecko.ts @@ -19,7 +19,7 @@ const pageResults = require('graph-results-pager'); import axios from 'axios'; -import Assertions from '../../../assertions'; +import Assertions from '../../assertions'; import { CoinGeckoCoinPrices, @@ -28,7 +28,7 @@ import { CoinGeckoTokenMap, CoinPricesParams, PolygonMappedTokenData -} from '../../../types'; +} from '../../types'; /** * These currency codes can be used for the vs_currencies parameter of the service's diff --git a/src/api/utils/tradeQuote/gasOracle.ts b/src/api/utils/gasOracle.ts similarity index 97% rename from src/api/utils/tradeQuote/gasOracle.ts rename to src/api/utils/gasOracle.ts index cacb6359..5e30f224 100644 --- a/src/api/utils/tradeQuote/gasOracle.ts +++ b/src/api/utils/gasOracle.ts @@ -16,12 +16,12 @@ 'use strict'; import axios from 'axios'; -import Assertions from '../../../assertions'; +import Assertions from '../../assertions'; import { GasNowData, GasOracleSpeed, -} from '../../../types'; +} from '../../types'; /** * @title GasOracleService diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 318b8e05..4f02f9cd 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -1 +1,4 @@ -export { TradeQuoteAPI } from './tradeQuote'; +export * from './tradequote'; +export * from './coingecko'; +export * from './gasOracle'; +export * from './zeroex'; diff --git a/src/api/utils/tradeQuote/index.ts b/src/api/utils/tradeQuote/index.ts deleted file mode 100644 index 4f02f9cd..00000000 --- a/src/api/utils/tradeQuote/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './tradequote'; -export * from './coingecko'; -export * from './gasOracle'; -export * from './zeroex'; diff --git a/src/api/utils/tradeQuote/tradequote.ts b/src/api/utils/tradequote.ts similarity index 99% rename from src/api/utils/tradeQuote/tradequote.ts rename to src/api/utils/tradequote.ts index 4150033e..b3295f04 100644 --- a/src/api/utils/tradeQuote/tradequote.ts +++ b/src/api/utils/tradequote.ts @@ -25,23 +25,23 @@ import { QuoteOptions, TradeQuote, TokenResponse, -} from '../../../types/index'; +} from '../../types/index'; import { CoinGeckoDataService, USD_CURRENCY_CODE } from './coingecko'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { GasOracleService } from './gasOracle'; import { ZeroExTradeQuoter } from './zeroex'; export const ZERO_EX_ADAPTER_NAME = 'ZeroExApiAdapterV3'; -import { Address } from '@setprotocol/set-protocol-v2/utils/types'; const SCALE = BigNumber.from(10).pow(18); /** - * @title TradeQuoteAPI + * @title TradeQuote * @author Set Protocol * * A utility library to generate trade quotes for token pairs associated with a @@ -49,7 +49,7 @@ const SCALE = BigNumber.from(10).pow(18); * 0x api key. */ -export class TradeQuoteAPI { +export class TradeQuoter { private tokenMap: CoinGeckoTokenMap; private largeTradeGasCostBase: number = 150000; private tradeQuoteGasBuffer: number = 5; diff --git a/src/api/utils/tradeQuote/zeroex.ts b/src/api/utils/zeroex.ts similarity index 97% rename from src/api/utils/tradeQuote/zeroex.ts rename to src/api/utils/zeroex.ts index 50181db0..a938f9d0 100644 --- a/src/api/utils/tradeQuote/zeroex.ts +++ b/src/api/utils/zeroex.ts @@ -18,13 +18,13 @@ import axios from 'axios'; import { BigNumber } from 'ethers'; -import Assertions from '../../../assertions'; +import Assertions from '../../assertions'; import { ZeroExTradeQuoterOptions, ZeroExTradeQuote, ZeroExQueryParams -} from '../../../types/index'; +} from '../../types/index'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; diff --git a/src/types/utils.ts b/src/types/utils.ts index c194a76c..5a977ab6 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,5 +1,6 @@ import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { BigNumber } from 'ethers/lib/ethers'; +import type SetTokenAPI from '../api/SetTokenAPI'; export type CurrencyCodePriceMap = { [key: string]: number diff --git a/test/api/TradeQuoteAPI.spec.ts b/test/api/TradeQuoteAPI.spec.ts index a62e37f6..36e4b6bf 100644 --- a/test/api/TradeQuoteAPI.spec.ts +++ b/test/api/TradeQuoteAPI.spec.ts @@ -19,7 +19,7 @@ import { ethers } from 'ethers'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { CoinGeckoTokenMap, TradeQuote } from '@src/types'; import SetTokenAPI from '@src/api/SetTokenAPI'; -import { TradeQuoteAPI, CoinGeckoDataService } from '@src/api/utils/tradeQuote'; +import { TradeQuoter, CoinGeckoDataService } from '@src/api/utils'; import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; import { expect } from '@test/utils/chai'; @@ -81,14 +81,14 @@ describe('TradeQuoteAPI', () => { }); describe('mainnet', () => { - let tradeQuote: TradeQuoteAPI; let coingecko: CoinGeckoDataService; let tokenMap: CoinGeckoTokenMap; + let tradeQuoter: TradeQuoter; beforeEach(async () => { coingecko = new CoinGeckoDataService(1); tokenMap = await coingecko.fetchTokenMap(); - tradeQuote = new TradeQuoteAPI('xyz'); + tradeQuoter = new TradeQuoter('xyz'); }); describe('generate a quote', () => { @@ -113,7 +113,7 @@ describe('TradeQuoteAPI', () => { }); async function subject(): Promise { - return await tradeQuote.generate({ + return await tradeQuoter.generate({ fromToken: subjectFromToken, toToken: subjectToToken, rawAmount: subjectRawAmount, @@ -133,14 +133,14 @@ describe('TradeQuoteAPI', () => { }); describe('polygon', () => { - let tradeQuote: TradeQuoteAPI; let coingecko: CoinGeckoDataService; let tokenMap: CoinGeckoTokenMap; + let tradeQuoter: TradeQuoter; beforeEach(async () => { coingecko = new CoinGeckoDataService(137); tokenMap = await coingecko.fetchTokenMap(); - tradeQuote = new TradeQuoteAPI('xyz'); + tradeQuoter = new TradeQuoter('xyz'); }); describe('generate a quote', () => { @@ -165,7 +165,7 @@ describe('TradeQuoteAPI', () => { }); async function subject(): Promise { - return await tradeQuote.generate({ + return await tradeQuoter.generate({ fromToken: subjectFromToken, toToken: subjectToToken, rawAmount: subjectRawAmount, From d7c7de7c28ba38e0adb7826a2bc366a6f08e05ec Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 13:36:59 -0700 Subject: [PATCH 07/23] Remove tokenMap dependency / add fetchTradeQuote arg validation --- src/api/TradeAPI.ts | 14 ++-- src/api/utils/tradequote.ts | 65 ++++++++++--------- src/types/utils.ts | 12 +--- ...deQuoteAPI.spec.ts => TradeQuoter.spec.ts} | 20 ++++-- test/fixtures/tradeQuote.ts | 20 ------ 5 files changed, 58 insertions(+), 73 deletions(-) rename test/api/{TradeQuoteAPI.spec.ts => TradeQuoter.spec.ts} (89%) diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index f0f06ac7..617a7069 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -138,26 +138,32 @@ export default class TradeAPI { public async fetchTradeQuoteAsync( fromToken: Address, toToken: Address, + fromTokenDecimals: number, + toTokenDecimals: number, rawAmount: string, fromAddress: Address, setToken: SetTokenAPI, - tokenMap?: CoinGeckoTokenMap, slippagePercentage?: number, isFirmQuote?: boolean, feePercentage?: number, feeRecipient?: Address, excludedSources?: string[], ): Promise { - await this.initializeForChain(); - const _tokenMap = (tokenMap) ? tokenMap : await this.coinGecko.fetchTokenMap(); + this.assert.schema.isValidAddress('fromToken', fromToken); + this.assert.schema.isValidAddress('toToken', toToken); + this.assert.schema.isValidAddress('fromAddress', fromAddress); + this.assert.schema.isValidNumber('fromTokenDecimals', fromTokenDecimals); + this.assert.schema.isValidNumber('toTokenDecimals', toTokenDecimals); + this.assert.schema.isValidNumber('rawAmount', rawAmount); return this.tradeQuoter.generate({ fromToken, toToken, + fromTokenDecimals, + toTokenDecimals, rawAmount, fromAddress, chainId: this.chainId, - tokenMap: _tokenMap, setToken, slippagePercentage, isFirmQuote, diff --git a/src/api/utils/tradequote.ts b/src/api/utils/tradequote.ts index b3295f04..3fcf3205 100644 --- a/src/api/utils/tradequote.ts +++ b/src/api/utils/tradequote.ts @@ -21,10 +21,8 @@ import BigDecimal from 'js-big-decimal'; import { CoinGeckoCoinPrices, - CoinGeckoTokenMap, QuoteOptions, TradeQuote, - TokenResponse, } from '../../types/index'; import { @@ -50,7 +48,6 @@ const SCALE = BigNumber.from(10).pow(18); */ export class TradeQuoter { - private tokenMap: CoinGeckoTokenMap; private largeTradeGasCostBase: number = 150000; private tradeQuoteGasBuffer: number = 5; private feeRecipient: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; @@ -73,8 +70,6 @@ export class TradeQuoter { * @return TradeQuote: trade quote object */ async generate(options: QuoteOptions): Promise { - this.tokenMap = options.tokenMap; - const chainId = options.chainId; const feePercentage = options.feePercentage || this.feePercentage; const isFirmQuote = (options.isFirmQuote === false) ? false : this.isFirmQuote; @@ -90,7 +85,7 @@ export class TradeQuoter { fromAddress, } = this.sanitizeAddress(options.fromToken, options.toToken, options.fromAddress); - const amount = this.sanitizeAmount(fromTokenAddress, options.rawAmount); + const amount = this.sanitizeAmount(options.rawAmount, options.fromTokenDecimals); const setOnChainDetails = await options.setToken.fetchSetDetailsAsync( fromAddress, [fromTokenAddress, toTokenAddress] @@ -157,12 +152,20 @@ export class TradeQuoter { inputAmountRaw: options.rawAmount.toString(), inputAmount: amount.toString(), quoteAmount: fromTokenRequestAmount.toString(), - fromTokenDisplayAmount: this.tokenDisplayAmount(fromTokenAmount, fromTokenAddress), - toTokenDisplayAmount: this.tokenDisplayAmount(toTokenAmount, toTokenAddress), - fromTokenPriceUsd: this.tokenPriceUsd(fromTokenAmount, fromTokenAddress, coinPrices), - toTokenPriceUsd: this.tokenPriceUsd(toTokenAmount, toTokenAddress, coinPrices), - toToken: this.tokenResponse(toTokenAddress), - fromToken: this.tokenResponse(fromTokenAddress), + fromTokenDisplayAmount: this.tokenDisplayAmount(fromTokenAmount, options.fromTokenDecimals), + toTokenDisplayAmount: this.tokenDisplayAmount(toTokenAmount, options.toTokenDecimals), + fromTokenPriceUsd: this.tokenPriceUsd( + fromTokenAmount, + fromTokenAddress, + options.fromTokenDecimals, + coinPrices + ), + toTokenPriceUsd: this.tokenPriceUsd( + toTokenAmount, + toTokenAddress, + options.toTokenDecimals, + coinPrices + ), gasCostsUsd: this.gasCostsUsd(gasPrice, gas, coinPrices, chainId), gasCostsChainCurrency: this.gasCostsChainCurrency(gasPrice, gas, chainId), feePercentage: this.formatAsPercentage(feePercentage), @@ -171,6 +174,8 @@ export class TradeQuoter { toTokenAmount, fromTokenAddress, toTokenAddress, + options.fromTokenDecimals, + options.toTokenDecimals, coinPrices ), }, @@ -185,8 +190,7 @@ export class TradeQuoter { }; } - private sanitizeAmount(fromTokenAddress: Address, rawAmount: string): BigNumber { - const decimals = this.tokenMap[fromTokenAddress].decimals; + private sanitizeAmount(rawAmount: string, decimals: number): BigNumber { return ethersUtils.parseUnits(rawAmount, decimals); } @@ -313,18 +317,8 @@ export class TradeQuoter { } } - private tokenDisplayAmount(amount: BigNumber, address: Address): string { - return this.normalizeTokenAmount(amount, address).toString(); - } - - private tokenResponse(address: Address): TokenResponse { - const tokenEntry = this.tokenMap[address]; - return { - symbol: tokenEntry.symbol, - name: tokenEntry.name, - address, - decimals: tokenEntry.decimals, - }; + private tokenDisplayAmount(amount: BigNumber, decimals: number): string { + return this.normalizeTokenAmount(amount, decimals).toString(); } private chainCurrencyAddress(chainId: number): Address { @@ -335,14 +329,19 @@ export class TradeQuoter { } } - private normalizeTokenAmount(amount: BigNumber, address: Address): number { - const tokenScale = BigNumber.from(10).pow(this.tokenMap[address].decimals); + private normalizeTokenAmount(amount: BigNumber, decimals: number): number { + const tokenScale = BigNumber.from(10).pow(decimals); return FixedNumber.from(amount).divUnsafe(FixedNumber.from(tokenScale)).toUnsafeFloat(); } - private tokenPriceUsd(amount: BigNumber, address: Address, coinPrices: CoinGeckoCoinPrices): string { + private tokenPriceUsd( + amount: BigNumber, + address: Address, + decimals: number, + coinPrices: CoinGeckoCoinPrices + ): string { const coinPrice = coinPrices[address][USD_CURRENCY_CODE]; - const normalizedAmount = this.normalizeTokenAmount(amount, address) * coinPrice; + const normalizedAmount = this.normalizeTokenAmount(amount, decimals) * coinPrice; return new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(normalizedAmount); } @@ -399,13 +398,15 @@ export class TradeQuoter { toTokenAmount: BigNumber, fromTokenAddress: Address, toTokenAddress: Address, + fromTokenDecimals: number, + toTokenDecimals: number, coinPrices: CoinGeckoCoinPrices ): string { const fromTokenPriceUsd = coinPrices[fromTokenAddress][USD_CURRENCY_CODE]; const toTokenPriceUsd = coinPrices[toTokenAddress][USD_CURRENCY_CODE]; - const fromTokenTotalUsd = this.normalizeTokenAmount(fromTokenAmount, fromTokenAddress) * fromTokenPriceUsd; - const toTokenTotalUsd = this.normalizeTokenAmount(toTokenAmount, toTokenAddress) * toTokenPriceUsd; + const fromTokenTotalUsd = this.normalizeTokenAmount(fromTokenAmount, fromTokenDecimals) * fromTokenPriceUsd; + const toTokenTotalUsd = this.normalizeTokenAmount(toTokenAmount, toTokenDecimals) * toTokenPriceUsd; const slippageRaw = (fromTokenTotalUsd - toTokenTotalUsd) / fromTokenTotalUsd; return this.formatAsPercentage(slippageRaw * 100); diff --git a/src/types/utils.ts b/src/types/utils.ts index 5a977ab6..08cf5588 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -38,10 +38,11 @@ export type PolygonMappedTokenData = { export type QuoteOptions = { fromToken: Address, toToken: Address, + fromTokenDecimals: number, + toTokenDecimals: number, rawAmount: string, fromAddress: Address, chainId: number, - tokenMap: CoinGeckoTokenMap, setToken: SetTokenAPI, slippagePercentage?: number, isFirmQuote?: boolean, @@ -59,13 +60,6 @@ export type ZeroExQuote = { zeroExGas: number }; -export type TokenResponse = { - symbol: string, - name: string, - address: Address, - decimals: number -}; - export type TradeQuote = { from: Address, fromTokenAddress: Address, @@ -85,8 +79,6 @@ export type TradeQuote = { toTokenDisplayAmount: string, fromTokenPriceUsd: string, toTokenPriceUsd: string, - toToken: TokenResponse, - fromToken: TokenResponse, gasCostsUsd: string, gasCostsChainCurrency: string, feePercentage: string, diff --git a/test/api/TradeQuoteAPI.spec.ts b/test/api/TradeQuoter.spec.ts similarity index 89% rename from test/api/TradeQuoteAPI.spec.ts rename to test/api/TradeQuoter.spec.ts index 36e4b6bf..e41f65d8 100644 --- a/test/api/TradeQuoteAPI.spec.ts +++ b/test/api/TradeQuoter.spec.ts @@ -94,21 +94,23 @@ describe('TradeQuoteAPI', () => { describe('generate a quote', () => { let subjectFromToken: Address; let subjectToToken: Address; + let subjectFromTokenDecimals: number; + let subjectToTokenDecimals: number; let subjectRawAmount: string; let subjectSetTokenAddress: Address; let subjectChainId: number; let subjectSlippagePercentage: number; - let subjectTokenMap: CoinGeckoTokenMap; let subjectSetToken: SetTokenAPI; beforeEach(async () => { subjectFromToken = '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2'; // MKR - subjectToToken = '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e'; // YFI + subjectToToken = '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e'; // YFI + subjectFromTokenDecimals = tokenMap[subjectFromToken].decimals; + subjectToTokenDecimals = tokenMap[subjectToToken].decimals; subjectSetTokenAddress = DPI_ETH; // DPI subjectRawAmount = '.5'; subjectChainId = 1; subjectSlippagePercentage = 2, - subjectTokenMap = tokenMap; subjectSetToken = setTokenAPI; }); @@ -116,11 +118,12 @@ describe('TradeQuoteAPI', () => { return await tradeQuoter.generate({ fromToken: subjectFromToken, toToken: subjectToToken, + fromTokenDecimals: subjectFromTokenDecimals, + toTokenDecimals: subjectToTokenDecimals, rawAmount: subjectRawAmount, fromAddress: subjectSetTokenAddress, chainId: subjectChainId, slippagePercentage: subjectSlippagePercentage, - tokenMap: subjectTokenMap, setToken: subjectSetToken, }); } @@ -146,21 +149,23 @@ describe('TradeQuoteAPI', () => { describe('generate a quote', () => { let subjectFromToken: Address; let subjectToToken: Address; + let subjectFromTokenDecimals: number; + let subjectToTokenDecimals: number; let subjectRawAmount: string; let subjectSetTokenAddress: Address; let subjectChainId: number; let subjectSlippagePercentage: number; - let subjectTokenMap: CoinGeckoTokenMap; let subjectSetToken: SetTokenAPI; beforeEach(async () => { subjectFromToken = '0x2791bca1f2de4661ed88a30c99a7a9449aa84174'; // USDC subjectToToken = '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6'; // WBTC + subjectFromTokenDecimals = tokenMap[subjectFromToken].decimals; + subjectToTokenDecimals = tokenMap[subjectToToken].decimals; subjectSetTokenAddress = BUD_POLY; // BUD subjectRawAmount = '1'; subjectChainId = 137; subjectSlippagePercentage = 2, - subjectTokenMap = tokenMap; subjectSetToken = setTokenAPI; }); @@ -168,11 +173,12 @@ describe('TradeQuoteAPI', () => { return await tradeQuoter.generate({ fromToken: subjectFromToken, toToken: subjectToToken, + fromTokenDecimals: subjectFromTokenDecimals, + toTokenDecimals: subjectToTokenDecimals, rawAmount: subjectRawAmount, fromAddress: subjectSetTokenAddress, chainId: subjectChainId, slippagePercentage: subjectSlippagePercentage, - tokenMap: subjectTokenMap, setToken: subjectSetToken, }); } diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 08fd9f3d..d50589e6 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -190,16 +190,6 @@ export const tradeQuoteFixtures = { toTokenDisplayAmount: '0.04131269116050703', fromTokenPriceUsd: '$1,597.20', toTokenPriceUsd: '$1,614.79', - toToken: - { symbol: 'MKR', - name: 'Maker', - address: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', - decimals: 18 }, - fromToken: - { symbol: 'YFI', - name: 'Yearn', - address: '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', - decimals: 18 }, gasCostsUsd: '$79.20', gasCostsChainCurrency: '0.0317688 ETH', feePercentage: '0.00%', @@ -289,16 +279,6 @@ export const tradeQuoteFixtures = { toTokenDisplayAmount: '0.00002973', fromTokenPriceUsd: '$1.01', toTokenPriceUsd: '$1.00', - toToken: - { symbol: 'WBTC', - name: '(PoS) Wrapped BTC', - address: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', - decimals: 8 }, - fromToken: - { symbol: 'USDC', - name: 'USD Coin (PoS)', - address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', - decimals: 6 }, gasCostsUsd: '$0.003051', gasCostsChainCurrency: '0.0020475 MATIC', feePercentage: '0.00%', From d317dc3f9c1aa395da8de422614ffe578be9b3c4 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 13:44:03 -0700 Subject: [PATCH 08/23] Add gasPrice option to fetchTradeAsync / tradeQuote.generate --- src/api/TradeAPI.ts | 2 ++ src/api/utils/tradequote.ts | 12 +++++++----- src/types/utils.ts | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index 617a7069..ba998dcc 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -143,6 +143,7 @@ export default class TradeAPI { rawAmount: string, fromAddress: Address, setToken: SetTokenAPI, + gasPrice?: number, slippagePercentage?: number, isFirmQuote?: boolean, feePercentage?: number, @@ -165,6 +166,7 @@ export default class TradeAPI { fromAddress, chainId: this.chainId, setToken, + gasPrice, slippagePercentage, isFirmQuote, feePercentage, diff --git a/src/api/utils/tradequote.ts b/src/api/utils/tradequote.ts index 3fcf3205..08d3f68e 100644 --- a/src/api/utils/tradequote.ts +++ b/src/api/utils/tradequote.ts @@ -134,8 +134,10 @@ export class TradeQuoter { vsCurrencies: [ USD_CURRENCY_CODE, USD_CURRENCY_CODE, USD_CURRENCY_CODE ], }); - const gasOracle = new GasOracleService(chainId); - const gasPrice = await gasOracle.fetchGasPrice(); + if (!options.gasPrice) { + const gasOracle = new GasOracleService(chainId); + options.gasPrice = await gasOracle.fetchGasPrice(); + } return { from: fromAddress, @@ -144,7 +146,7 @@ export class TradeQuoter { exchangeAdapterName, calldata, gas: gas.toString(), - gasPrice: gasPrice.toString(), + gasPrice: options.gasPrice.toString(), slippagePercentage: this.formatAsPercentage(options.slippagePercentage), fromTokenAmount: fromUnits.toString(), toTokenAmount: toUnits.toString(), @@ -166,8 +168,8 @@ export class TradeQuoter { options.toTokenDecimals, coinPrices ), - gasCostsUsd: this.gasCostsUsd(gasPrice, gas, coinPrices, chainId), - gasCostsChainCurrency: this.gasCostsChainCurrency(gasPrice, gas, chainId), + gasCostsUsd: this.gasCostsUsd(options.gasPrice, gas, coinPrices, chainId), + gasCostsChainCurrency: this.gasCostsChainCurrency(options.gasPrice, gas, chainId), feePercentage: this.formatAsPercentage(feePercentage), slippage: this.calculateSlippage( fromTokenAmount, diff --git a/src/types/utils.ts b/src/types/utils.ts index 08cf5588..3ee0894c 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -44,6 +44,7 @@ export type QuoteOptions = { fromAddress: Address, chainId: number, setToken: SetTokenAPI, + gasPrice?: number, slippagePercentage?: number, isFirmQuote?: boolean, feePercentage?: number, From 9ccac88d7f45883614f5d33593e9048ef582d4af Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 16:06:52 -0700 Subject: [PATCH 09/23] Estimate gas via tradeModuleWrapper --- src/api/TradeAPI.ts | 2 + src/api/utils/tradequote.ts | 50 +++++++++++++++---- src/api/utils/zeroex.ts | 1 - src/types/utils.ts | 8 ++- .../set-protocol-v2/ContractWrapper.ts | 10 ++++ .../set-protocol-v2/TradeModuleWrapper.ts | 45 +++++++++++++++++ test/api/TradeQuoter.spec.ts | 15 +++++- test/fixtures/tradeQuote.ts | 12 ++--- 8 files changed, 124 insertions(+), 19 deletions(-) diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index ba998dcc..486314c4 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -165,6 +165,8 @@ export default class TradeAPI { rawAmount, fromAddress, chainId: this.chainId, + tradeModule: this.tradeModuleWrapper, + provider: this.provider, setToken, gasPrice, slippagePercentage, diff --git a/src/api/utils/tradequote.ts b/src/api/utils/tradequote.ts index 08d3f68e..8d1914b3 100644 --- a/src/api/utils/tradequote.ts +++ b/src/api/utils/tradequote.ts @@ -16,8 +16,9 @@ 'use strict'; -import { BigNumber, FixedNumber, utils as ethersUtils } from 'ethers'; import BigDecimal from 'js-big-decimal'; +import { BigNumber, FixedNumber, utils as ethersUtils } from 'ethers'; +import type TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; import { CoinGeckoCoinPrices, @@ -48,7 +49,6 @@ const SCALE = BigNumber.from(10).pow(18); */ export class TradeQuoter { - private largeTradeGasCostBase: number = 150000; private tradeQuoteGasBuffer: number = 5; private feeRecipient: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; private feePercentage: number = 0; @@ -103,7 +103,6 @@ export class TradeQuoter { toTokenAmount, toUnits, calldata, - zeroExGas, } = await this.fetchZeroExQuote( // fetchQuote (and switch...) fromTokenAddress, toTokenAddress, @@ -126,7 +125,17 @@ export class TradeQuoter { toUnits ); - const gas = this.estimateGasCost(zeroExGas); + const gas = await this.estimateGasCost( + options.tradeModule, + fromTokenAddress, + fromUnits, + toTokenAddress, + toUnits, + exchangeAdapterName, + fromAddress, + calldata, + setOnChainDetails.manager + ); const coinGecko = new CoinGeckoDataService(chainId); const coinPrices = await coinGecko.fetchCoinPrices({ @@ -251,7 +260,6 @@ export class TradeQuoter { toTokenAmount, toUnits, calldata: quote.calldata, - zeroExGas: quote.gas, }; } @@ -389,10 +397,34 @@ export class TradeQuoter { } } - private estimateGasCost(zeroExGas: number): number { - const gas = zeroExGas + this.largeTradeGasCostBase; - const gasCostBuffer = (100 + this.tradeQuoteGasBuffer) / 100; - return Math.floor(gas * gasCostBuffer); + private async estimateGasCost( + tradeModule: TradeModuleWrapper, + fromTokenAddress: Address, + fromTokenUnits: BigNumber, + toTokenAddress: Address, + toTokenUnits: BigNumber, + adapterName: string, + fromAddress: Address, + calldata: string, + managerAddress: Address + ): Promise { + try { + const gas = await tradeModule.estimateGasForTradeAsync( + fromAddress, + adapterName, + fromTokenAddress, + fromTokenUnits, + toTokenAddress, + toTokenUnits, + calldata, + managerAddress + ); + const gasCostBuffer = (100 + this.tradeQuoteGasBuffer) / 100; + return Math.floor(gas.toNumber() * gasCostBuffer); + } catch (error) { + console.log('error --> ' + error); + throw new Error('Unable to fetch gas cost estimate for trade'); + } } private calculateSlippage( diff --git a/src/api/utils/zeroex.ts b/src/api/utils/zeroex.ts index a938f9d0..57ee2d18 100644 --- a/src/api/utils/zeroex.ts +++ b/src/api/utils/zeroex.ts @@ -105,7 +105,6 @@ export class ZeroExTradeQuoter { sellAmount: BigNumber.from(response.data.sellAmount), buyAmount: BigNumber.from(response.data.buyAmount), calldata: response.data.data, - gas: parseInt(response.data.gas), }; } catch (error) { throw new Error('ZeroEx quote request failed: ' + error); diff --git a/src/types/utils.ts b/src/types/utils.ts index 3ee0894c..ce46f1bb 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,6 +1,9 @@ import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { BigNumber } from 'ethers/lib/ethers'; +import type { Provider } from '@ethersproject/providers'; import type SetTokenAPI from '../api/SetTokenAPI'; +import type TradeModuleWrapper from '../wrappers/set-protocol-v2/TradeModuleWrapper'; + export type CurrencyCodePriceMap = { [key: string]: number @@ -44,6 +47,8 @@ export type QuoteOptions = { fromAddress: Address, chainId: number, setToken: SetTokenAPI, + tradeModule: TradeModuleWrapper, + provider: Provider, gasPrice?: number, slippagePercentage?: number, isFirmQuote?: boolean, @@ -111,8 +116,7 @@ export type ZeroExTradeQuote = { price: number, sellAmount: BigNumber, buyAmount: BigNumber, - calldata: string, - gas: number + calldata: string }; export type GasNowData = { diff --git a/src/wrappers/set-protocol-v2/ContractWrapper.ts b/src/wrappers/set-protocol-v2/ContractWrapper.ts index 2b46b761..fcc62449 100644 --- a/src/wrappers/set-protocol-v2/ContractWrapper.ts +++ b/src/wrappers/set-protocol-v2/ContractWrapper.ts @@ -168,6 +168,16 @@ export default class ContractWrapper { } } + /** + * Load TradeModule contract without signer (for running populateTransaction) + * + * @param tradeModuleAddress Address of the trade module + * @return TradeModule contract instance + */ + public loadTradeModuleWithoutSigner(tradeModuleAddress: Address): TradeModule { + return TradeModule__factory.connect(tradeModuleAddress); + } + /** * Load NavIssuanceModule contract * diff --git a/src/wrappers/set-protocol-v2/TradeModuleWrapper.ts b/src/wrappers/set-protocol-v2/TradeModuleWrapper.ts index 826d8c6e..74d2c02d 100644 --- a/src/wrappers/set-protocol-v2/TradeModuleWrapper.ts +++ b/src/wrappers/set-protocol-v2/TradeModuleWrapper.ts @@ -111,4 +111,49 @@ export default class TradeModuleWrapper { txOptions ); } + + /** + * Estimate gas cost for executing a trade on a supported DEX. + * + * @dev Although the SetToken units are passed in for the send and receive quantities, the total quantity + * sent and received is the quantity of SetToken units multiplied by the SetToken totalSupply. + * + * @param setTokenAddress Address of the SetToken to trade + * @param exchangeName Human readable name of the exchange in the integrations registry + * @param sendTokenAddress Address of the token to be sent to the exchange + * @param sendQuantity Units of token in SetToken sent to the exchange + * @param receiveTokenAddress Address of the token that will be received from the exchange + * @param minReceiveQuantity Min units of token in SetToken to be received from the exchange + * @param data Arbitrary bytes to be used to construct trade call data + * @param callerAddress Address of caller + * + * @return Transaction hash of the trade transaction + */ + public async estimateGasForTradeAsync( + setTokenAddress: Address, + exchangeName: string, + sendTokenAddress: Address, + sendQuantity: BigNumber, + receiveTokenAddress: Address, + minReceivedQuantity: BigNumber, + data: string, + callerAddress: Address + ): Promise { + const tradeModuleInstance = this.contracts.loadTradeModuleWithoutSigner( + this.tradeModuleAddress, + ); + + const tx = await tradeModuleInstance.populateTransaction.trade( + setTokenAddress, + exchangeName, + sendTokenAddress, + sendQuantity, + receiveTokenAddress, + minReceivedQuantity, + data, + { from: callerAddress } + ); + + return this.provider.estimateGas(tx); + } } diff --git a/test/api/TradeQuoter.spec.ts b/test/api/TradeQuoter.spec.ts index e41f65d8..e4bde4de 100644 --- a/test/api/TradeQuoter.spec.ts +++ b/test/api/TradeQuoter.spec.ts @@ -15,10 +15,11 @@ */ import axios from 'axios'; -import { ethers } from 'ethers'; +import { ethers, BigNumber } from 'ethers'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { CoinGeckoTokenMap, TradeQuote } from '@src/types'; import SetTokenAPI from '@src/api/SetTokenAPI'; +import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; import { TradeQuoter, CoinGeckoDataService } from '@src/api/utils'; import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; import { expect } from '@test/utils/chai'; @@ -59,17 +60,23 @@ axios.get.mockImplementation(val => { } }); +// @ts-ignore +provider.estimateGas = jest.fn((arg: any) => Promise.resolve(BigNumber.from(300_000))); + describe('TradeQuoteAPI', () => { let streamingFeeModuleAddress: Address; let protocolViewerAddress: Address; let setTokenCreatorAddress: Address; + let tradeModuleAddress: Address; let setTokenAPI: SetTokenAPI; + let tradeModuleWrapper: TradeModuleWrapper; beforeEach(async () => { [ streamingFeeModuleAddress, protocolViewerAddress, setTokenCreatorAddress, + tradeModuleAddress, ] = await provider.listAccounts(); setTokenAPI = new SetTokenAPI( @@ -78,6 +85,8 @@ describe('TradeQuoteAPI', () => { streamingFeeModuleAddress, setTokenCreatorAddress ); + + tradeModuleWrapper = new TradeModuleWrapper(provider, tradeModuleAddress); }); describe('mainnet', () => { @@ -125,6 +134,8 @@ describe('TradeQuoteAPI', () => { chainId: subjectChainId, slippagePercentage: subjectSlippagePercentage, setToken: subjectSetToken, + tradeModule: tradeModuleWrapper, + provider: provider, }); } @@ -180,6 +191,8 @@ describe('TradeQuoteAPI', () => { chainId: subjectChainId, slippagePercentage: subjectSlippagePercentage, setToken: subjectSetToken, + tradeModule: tradeModuleWrapper, + provider: provider, }); } diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index d50589e6..e284ebf5 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -177,7 +177,7 @@ export const tradeQuoteFixtures = { toTokenAddress: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', exchangeAdapterName: 'ZeroExApiAdapterV3', calldata: '0x415565b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2', - gas: '520800', + gas: '315000', gasPrice: '61', slippagePercentage: '2.00%', fromTokenAmount: '1126868991563', @@ -190,8 +190,8 @@ export const tradeQuoteFixtures = { toTokenDisplayAmount: '0.04131269116050703', fromTokenPriceUsd: '$1,597.20', toTokenPriceUsd: '$1,614.79', - gasCostsUsd: '$79.20', - gasCostsChainCurrency: '0.0317688 ETH', + gasCostsUsd: '$47.91', + gasCostsChainCurrency: '0.0192150 ETH', feePercentage: '0.00%', slippage: '-1.10%', }, @@ -266,7 +266,7 @@ export const tradeQuoteFixtures = { exchangeAdapterName: 'ZeroExApiAdapterV3', calldata: '0x415565b00000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174', - gas: '409500', + gas: '315000', gasPrice: '5', slippagePercentage: '2.00%', fromTokenAmount: '1000000', @@ -279,8 +279,8 @@ export const tradeQuoteFixtures = { toTokenDisplayAmount: '0.00002973', fromTokenPriceUsd: '$1.01', toTokenPriceUsd: '$1.00', - gasCostsUsd: '$0.003051', - gasCostsChainCurrency: '0.0020475 MATIC', + gasCostsUsd: '$0.002347', + gasCostsChainCurrency: '0.0015750 MATIC', feePercentage: '0.00%', slippage: '1.11%' }, }, From 45b0b6bc0fe1f740c25f31b9f7ffcfbfc02017af Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 17:44:50 -0700 Subject: [PATCH 10/23] Add TradeAPI#fetchTradeQuote tests --- src/api/TradeAPI.ts | 11 ++- src/assertions/SchemaAssertions.ts | 20 +++++ src/schemas/commonSchemas.ts | 10 +++ src/schemas/schemas.ts | 4 + test/api/TradeAPI.spec.ts | 116 +++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index 486314c4..c20144f6 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -153,10 +153,13 @@ export default class TradeAPI { this.assert.schema.isValidAddress('fromToken', fromToken); this.assert.schema.isValidAddress('toToken', toToken); this.assert.schema.isValidAddress('fromAddress', fromAddress); - this.assert.schema.isValidNumber('fromTokenDecimals', fromTokenDecimals); - this.assert.schema.isValidNumber('toTokenDecimals', toTokenDecimals); - this.assert.schema.isValidNumber('rawAmount', rawAmount); + this.assert.schema.isValidJsNumber('fromTokenDecimals', fromTokenDecimals); + this.assert.schema.isValidJsNumber('toTokenDecimals', toTokenDecimals); + this.assert.schema.isValidString('rawAmount', rawAmount); + const chainId = (await this.provider.getNetwork()).chainId; + + // @ts-ignore return this.tradeQuoter.generate({ fromToken, toToken, @@ -164,7 +167,7 @@ export default class TradeAPI { toTokenDecimals, rawAmount, fromAddress, - chainId: this.chainId, + chainId, tradeModule: this.tradeModuleWrapper, provider: this.provider, setToken, diff --git a/src/assertions/SchemaAssertions.ts b/src/assertions/SchemaAssertions.ts index cf64a10c..904acb78 100644 --- a/src/assertions/SchemaAssertions.ts +++ b/src/assertions/SchemaAssertions.ts @@ -91,6 +91,26 @@ export class SchemaAssertions { this.assertConformsToSchema(variableName, value, schemas.wholeNumberSchema); } + /** + * Throws if a given input is not a native JS number. + * + * @param variableName Variable name being validated. Used for displaying error messages. + * @param value Value being validated. + */ + public isValidJsNumber(variableName: string, value: any) { + this.assertConformsToSchema(variableName, value, schemas.jsNumberSchema); + } + + /** + * Throws if a given input is not a string. + * + * @param variableName Variable name being validated. Used for displaying error messages. + * @param value Value being validated. + */ + public isValidString(variableName: string, value: any) { + this.assertConformsToSchema(variableName, value, schemas.stringSchema); + } + private assertConformsToSchema( variableName: string, value: any, diff --git a/src/schemas/commonSchemas.ts b/src/schemas/commonSchemas.ts index f09add17..c7d53b7f 100644 --- a/src/schemas/commonSchemas.ts +++ b/src/schemas/commonSchemas.ts @@ -46,3 +46,13 @@ export const wholeNumberSchema = { type: 'object', format: 'wholeBigNumber', }; + +export const jsNumberSchema = { + id: '/JsNumber', + type: 'number', +}; + +export const stringSchema = { + id: '/String', + type: 'string', +}; diff --git a/src/schemas/schemas.ts b/src/schemas/schemas.ts index 165c17ad..c9ef1507 100644 --- a/src/schemas/schemas.ts +++ b/src/schemas/schemas.ts @@ -22,6 +22,8 @@ import { bytesSchema, numberSchema, wholeNumberSchema, + jsNumberSchema, + stringSchema } from './commonSchemas'; export const schemas = { @@ -30,4 +32,6 @@ export const schemas = { bytesSchema, bytes32Schema, wholeNumberSchema, + jsNumberSchema, + stringSchema, }; diff --git a/test/api/TradeAPI.spec.ts b/test/api/TradeAPI.spec.ts index 84f67001..46b9dc64 100644 --- a/test/api/TradeAPI.spec.ts +++ b/test/api/TradeAPI.spec.ts @@ -22,11 +22,15 @@ import { ether } from '@setprotocol/set-protocol-v2/dist/utils/common'; import TradeAPI from '@src/api/TradeAPI'; import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; +import type SetTokenAPI from '@src/api/SetTokenAPI'; +import { TradeQuoter } from '@src/api/utils/tradequote'; import { expect } from '@test/utils/chai'; +import { TradeQuote } from '@src/types'; const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); jest.mock('@src/wrappers/set-protocol-v2/TradeModuleWrapper'); +jest.mock('@src/api/utils/tradequote'); describe('TradeAPI', () => { let tradeModuleAddress: Address; @@ -34,6 +38,7 @@ describe('TradeAPI', () => { let owner: Address; let tradeModuleWrapper: TradeModuleWrapper; + let tradeQuoter: TradeQuoter; let tradeAPI: TradeAPI; @@ -46,10 +51,12 @@ describe('TradeAPI', () => { tradeAPI = new TradeAPI(provider, tradeModuleAddress); tradeModuleWrapper = (TradeModuleWrapper as any).mock.instances[0]; + tradeQuoter = (TradeQuoter as any).mock.instances[0]; }); afterEach(async () => { (TradeModuleWrapper as any).mockClear(); + (TradeQuoter as any).mockClear(); }); describe('#initializeAsync', () => { @@ -175,4 +182,113 @@ describe('TradeAPI', () => { }); }); }); + + describe('#fetchTradeQuote', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectFromTokenDecimals: number; + let subjectToTokenDecimals: number; + let subjectRawAmount: string; + let subjectFromAddress: Address; + let subjectSetToken: SetTokenAPI; + let subjectGasPrice: number; + + beforeEach(async () => { + subjectFromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + subjectToToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectFromTokenDecimals = 8; + subjectToTokenDecimals = 6; + subjectRawAmount = '5'; + subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectSetToken = { val: 'settoken' } as SetTokenAPI; + subjectGasPrice = 20; + }); + + async function subject(): Promise { + return await tradeAPI.fetchTradeQuoteAsync( + subjectFromToken, + subjectToToken, + subjectFromTokenDecimals, + subjectToTokenDecimals, + subjectRawAmount, + subjectFromAddress, + subjectSetToken, + subjectGasPrice + ); + } + + it('should call the TradeQuoter with correct params', async () => { + const expectedQuoteOptions = { + fromToken: subjectFromToken, + toToken: subjectToToken, + fromTokenDecimals: subjectFromTokenDecimals, + toTokenDecimals: subjectToTokenDecimals, + rawAmount: subjectRawAmount, + fromAddress: subjectFromAddress, + chainId: (await provider.getNetwork()).chainId, + tradeModule: tradeModuleWrapper, + provider: provider, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, + slippagePercentage: undefined, + isFirmQuote: undefined, + feePercentage: undefined, + feeRecipient: undefined, + excludedSources: undefined, + }; + await subject(); + + expect(tradeQuoter.generate).to.have.beenCalledWith(expectedQuoteOptions); + }); + + describe('when the fromToken address is invalid', () => { + beforeEach(async () => { + subjectFromToken = '0xInvalidAddress'; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the toToken address is invalid', () => { + beforeEach(async () => { + subjectToToken = '0xInvalidAddress'; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the fromTokenDecimals is invalid', () => { + beforeEach(async () => { + subjectFromTokenDecimals = '100' as number; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the toTokenDecimals is invalid', () => { + beforeEach(async () => { + subjectToTokenDecimals = '100' as number; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when the rawAmount quantity is invalid', () => { + beforeEach(async () => { + subjectRawAmount = 5 as string; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); }); From 9231a41ba38371d9b2743dc59fea7a5e6ae328cc Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 18:01:16 -0700 Subject: [PATCH 11/23] 0.2.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f70fb12e..4a444b1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.2.0", + "version": "0.2.1", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From 42a7b20ed08630474e355501811375738840785f Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 18:13:56 -0700 Subject: [PATCH 12/23] Set coin prices to zero if CoinGecko fails --- src/api/utils/coingecko.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/api/utils/coingecko.ts b/src/api/utils/coingecko.ts index 7ff5ed79..7bf51491 100644 --- a/src/api/utils/coingecko.ts +++ b/src/api/utils/coingecko.ts @@ -71,7 +71,17 @@ export class CoinGeckoDataService { const vsCurrenciesParams = `vs_currencies=${params.vsCurrencies.join(',')}`; const url = `${endpoint}${contractAddressParams}&${vsCurrenciesParams}`; - const response = await axios.get(url); + let response; + try { + response = await axios.get(url); + } catch (e) { + // If coingecko fails, set prices to zero + response = { data: {} }; + for (const address of params.contractAddresses) { + response.data[address] = {}; + response.data[address][params.vsCurrencies[0]] = 0.00; + } + } return response.data; } From d8a72569d216f6db71705dcb9e0e3822e3c28987 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 9 Jun 2021 18:29:26 -0700 Subject: [PATCH 13/23] 0.2.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a444b1b..b3c778b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.2.1", + "version": "0.2.2", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From c7c5ac61e0b4ad4d91a62e01d1e09a61bfbfed9f Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 10 Jun 2021 09:25:37 -0700 Subject: [PATCH 14/23] Rename tradequote file to tradeQuoter --- src/api/utils/index.ts | 2 +- src/api/utils/{tradequote.ts => tradeQuoter.ts} | 0 test/api/TradeAPI.spec.ts | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/api/utils/{tradequote.ts => tradeQuoter.ts} (100%) diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 4f02f9cd..9f1a0dd2 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -1,4 +1,4 @@ -export * from './tradequote'; +export * from './tradeQuoter'; export * from './coingecko'; export * from './gasOracle'; export * from './zeroex'; diff --git a/src/api/utils/tradequote.ts b/src/api/utils/tradeQuoter.ts similarity index 100% rename from src/api/utils/tradequote.ts rename to src/api/utils/tradeQuoter.ts diff --git a/test/api/TradeAPI.spec.ts b/test/api/TradeAPI.spec.ts index 46b9dc64..9fa13c60 100644 --- a/test/api/TradeAPI.spec.ts +++ b/test/api/TradeAPI.spec.ts @@ -23,14 +23,14 @@ import { ether } from '@setprotocol/set-protocol-v2/dist/utils/common'; import TradeAPI from '@src/api/TradeAPI'; import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; import type SetTokenAPI from '@src/api/SetTokenAPI'; -import { TradeQuoter } from '@src/api/utils/tradequote'; +import { TradeQuoter } from '@src/api/utils'; import { expect } from '@test/utils/chai'; import { TradeQuote } from '@src/types'; const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); jest.mock('@src/wrappers/set-protocol-v2/TradeModuleWrapper'); -jest.mock('@src/api/utils/tradequote'); +jest.mock('@src/api/utils/tradeQuoter'); describe('TradeAPI', () => { let tradeModuleAddress: Address; From b5b52f07556530ef7ddebbdefee5ac5c7f71dce4 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 10 Jun 2021 09:28:20 -0700 Subject: [PATCH 15/23] Make sure dist is cleared before build --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b3c778b5..8415e10c 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,10 @@ "scripts": { "prod": "yarn run build", "chain": "bash scripts/init_chain_internal.sh", - "build": "yarn run build-dist && yarn run tslint && tspath -f", + "build": "yarn run clean && yarn run build-dist && yarn run tslint && tspath -f", "build-ts": "tsc -p tsconfig.json", "build-dist": "tsc -p tsconfig.dist.json", + "clean": "rm -rf dist", "test": "jest --runInBand", "test:verbose": "jest --runInBand --silent=false", "test:watch": "jest --watch --runInBand", From 3940d82239c47c66d822eba408fb2a44165f56d6 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Thu, 10 Jun 2021 09:40:49 -0700 Subject: [PATCH 16/23] 0.2.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8415e10c..fc8a8c00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.2.2", + "version": "0.2.3", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From 8d7660647bdb4893dfcb3b13c748c22f8ffa11d9 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Fri, 11 Jun 2021 05:32:43 -0700 Subject: [PATCH 17/23] Fix bug reading from options.slippagePercentage --- src/api/utils/tradeQuoter.ts | 2 +- test/api/TradeQuoter.spec.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index 8d1914b3..5ee7a415 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -156,7 +156,7 @@ export class TradeQuoter { calldata, gas: gas.toString(), gasPrice: options.gasPrice.toString(), - slippagePercentage: this.formatAsPercentage(options.slippagePercentage), + slippagePercentage: this.formatAsPercentage(slippagePercentage), fromTokenAmount: fromUnits.toString(), toTokenAmount: toUnits.toString(), display: { diff --git a/test/api/TradeQuoter.spec.ts b/test/api/TradeQuoter.spec.ts index e4bde4de..fc3f12b7 100644 --- a/test/api/TradeQuoter.spec.ts +++ b/test/api/TradeQuoter.spec.ts @@ -108,7 +108,6 @@ describe('TradeQuoteAPI', () => { let subjectRawAmount: string; let subjectSetTokenAddress: Address; let subjectChainId: number; - let subjectSlippagePercentage: number; let subjectSetToken: SetTokenAPI; beforeEach(async () => { @@ -119,7 +118,6 @@ describe('TradeQuoteAPI', () => { subjectSetTokenAddress = DPI_ETH; // DPI subjectRawAmount = '.5'; subjectChainId = 1; - subjectSlippagePercentage = 2, subjectSetToken = setTokenAPI; }); @@ -132,7 +130,6 @@ describe('TradeQuoteAPI', () => { rawAmount: subjectRawAmount, fromAddress: subjectSetTokenAddress, chainId: subjectChainId, - slippagePercentage: subjectSlippagePercentage, setToken: subjectSetToken, tradeModule: tradeModuleWrapper, provider: provider, From e4a87aaaf33785e5afb700f7a5f23e711c8374b3 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Fri, 11 Jun 2021 05:54:25 -0700 Subject: [PATCH 18/23] 0.2.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fc8a8c00..6350d38f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.2.3", + "version": "0.2.4", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From 2c360f0f140a512e11c0ad0ddc9c6de6c9bc2caf Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 14 Jun 2021 10:29:08 -0700 Subject: [PATCH 19/23] Additional tests for TradeAPI utils and public method comments --- src/api/TradeAPI.ts | 59 ++++++- src/api/utils/coingecko.ts | 36 ++--- src/api/utils/gasOracle.ts | 1 - src/api/utils/tradeQuoter.ts | 5 +- src/assertions/CommonAssertions.ts | 2 +- test/api/TradeAPI.spec.ts | 241 ++++++++++++++++++++++++++++- test/api/TradeQuoter.spec.ts | 2 +- test/fixtures/tradeQuote.ts | 49 ++++++ 8 files changed, 362 insertions(+), 33 deletions(-) diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index c20144f6..16716aa2 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -135,6 +135,25 @@ export default class TradeAPI { ); } + /** + * Call 0x API to generate a trade quote for two SetToken components. + * + * @param fromToken Address of token being sold + * @param toToken Address of token being bought + * @param fromTokenDecimals Token decimals of token being sold (ex: 18) + * @param toTokenDecimals Token decimals of token being bought (ex: 18) + * @param rawAmount String quantity of token to sell (ex: "0.5") + * @param fromAddress SetToken address which holds the buy / sell components + * @param setToken SetTokenAPI instance + * @param gasPrice (Optional) gasPrice to calculate gas costs with (Default: fetched from GasNow) + * @param slippagePercentage (Optional) maximum slippage, determines min receive quantity. (Default: 2%) + * @param isFirmQuote (Optional) Whether quote request is indicative or firm + * @param feePercentage (Optional) Default: 0 + * @param feeRecipient (Optional) Default: 0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55 + * @param excludedSources (Optional) Exchanges to exclude (Default: ['Kyber', 'Eth2Dai', 'Uniswap', 'Mesh']) + * + * @return {Promise} + */ public async fetchTradeQuoteAsync( fromToken: Address, toToken: Address, @@ -159,7 +178,6 @@ export default class TradeAPI { const chainId = (await this.provider.getNetwork()).chainId; - // @ts-ignore return this.tradeQuoter.generate({ fromToken, toToken, @@ -180,17 +198,40 @@ export default class TradeAPI { }); } - public async fetchTokenList(): Promise { + /** + * Fetches a list of tokens and their metadata from CoinGecko. Each entry includes + * the token's address, proper name, decimals, exchange symbol and a logo URI if available. + * For Ethereum, this is a list of tokens tradeable on Uniswap, for Polygon it's a list of + * tokens tradeable on Sushiswap's Polygon exchange. Method is useful for acquiring token decimals + * necessary to generate a trade quote and images for representing available tokens in a UI. + * + * @return List of tradeable tokens for chain platform + */ + public async fetchTokenListAsync(): Promise { await this.initializeForChain(); return this.coinGecko.fetchTokenList(); } - public async fetchTokenMap(): Promise { + /** + * Fetches the same info as `fetchTokenList` in the form of a map indexed by address. Method is + * useful if you're cacheing the token list and want quick lookups for a variety of trades. + * + * @return Map of token addresses to token metadata + */ + public async fetchTokenMapAsync(): Promise { await this.initializeForChain(); return this.coinGecko.fetchTokenMap(); } - public async fetchCoinPrices( + /** + * Fetches a list of prices vs currencies for the specified inputs from CoinGecko + * + * @param contractAddresses String array of contract addresses + * @param vsCurrencies String array of currency codes (see CoinGecko api for a complete list) + * + * @return List of prices vs currencies + */ + public async fetchCoinPricesAsync( contractAddresses: string[], vsCurrencies: string[] ): Promise { @@ -198,12 +239,20 @@ export default class TradeAPI { return this.coinGecko.fetchCoinPrices({contractAddresses, vsCurrencies}); } - public async fetchGasPrice(speed: GasOracleSpeed): Promise { + /** + * Fetches the recommended gas price for a specified execution speed. + * + * @param speed (Optional) string value: "average" | "fast" | "fastest" (Default: fast) + * + * @return Number: gas price + */ + public async fetchGasPriceAsync(speed?: GasOracleSpeed): Promise { await this.initializeForChain(); const oracle = new GasOracleService(this.chainId); return oracle.fetchGasPrice(speed); } + private async initializeForChain() { if (this.coinGecko === undefined) { const network = await this.provider.getNetwork(); diff --git a/src/api/utils/coingecko.ts b/src/api/utils/coingecko.ts index 7bf51491..021a901c 100644 --- a/src/api/utils/coingecko.ts +++ b/src/api/utils/coingecko.ts @@ -122,6 +122,24 @@ export class CoinGeckoDataService { return this.tokenMap; } + public convertTokenListToAddressMap(list: CoinGeckoTokenData[] = []): CoinGeckoTokenMap { + const tokenMap: CoinGeckoTokenMap = {}; + + for (const entry of list) { + tokenMap[entry.address] = Object.assign({}, entry); + } + + return tokenMap; + } + + private getPlatform(): string { + switch (this.chainId) { + case 1: return 'ethereum'; + case 137: return 'polygon-pos'; + default: return ''; + } + } + private async fetchEthereumTokenList(): Promise { const url = 'https://tokens.coingecko.com/uniswap/all.json'; const response = await axios.get(url); @@ -226,22 +244,4 @@ export class CoinGeckoDataService { const data = (await axios.get(url)).data; return data; } - - private convertTokenListToAddressMap(list: CoinGeckoTokenData[] = []): CoinGeckoTokenMap { - const tokenMap: CoinGeckoTokenMap = {}; - - for (const entry of list) { - tokenMap[entry.address] = Object.assign({}, entry); - } - - return tokenMap; - } - - private getPlatform(): string { - switch (this.chainId) { - case 1: return 'ethereum'; - case 137: return 'polygon-pos'; - default: return ''; - } - } } diff --git a/src/api/utils/gasOracle.ts b/src/api/utils/gasOracle.ts index 5e30f224..b9950556 100644 --- a/src/api/utils/gasOracle.ts +++ b/src/api/utils/gasOracle.ts @@ -67,7 +67,6 @@ export class GasOracleService { const url = 'https://www.gasnow.org/api/v3/gas/price'; const data: GasNowData = (await axios.get(url)).data.data; - // EthGasStation returns gas price in x10 Gwei (divite by 10 to convert it to gwei) switch (speed) { case GasOracleService.AVERAGE: return data.standard / 1e9; case GasOracleService.FAST: return data.fast / 1e9; diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index 5ee7a415..cb5716f2 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -235,7 +235,7 @@ export class TradeQuoter { const fromTokenAmount = quote.sellAmount; - // Convert to BigDecimal to get cieling in fromUnits calculation + // Convert to BigDecimal to get ceiling in fromUnits calculation // This is necessary to derive the trade amount ZeroEx expects when scaling is // done in the TradeModule contract. (ethers.FixedNumber does not work for this case) const fromTokenAmountBD = new BigDecimal(fromTokenAmount.toString()); @@ -422,8 +422,7 @@ export class TradeQuoter { const gasCostBuffer = (100 + this.tradeQuoteGasBuffer) / 100; return Math.floor(gas.toNumber() * gasCostBuffer); } catch (error) { - console.log('error --> ' + error); - throw new Error('Unable to fetch gas cost estimate for trade'); + throw new Error('Unable to fetch gas cost estimate for trade' + error); } } diff --git a/src/assertions/CommonAssertions.ts b/src/assertions/CommonAssertions.ts index 73dd5a13..590ede03 100644 --- a/src/assertions/CommonAssertions.ts +++ b/src/assertions/CommonAssertions.ts @@ -120,7 +120,7 @@ export class CommonAssertions { const validChainIds = [1, 137]; if ( !validChainIds.includes(chainId)) { - throw new Error('Unsupported chainId: ${chainId}. Must be one of ${validChainIds}'); + throw new Error(`Unsupported chainId: ${chainId}. Must be one of ${validChainIds}`); } } } diff --git a/test/api/TradeAPI.spec.ts b/test/api/TradeAPI.spec.ts index 9fa13c60..4d19c9b3 100644 --- a/test/api/TradeAPI.spec.ts +++ b/test/api/TradeAPI.spec.ts @@ -14,8 +14,12 @@ limitations under the License. */ +import axios from 'axios'; +const pageResults = require('graph-results-pager'); + import { ethers, ContractTransaction } from 'ethers'; import { BigNumber } from 'ethers/lib/ethers'; +import { Network } from '@ethersproject/providers'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { EMPTY_BYTES } from '@setprotocol/set-protocol-v2/dist/utils/constants'; import { ether } from '@setprotocol/set-protocol-v2/dist/utils/common'; @@ -23,14 +27,42 @@ import { ether } from '@setprotocol/set-protocol-v2/dist/utils/common'; import TradeAPI from '@src/api/TradeAPI'; import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper'; import type SetTokenAPI from '@src/api/SetTokenAPI'; -import { TradeQuoter } from '@src/api/utils'; +import { + TradeQuoter, + CoinGeckoDataService, +} from '@src/api/utils'; import { expect } from '@test/utils/chai'; -import { TradeQuote } from '@src/types'; +import { + TradeQuote, + CoinGeckoTokenData, + CoinGeckoTokenMap, + CoinGeckoCoinPrices +} from '@src/types'; + +import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); jest.mock('@src/wrappers/set-protocol-v2/TradeModuleWrapper'); jest.mock('@src/api/utils/tradeQuoter'); +jest.mock('axios'); +jest.mock('graph-results-pager'); + +// @ts-ignore +axios.get.mockImplementation(val => { + switch (val) { + case fixture.gasNowRequest: return fixture.gasNowResponse; + case fixture.maticGasStationRequest: return fixture.maticGasStationResponse; + case fixture.coinGeckoTokenRequestEth: return fixture.coinGeckoTokenResponseEth; + case fixture.coinGeckoTokenRequestPoly: return fixture.coinGeckoTokenResponsePoly; + case fixture.coinGeckoPricesRequestEth: return fixture.coinGeckoPricesResponseEth; + case fixture.coinGeckoPricesRequestPoly: return fixture.coinGeckoPricesResponsePoly; + case fixture.maticMapperRequestPoly: return fixture.maticMapperResponsePoly; + case fixture.quickswapRequestPoly: return fixture.quickswapResponsePoly; + } +}); + +pageResults.mockImplementation(() => fixture.sushiSubgraphResponsePoly); describe('TradeAPI', () => { let tradeModuleAddress: Address; @@ -39,7 +71,6 @@ describe('TradeAPI', () => { let tradeModuleWrapper: TradeModuleWrapper; let tradeQuoter: TradeQuoter; - let tradeAPI: TradeAPI; beforeEach(async () => { @@ -57,6 +88,7 @@ describe('TradeAPI', () => { afterEach(async () => { (TradeModuleWrapper as any).mockClear(); (TradeQuoter as any).mockClear(); + (axios as any).mockClear(); }); describe('#initializeAsync', () => { @@ -183,7 +215,7 @@ describe('TradeAPI', () => { }); }); - describe('#fetchTradeQuote', () => { + describe('#fetchTradeQuoteAsync', () => { let subjectFromToken: Address; let subjectToToken: Address; let subjectFromTokenDecimals: number; @@ -291,4 +323,205 @@ describe('TradeAPI', () => { }); }); }); + + describe('#fetchTokenListAsync', () => { + let subjectChainId; + + async function subject(): Promise { + return await tradeAPI.fetchTokenListAsync(); + } + + describe('when the chain is ethereum (1)', () => { + beforeEach(() => { + subjectChainId = 1; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should fetch correct token data for network', async() => { + const tokenData = await subject(); + await expect(tokenData).to.deep.equal(fixture.coinGeckoTokenResponseEth.data.tokens); + }); + }); + + describe('when the chain is polygon (137)', () => { + beforeEach(() => { + subjectChainId = 137; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should fetch correct token data for network', async() => { + const tokenData = await subject(); + await expect(tokenData).to.deep.equal(fixture.fetchTokenListResponsePoly); + }); + }); + + describe('when chain is invalid', () => { + beforeEach(() => { + subjectChainId = 1337; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should error', async() => { + await expect(subject()).to.be.rejectedWith(`Unsupported chainId: ${subjectChainId}`); + }); + }); + }); + + describe('#fetchTokenMapAsync', () => { + let subjectChainId; + let subjectTokenList; + let subjectCoinGecko; + + async function subject(): Promise { + return await tradeAPI.fetchTokenMapAsync(); + } + + describe('when the chain is ethereum (1)', () => { + beforeEach(async () => { + subjectChainId = 1; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + subjectCoinGecko = new CoinGeckoDataService(subjectChainId); + subjectTokenList = await tradeAPI.fetchTokenListAsync(); + }); + + it('should fetch correct token data for network', async() => { + const expectedTokenMap = subjectCoinGecko.convertTokenListToAddressMap(subjectTokenList); + const tokenData = await subject(); + await expect(tokenData).to.deep.equal(expectedTokenMap); + }); + }); + + describe('when the chain is polygon (137)', () => { + beforeEach(async () => { + subjectChainId = 1; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + subjectCoinGecko = new CoinGeckoDataService(subjectChainId); + subjectTokenList = await tradeAPI.fetchTokenListAsync(); + }); + + it('should fetch correct token data for network', async() => { + const expectedTokenMap = subjectCoinGecko.convertTokenListToAddressMap(subjectTokenList); + const tokenData = await subject(); + await expect(tokenData).to.deep.equal(expectedTokenMap); + }); + }); + + describe('when chain is invalid', () => { + beforeEach(() => { + subjectChainId = 1337; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should error', async() => { + await expect(subject()).to.be.rejectedWith(`Unsupported chainId: ${subjectChainId}`); + }); + }); + }); + + describe('#fetchCoinPricesAsync', () => { + let subjectChainId; + let subjectContractAddresses; + let subjectVsCurrencies; + + beforeEach(() => { + subjectVsCurrencies = ['usd,usd,usd']; + }); + + async function subject(): Promise { + return await tradeAPI.fetchCoinPricesAsync( + subjectContractAddresses, + subjectVsCurrencies + ); + } + + describe('when the chain is ethereum (1)', () => { + beforeEach(() => { + subjectChainId = 1; + subjectContractAddresses = [ + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', + '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', + ]; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should fetch correct coin prices for network', async() => { + const coinPrices = await subject(); + await expect(coinPrices).to.deep.equal(fixture.coinGeckoPricesResponseEth.data); + }); + }); + + describe('when the chain is polygon (137)', () => { + beforeEach(() => { + subjectChainId = 137; + subjectContractAddresses = [ + '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270', + '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', + ]; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should fetch correct coin prices for network', async() => { + const coinPrices = await subject(); + await expect(coinPrices).to.deep.equal(fixture.coinGeckoPricesResponsePoly.data); + }); + }); + + describe('when chain is invalid', () => { + beforeEach(() => { + subjectChainId = 1337; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should error', async() => { + await expect(subject()).to.be.rejectedWith(`Unsupported chainId: ${subjectChainId}`); + }); + }); + }); + + describe('#fetchGasPricesAsync', () => { + let subjectChainId; + + async function subject(): Promise { + return await tradeAPI.fetchGasPriceAsync(); + } + + describe('when chain is Ethereum (1)', () => { + beforeEach(() => { + subjectChainId = 1; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should get gas price for the correct network', async() => { + const expectedGasPrice = fixture.gasNowResponse.data.data.fast / 1e9; + const gasPrice = await subject(); + expect(gasPrice).to.equal(expectedGasPrice); + }); + }); + + describe('when chain is Polygon (137)', () => { + beforeEach(() => { + subjectChainId = 137; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should get gas price for the correct network', async() => { + const expectedGasPrice = fixture.maticGasStationResponse.data.fast; + const gasPrice = await subject(); + expect(gasPrice).to.equal(expectedGasPrice); + }); + }); + + describe('when chain is invalid', () => { + beforeEach(() => { + subjectChainId = 1337; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + }); + + it('should error', async() => { + await expect(subject()).to.be.rejectedWith(`Unsupported chainId: ${subjectChainId}`); + }); + }); + }); }); diff --git a/test/api/TradeQuoter.spec.ts b/test/api/TradeQuoter.spec.ts index fc3f12b7..263d1c48 100644 --- a/test/api/TradeQuoter.spec.ts +++ b/test/api/TradeQuoter.spec.ts @@ -63,7 +63,7 @@ axios.get.mockImplementation(val => { // @ts-ignore provider.estimateGas = jest.fn((arg: any) => Promise.resolve(BigNumber.from(300_000))); -describe('TradeQuoteAPI', () => { +describe('TradeQuoter', () => { let streamingFeeModuleAddress: Address; let protocolViewerAddress: Address; let setTokenCreatorAddress: Address; diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index e284ebf5..e2eae527 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -218,6 +218,55 @@ export const tradeQuoteFixtures = { }], }, + sushiSubgraphResponsePoly: [ + { + id: '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0', + name: 'Matic Token', + symbol: 'MATIC', + decimals: 18, + volumeUSD: '123000000.123', + }, + { + id: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', + name: 'Wrapped BTC', + symbol: 'WBTC', + decimals: 8, + volumeUSD: '222000000.123', + }, + { + id: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + volumeUSD: '333000000.123', + }, + ], + + fetchTokenListResponsePoly: [ + { chainId: 137, + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + volumeUSD: 333000000.123, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png' }, + { chainId: 137, + address: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', + symbol: 'WBTC', + name: 'Wrapped BTC', + decimals: 8, + volumeUSD: 222000000.123, + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png' }, + { chainId: 137, + address: '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0', + symbol: 'MATIC', + name: 'Matic Token', + decimals: 18, + volumeUSD: 123000000.123 }, + ], + maticMapperRequestPoly: 'https://tokenmapper.api.matic.today/api/v1/mapping?map_type=[%22POS%22]&chain_id=137&limit=200&offset=0', maticMapperResponsePoly: { data: { From 140edb3cfab54067d119eb0c513de42ad8519e46 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 12 Jul 2021 15:58:49 -0700 Subject: [PATCH 20/23] Pass fee percentage to ZeroEx api call / remove Uniswap from excluded sources --- src/api/utils/tradeQuoter.ts | 11 +++++++---- src/api/utils/zeroex.ts | 10 +++++----- test/api/TradeAPI.spec.ts | 9 +++++++-- test/api/TradeQuoter.spec.ts | 3 +++ test/fixtures/tradeQuote.ts | 2 +- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index cb5716f2..fe1adcd1 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -54,7 +54,7 @@ export class TradeQuoter { private feePercentage: number = 0; private isFirmQuote: boolean = true; private slippagePercentage: number = 2; - private excludedSources: string[] = ['Kyber', 'Eth2Dai', 'Uniswap', 'Mesh']; + private excludedSources: string[] = ['Kyber', 'Eth2Dai', 'Mesh']; private zeroExApiKey: string; constructor(zeroExApiKey: string = '') { @@ -113,7 +113,8 @@ export class TradeQuoter { isFirmQuote, slippagePercentage, feeRecipient, - excludedSources + excludedSources, + feePercentage, ); // Sanity check response from quote APIs @@ -215,7 +216,8 @@ export class TradeQuoter { isFirmQuote: boolean, slippagePercentage: number, feeRecipient: Address, - excludedSources: string[] + excludedSources: string[], + feePercentage: number, ) { const zeroEx = new ZeroExTradeQuoter({ chainId: chainId, @@ -230,7 +232,8 @@ export class TradeQuoter { isFirmQuote, (slippagePercentage / 100), feeRecipient, - excludedSources + excludedSources, + (feePercentage / 100) ); const fromTokenAmount = quote.sellAmount; diff --git a/src/api/utils/zeroex.ts b/src/api/utils/zeroex.ts index 57ee2d18..26dd13c2 100644 --- a/src/api/utils/zeroex.ts +++ b/src/api/utils/zeroex.ts @@ -40,7 +40,6 @@ export class ZeroExTradeQuoter { private assert: Assertions; private swapQuoteRoute = '/swap/v1/quote'; - private feePercentage: number = 0; private affiliateAddress: Address = '0xD3D555Bb655AcBA9452bfC6D7cEa8cC7b3628C55'; private skipValidation: boolean = true; @@ -69,9 +68,10 @@ export class ZeroExTradeQuoter { sellAmount: BigNumber, takerAddress: Address, isFirm: boolean, - slippagePercentage, - feeRecipient, - excludedSources + slippagePercentage: number, + feeRecipient: Address, + excludedSources: string[], + feePercentage: number ): Promise { const url = `${this.host}${this.swapQuoteRoute}`; @@ -84,7 +84,7 @@ export class ZeroExTradeQuoter { excludedSources: excludedSources.join(','), skipValidation: this.skipValidation, feeRecipient: feeRecipient, - buyTokenPercentageFee: this.feePercentage, + buyTokenPercentageFee: feePercentage, affiliateAddress: this.affiliateAddress, intentOnFilling: isFirm, }; diff --git a/test/api/TradeAPI.spec.ts b/test/api/TradeAPI.spec.ts index 4d19c9b3..dc2d4825 100644 --- a/test/api/TradeAPI.spec.ts +++ b/test/api/TradeAPI.spec.ts @@ -224,6 +224,7 @@ describe('TradeAPI', () => { let subjectFromAddress: Address; let subjectSetToken: SetTokenAPI; let subjectGasPrice: number; + let subjectFeePercentage: number; beforeEach(async () => { subjectFromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; @@ -234,6 +235,7 @@ describe('TradeAPI', () => { subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; subjectSetToken = { val: 'settoken' } as SetTokenAPI; subjectGasPrice = 20; + subjectFeePercentage = 1; }); async function subject(): Promise { @@ -245,7 +247,10 @@ describe('TradeAPI', () => { subjectRawAmount, subjectFromAddress, subjectSetToken, - subjectGasPrice + subjectGasPrice, + undefined, + undefined, + subjectFeePercentage ); } @@ -264,7 +269,7 @@ describe('TradeAPI', () => { gasPrice: subjectGasPrice, slippagePercentage: undefined, isFirmQuote: undefined, - feePercentage: undefined, + feePercentage: subjectFeePercentage, feeRecipient: undefined, excludedSources: undefined, }; diff --git a/test/api/TradeQuoter.spec.ts b/test/api/TradeQuoter.spec.ts index 263d1c48..baa7d6ee 100644 --- a/test/api/TradeQuoter.spec.ts +++ b/test/api/TradeQuoter.spec.ts @@ -109,6 +109,7 @@ describe('TradeQuoter', () => { let subjectSetTokenAddress: Address; let subjectChainId: number; let subjectSetToken: SetTokenAPI; + let subjectFeePercentage: number; beforeEach(async () => { subjectFromToken = '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2'; // MKR @@ -119,6 +120,7 @@ describe('TradeQuoter', () => { subjectRawAmount = '.5'; subjectChainId = 1; subjectSetToken = setTokenAPI; + subjectFeePercentage = 1; }); async function subject(): Promise { @@ -129,6 +131,7 @@ describe('TradeQuoter', () => { toTokenDecimals: subjectToTokenDecimals, rawAmount: subjectRawAmount, fromAddress: subjectSetTokenAddress, + feePercentage: subjectFeePercentage, chainId: subjectChainId, setToken: subjectSetToken, tradeModule: tradeModuleWrapper, diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index e2eae527..0e3f2992 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -192,7 +192,7 @@ export const tradeQuoteFixtures = { toTokenPriceUsd: '$1,614.79', gasCostsUsd: '$47.91', gasCostsChainCurrency: '0.0192150 ETH', - feePercentage: '0.00%', + feePercentage: '1.00%', slippage: '-1.10%', }, }, From c0b8941b447ec46ea7df0e24b4491f83a07f49e0 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 12 Jul 2021 16:12:24 -0700 Subject: [PATCH 21/23] 0.2.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6350d38f..11d602ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.2.4", + "version": "0.2.5", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", From 184406fcb6b6f83e777070e86746fbe0d8247e5b Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 12 Jul 2021 19:47:09 -0700 Subject: [PATCH 22/23] Include fee in toTokenAmount adjustment --- src/api/utils/tradeQuoter.ts | 3 ++- test/fixtures/tradeQuote.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index fe1adcd1..48f44a10 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -253,7 +253,8 @@ export class TradeQuoter { // BigNumber does not do fixed point math & FixedNumber underflows w/ numbers less than 1 // Multiply the slippage by a factor and divide the end result by same... const percentMultiplier = 1000; - const slippageToleranceBN = Math.floor(percentMultiplier * this.outputSlippageTolerance(slippagePercentage)); + const slippageAndFee = slippagePercentage + feePercentage; + const slippageToleranceBN = Math.floor(percentMultiplier * this.outputSlippageTolerance(slippageAndFee)); const toTokenAmountMinusSlippage = toTokenAmount.mul(slippageToleranceBN).div(percentMultiplier); const toUnits = toTokenAmountMinusSlippage.mul(SCALE).div(setTotalSupply); diff --git a/test/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 0e3f2992..6b04889f 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -181,7 +181,7 @@ export const tradeQuoteFixtures = { gasPrice: '61', slippagePercentage: '2.00%', fromTokenAmount: '1126868991563', - toTokenAmount: '91245821628', + toTokenAmount: '90314741816', display: { inputAmountRaw: '.5', inputAmount: '500000000000000000', From 324f6a4e80b2da6223958e23af0bf4a20b2017cc Mon Sep 17 00:00:00 2001 From: cgewecke Date: Mon, 12 Jul 2021 19:47:26 -0700 Subject: [PATCH 23/23] 0.2.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 11d602ca..10734dde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.2.5", + "version": "0.2.6", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js",