From 592c474c458361496dd72446adf1c5052744cd75 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Fri, 11 Mar 2022 15:21:24 -0800 Subject: [PATCH] Add support 0x Swap Quote fetching (#105) --- package.json | 2 +- src/Set.ts | 10 +- src/api/TradeAPI.ts | 88 +------ src/api/UtilsAPI.ts | 311 ++++++++++++++++++++++ src/api/index.ts | 2 + src/api/utils/tradeQuoter.ts | 94 ++++++- src/api/utils/zeroex.ts | 45 +++- src/types/common.ts | 2 + src/types/utils.ts | 54 +++- test/api/TradeAPI.spec.ts | 208 +-------------- test/api/TradeQuoter.spec.ts | 104 +++++++- test/api/UtilsAPI.spec.ts | 492 +++++++++++++++++++++++++++++++++++ test/fixtures/tradeQuote.ts | 25 ++ 13 files changed, 1120 insertions(+), 317 deletions(-) create mode 100644 src/api/UtilsAPI.ts create mode 100644 test/api/UtilsAPI.spec.ts diff --git a/package.json b/package.json index 64ba76b..adfd061 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "set.js", - "version": "0.4.12", + "version": "0.5.0", "description": "A javascript library for interacting with the Set Protocol v2", "keywords": [ "set.js", diff --git a/src/Set.ts b/src/Set.ts index 59edcff..def1f88 100644 --- a/src/Set.ts +++ b/src/Set.ts @@ -33,6 +33,7 @@ import { SlippageIssuanceAPI, PerpV2LeverageAPI, PerpV2LeverageViewerAPI, + UtilsAPI, } from './api/index'; const ethersProviders = require('ethers').providers; @@ -134,6 +135,12 @@ class Set { */ public blockchain: BlockchainAPI; + /** + * An instance of the UtilsAPI class. Contains interfaces for fetching swap quotes from 0x Protocol, + * prices and token metadata from coingecko, and network gas prices from various sources + */ + public utils: UtilsAPI; + /** * Instantiates a new Set instance that provides the public interface to the Set.js library */ @@ -155,7 +162,7 @@ class Set { assertions ); this.system = new SystemAPI(ethersProvider, config.controllerAddress); - this.trade = new TradeAPI(ethersProvider, config.tradeModuleAddress, config.zeroExApiKey); + this.trade = new TradeAPI(ethersProvider, config.tradeModuleAddress, config.zeroExApiKey, config.zeroExApiUrls); this.navIssuance = new NavIssuanceAPI(ethersProvider, config.navIssuanceModuleAddress); this.priceOracle = new PriceOracleAPI(ethersProvider, config.masterOracleAddress); this.debtIssuance = new DebtIssuanceAPI(ethersProvider, config.debtIssuanceModuleAddress); @@ -164,6 +171,7 @@ class Set { this.perpV2Leverage = new PerpV2LeverageAPI(ethersProvider, config.perpV2LeverageModuleAddress); this.perpV2LeverageViewer = new PerpV2LeverageViewerAPI(ethersProvider, config.perpV2LeverageModuleViewerAddress); this.blockchain = new BlockchainAPI(ethersProvider, assertions); + this.utils = new UtilsAPI(ethersProvider, config.zeroExApiKey, config.zeroExApiUrls); } } diff --git a/src/api/TradeAPI.ts b/src/api/TradeAPI.ts index 4302885..d6c4c5c 100644 --- a/src/api/TradeAPI.ts +++ b/src/api/TradeAPI.ts @@ -27,17 +27,12 @@ import SetTokenAPI from './SetTokenAPI'; import Assertions from '../assertions'; import { - TradeQuoter, - CoinGeckoDataService, - GasOracleService + TradeQuoter } from './utils'; import { TradeQuote, - CoinGeckoTokenData, - CoinGeckoTokenMap, - GasOracleSpeed, - CoinGeckoCoinPrices + ZeroExApiUrls } from '../types'; /** @@ -53,18 +48,17 @@ export default class TradeAPI { private assert: Assertions; private provider: Provider; private tradeQuoter: TradeQuoter; - private coinGecko: CoinGeckoDataService; - private chainId: number; public constructor( provider: Provider, tradeModuleAddress: Address, zeroExApiKey?: string, + zeroExApiUrls?: ZeroExApiUrls ) { this.provider = provider; this.tradeModuleWrapper = new TradeModuleWrapper(provider, tradeModuleAddress); this.assert = new Assertions(); - this.tradeQuoter = new TradeQuoter(zeroExApiKey); + this.tradeQuoter = new TradeQuoter(zeroExApiKey, zeroExApiUrls); } /** @@ -150,7 +144,8 @@ export default class TradeAPI { * @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']) + * @param excludedSources (Optional) Exchanges to exclude (Default: ['Kyber', 'Eth2Dai', 'Mesh']) + * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) * * @return {Promise} */ @@ -168,6 +163,7 @@ export default class TradeAPI { feePercentage?: number, feeRecipient?: Address, excludedSources?: string[], + simulatedChainId?: number, ): Promise { this.assert.schema.isValidAddress('fromToken', fromToken); this.assert.schema.isValidAddress('toToken', toToken); @@ -176,9 +172,12 @@ export default class TradeAPI { this.assert.schema.isValidJsNumber('toTokenDecimals', toTokenDecimals); this.assert.schema.isValidString('rawAmount', rawAmount); - const chainId = (await this.provider.getNetwork()).chainId; + // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value + const chainId = (simulatedChainId !== undefined) + ? simulatedChainId + : (await this.provider.getNetwork()).chainId; - return this.tradeQuoter.generate({ + return this.tradeQuoter.generateQuoteForTrade({ fromToken, toToken, fromTokenDecimals, @@ -197,67 +196,4 @@ export default class TradeAPI { excludedSources, }); } - - /** - * 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(); - } - - /** - * 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(); - } - - /** - * 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 { - await this.initializeForChain(); - return this.coinGecko.fetchCoinPrices({contractAddresses, vsCurrencies}); - } - - /** - * 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(); - this.chainId = network.chainId; - this.coinGecko = new CoinGeckoDataService(network.chainId); - } - } } diff --git a/src/api/UtilsAPI.ts b/src/api/UtilsAPI.ts new file mode 100644 index 0000000..1f47424 --- /dev/null +++ b/src/api/UtilsAPI.ts @@ -0,0 +1,311 @@ +/* + Copyright 2022 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 { constants as EthersConstants } from 'ethers'; +import { Provider } from '@ethersproject/providers'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; + +import SetTokenAPI from './SetTokenAPI'; +import Assertions from '../assertions'; + +import { + TradeQuoter, + CoinGeckoDataService, + GasOracleService +} from './utils'; + +import { + SwapQuote, + SwapOrderPairs, + CoinGeckoTokenData, + CoinGeckoTokenMap, + GasOracleSpeed, + CoinGeckoCoinPrices, + ZeroExApiUrls, +} from '../types'; + +/** + * @title UtilsAPI + * @author Set Protocol + * + * The UtilsAPI exposes methods to fetch swap quotes from 0x Exchange and get token prices and + * token metadata from CoinGecko + * + */ +export default class UtilsAPI { + private assert: Assertions; + private provider: Provider; + private tradeQuoter: TradeQuoter; + private coinGecko: CoinGeckoDataService; + private chainId: number; + + public constructor( + provider: Provider, + zeroExApiKey?: string, + zeroExApiUrls?: ZeroExApiUrls + ) { + this.provider = provider; + this.assert = new Assertions(); + this.tradeQuoter = new TradeQuoter(zeroExApiKey, zeroExApiUrls); + } + + /** + * 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 rawAmount String quantity of token to sell (ex: "0.5") + * @param useBuyAmount When true, amount is `buyAmount` of `toToken`, + * When false, amount is `sellAmount` of `fromToken` + * @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 EthGasStation) + * @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', 'Mesh']) + * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) + * + * @return {Promise} + */ + public async fetchSwapQuoteAsync( + fromToken: Address, + toToken: Address, + rawAmount: string, + useBuyAmount: boolean, + fromAddress: Address, + setToken: SetTokenAPI, + gasPrice?: number, + slippagePercentage?: number, + isFirmQuote?: boolean, + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], + simulatedChainId?: number, + ): Promise { + this.assert.schema.isValidAddress('fromToken', fromToken); + this.assert.schema.isValidAddress('toToken', toToken); + this.assert.schema.isValidAddress('fromAddress', fromAddress); + this.assert.schema.isValidString('rawAmount', rawAmount); + + // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value + const chainId = (simulatedChainId !== undefined) + ? simulatedChainId + : (await this.provider.getNetwork()).chainId; + + return this.tradeQuoter.generateQuoteForSwap({ + fromToken, + toToken, + rawAmount, + useBuyAmount, + fromAddress, + chainId, + setToken, + gasPrice, + slippagePercentage, + isFirmQuote, + feePercentage, + feeRecipient, + excludedSources, + }); + } + + /** + * Call 0x API to generate a trade quote for two SetToken components. By default, swap quotes + * are fetched for 0x's public endpoints using their `https://api.0x.org`, `https:///api.0x.org` + * url scheme. These open endpoints are rate limited at ~3 req/sec + * + * It's also possible to make calls from non-browser context with an API key using the `https://gated.api.0x.org` + * url scheme. + * + * 0x rate-limits calls per API key as follows: + * + * > Ethereum: 10 requests per second/200 requests per minute. + * > Other networks: 30 requests per second. + * + * They also permit parallelization and allow making up to 50 requests in parallel. In testing (March 2022) + * we found this worked on Optimism and Ethereum but consistently 429'd (too many reqs) on Polygon. A + * delay step parameter option is available to stagger parallelized requests and is set to 25ms by default. + * + * @param orderPairs SwapOrderPairs array + * @param useBuyAmount When true, amount is `buyAmount` of `toToken`, + * When false, amount is `sellAmount` of `fromToken` + * @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 EthGasStation) + * @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', 'Mesh']) + * @param simulatedChainId (Optional) ChainId of target network (useful when using a forked development client) + * @param delayStep (Optional) Delay between firing each quote request (to manage rate-limiting) + * + * @return {Promise} + */ + public async batchFetchSwapQuoteAsync( + orderPairs: SwapOrderPairs[], + useBuyAmount: boolean, + fromAddress: Address, + setToken: SetTokenAPI, + gasPrice?: number, + slippagePercentage?: number, + isFirmQuote?: boolean, + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], + simulatedChainId?: number, + delayStep?: number, + ): Promise { + const self = this; + this.assert.schema.isValidAddress('fromAddress', fromAddress); + + for (const pair of orderPairs) { + this.assert.schema.isValidAddress('fromToken', pair.fromToken); + this.assert.schema.isValidAddress('toToken', pair.toToken); + this.assert.schema.isValidString('rawAmount', pair.rawAmount); + } + + // The forked Hardhat network has a chainId of 31337 so we can't rely on autofetching this value + const chainId = (simulatedChainId !== undefined) + ? simulatedChainId + : (await this.provider.getNetwork()).chainId; + + // Default 25 ms delay + const _delayStep = (delayStep !== undefined) + ? delayStep + : 25; + + const orders = []; + let delay = 0; + + for (const pair of orderPairs) { + let order; + + // We can't get a quote when `to` and `from` tokens are the same but it's helpful to be able + // to stub in null order calldata for use-cases where contract methods expect components and data + // array lengths to match. (This is a common SetProtocol design pattern). We populate + // the from and to amounts to permit pre-trade accounting by the consumer of this method + // for issuance and redemption, respectively. + if (pair.ignore === true) { + order = Promise.resolve({ + fromTokenAmount: pair.rawAmount, + toTokenAmount: pair.rawAmount, + calldata: EthersConstants.HashZero, + }); + } else { + order = new Promise(async function (resolve, reject) { + await new Promise(r => setTimeout(() => r(true), delay)); + + + try { + const response = await self.tradeQuoter.generateQuoteForSwap({ + fromToken: pair.fromToken, + toToken: pair.toToken, + rawAmount: pair.rawAmount, + useBuyAmount, + fromAddress, + chainId, + setToken, + gasPrice, + slippagePercentage, + isFirmQuote, + feePercentage, + feeRecipient, + excludedSources, + }); + + resolve(response); + } catch (e) { + reject(e); + } + }); + + delay += _delayStep; + } + + orders.push(order); + } + + return Promise.all(orders); + } + + /** + * 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(); + } + + /** + * 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(); + } + + /** + * 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 { + await this.initializeForChain(); + return this.coinGecko.fetchCoinPrices({contractAddresses, vsCurrencies}); + } + + /** + * 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(); + this.chainId = network.chainId; + this.coinGecko = new CoinGeckoDataService(network.chainId); + } + } +} diff --git a/src/api/index.ts b/src/api/index.ts index b1e8e1e..72c7836 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -12,6 +12,7 @@ import DebtIssuanceV2API from './DebtIssuanceV2API'; import SlippageIssuanceAPI from './SlippageIssuanceAPI'; import PerpV2LeverageAPI from './PerpV2LeverageAPI'; import PerpV2LeverageViewerAPI from './PerpV2LeverageViewerAPI'; +import UtilsAPI from './UtilsAPI'; import { TradeQuoter, CoinGeckoDataService, @@ -33,6 +34,7 @@ export { SlippageIssuanceAPI, PerpV2LeverageAPI, PerpV2LeverageViewerAPI, + UtilsAPI, TradeQuoter, CoinGeckoDataService, GasOracleService diff --git a/src/api/utils/tradeQuoter.ts b/src/api/utils/tradeQuoter.ts index ab7886b..8d6d16a 100644 --- a/src/api/utils/tradeQuoter.ts +++ b/src/api/utils/tradeQuoter.ts @@ -22,8 +22,11 @@ import type TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWr import { CoinGeckoCoinPrices, - QuoteOptions, + TradeQuoteOptions, + SwapQuoteOptions, TradeQuote, + SwapQuote, + ZeroExApiUrls } from '../../types/index'; import { @@ -56,20 +59,21 @@ export class TradeQuoter { private slippagePercentage: number = 2; private excludedSources: string[] = ['Kyber', 'Eth2Dai', 'Mesh']; private zeroExApiKey: string; + private zeroExApiUrls: ZeroExApiUrls; - constructor(zeroExApiKey: string = '') { + constructor(zeroExApiKey?: string, zeroExApiUrls?: ZeroExApiUrls) { this.zeroExApiKey = zeroExApiKey; + this.zeroExApiUrls = zeroExApiUrls; } /** - * 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'. + * Generates a trade quote for a token pair in a SetToken. This method is useful for + * operations like rebalancing where the ratio of existing SetToken components is modified. * - * @param options QuoteOptions: options / config to generate the quote + * @param options TradeQuoteOptions: options / config to generate the trade quote * @return TradeQuote: trade quote object */ - async generate(options: QuoteOptions): Promise { + async generateQuoteForTrade(options: TradeQuoteOptions): Promise { const chainId = options.chainId; const feePercentage = options.feePercentage || this.feePercentage; const isFirmQuote = (options.isFirmQuote === false) ? false : this.isFirmQuote; @@ -103,7 +107,7 @@ export class TradeQuoter { toTokenAmount, toUnits, calldata, - } = await this.fetchZeroExQuote( // fetchQuote (and switch...) + } = await this.fetchZeroExQuoteForTradeModule( // fetchQuote (and switch...) fromTokenAddress, toTokenAddress, fromTokenRequestAmount, @@ -126,6 +130,7 @@ export class TradeQuoter { toUnits ); + // We should use the zeroex estimates plus a constant... const gas = await this.estimateGasCost( options.tradeModule, fromTokenAddress, @@ -194,6 +199,75 @@ export class TradeQuoter { }; } + /** + * Generates a ZeroEx swap quote for any token pair. This method is useful for operations + * like ExchangeIssuance where a liquid token or native chain currency is used to acquire a + * SetToken component that will be supplied to a SetToken issuance flow. + * + * @param options SwapQuoteOptions: options / config to generate the swap quote + * @return SwapQuote: swap quote object + */ + async generateQuoteForSwap(options: SwapQuoteOptions): Promise { + const chainId = options.chainId; + const useBuyAmount = options.useBuyAmount; + const feeRecipient = options.feeRecipient || this.feeRecipient; + const excludedSources = options.excludedSources || this.excludedSources; + + const isFirmQuote = (options.isFirmQuote === false) + ? false + : this.isFirmQuote; + + const slippagePercentage = (options.slippagePercentage !== undefined) + ? options.slippagePercentage + : this.slippagePercentage; + + const feePercentage = (options.feePercentage !== undefined) + ? options.feePercentage + : this.feePercentage; + + const { + fromTokenAddress, + toTokenAddress, + fromAddress, + } = this.sanitizeAddress(options.fromToken, options.toToken, options.fromAddress); + + const amount = BigNumber.from(options.rawAmount); + + const setManager = await options.setToken.getManagerAddressAsync(fromAddress); + + const zeroEx = new ZeroExTradeQuoter({ + chainId: chainId, + zeroExApiKey: this.zeroExApiKey, + zeroExApiUrls: this.zeroExApiUrls, + }); + + const quote = await zeroEx.fetchTradeQuote( + fromTokenAddress, + toTokenAddress, + amount, + useBuyAmount, + setManager, + isFirmQuote, + (slippagePercentage / 100), + feeRecipient, + excludedSources, + (feePercentage / 100) + ); + + return { + from: fromAddress, + fromTokenAddress, + toTokenAddress, + calldata: quote.calldata, + gas: quote.gas.toString(), + gasPrice: options.gasPrice.toString(), + slippagePercentage: this.formatAsPercentage(slippagePercentage), + fromTokenAmount: quote.sellAmount.toString(), + toTokenAmount: quote.buyAmount.toString(), + _quote: quote._quote, + }; + } + private sanitizeAddress(fromToken: Address, toToken: Address, fromAddress: Address) { return { fromTokenAddress: fromToken.toLowerCase(), @@ -206,7 +280,7 @@ export class TradeQuoter { return ethersUtils.parseUnits(rawAmount, decimals); } - private async fetchZeroExQuote( + private async fetchZeroExQuoteForTradeModule( fromTokenAddress: Address, toTokenAddress: Address, fromTokenRequestAmount: BigNumber, @@ -222,12 +296,14 @@ export class TradeQuoter { const zeroEx = new ZeroExTradeQuoter({ chainId: chainId, zeroExApiKey: this.zeroExApiKey, + zeroExApiUrls: this.zeroExApiUrls, }); const quote = await zeroEx.fetchTradeQuote( fromTokenAddress, toTokenAddress, fromTokenRequestAmount, + false, // Input amount is `sellAmount` of fromToken manager, isFirmQuote, (slippagePercentage / 100), diff --git a/src/api/utils/zeroex.ts b/src/api/utils/zeroex.ts index 02e1a65..ac100dc 100644 --- a/src/api/utils/zeroex.ts +++ b/src/api/utils/zeroex.ts @@ -23,7 +23,8 @@ import Assertions from '../../assertions'; import { ZeroExTradeQuoterOptions, ZeroExTradeQuote, - ZeroExQueryParams + ZeroExQueryParams, + ZeroExApiUrls } from '../../types/index'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; @@ -46,7 +47,7 @@ export class ZeroExTradeQuoter { constructor(options: ZeroExTradeQuoterOptions) { this.assert = new Assertions(); this.assert.common.isSupportedChainId(options.chainId); - this.host = this.getHostForChain(options.chainId) as string; + this.host = this.getHostForChain(options.chainId, options.zeroExApiUrls) as string; this.zeroExApiKey = options.zeroExApiKey; } @@ -65,7 +66,8 @@ export class ZeroExTradeQuoter { async fetchTradeQuote( sellTokenAddress: Address, buyTokenAddress: Address, - sellAmount: BigNumber, + amount: BigNumber, + useBuyAmount: boolean, takerAddress: Address, isFirm: boolean, slippagePercentage: number, @@ -79,7 +81,8 @@ export class ZeroExTradeQuoter { sellToken: sellTokenAddress, buyToken: buyTokenAddress, slippagePercentage: slippagePercentage, - sellAmount: sellAmount.toString(), + sellAmount: (useBuyAmount) ? undefined : amount.toString(), + buyAmount: (useBuyAmount) ? amount.toString() : undefined, takerAddress, excludedSources: excludedSources.join(','), skipValidation: this.skipValidation, @@ -89,14 +92,22 @@ export class ZeroExTradeQuoter { intentOnFilling: isFirm, }; + // Only set the zeroExApiKey if calling `gated.api.0x.org` endpoints from a backend + // + `api.0x.org` is public - no api key is required + // + `frontend-integrations.api.0x.org` relies on IP whitelisting from 0x + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + if (this.zeroExApiKey !== undefined) { + headers['0x-api-key'] = this.zeroExApiKey; + } + try { const response = await axios.get(url, { - params: params, - headers: { - '0x-api-key': this.zeroExApiKey, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, + params, + headers, }); return { @@ -105,17 +116,23 @@ export class ZeroExTradeQuoter { sellAmount: BigNumber.from(response.data.sellAmount), buyAmount: BigNumber.from(response.data.buyAmount), calldata: response.data.data, + gas: parseInt(response.data.gas), + _quote: response.data, }; } catch (error) { throw new Error('ZeroEx quote request failed: ' + error); } } - private getHostForChain(chainId: number) { + private getHostForChain(chainId: number, zeroExAPIUrls?: ZeroExApiUrls ) { + const ethereumUrl = zeroExAPIUrls?.ethereum ? zeroExAPIUrls.ethereum : 'https://api.0x.org'; + const optimismUrl = zeroExAPIUrls?.optimism ? zeroExAPIUrls.optimism : 'https://optimism.api.0x.org'; + const polygonUrl = zeroExAPIUrls?.polygon ? zeroExAPIUrls.polygon : 'https://polygon.api.0x.org'; + switch (chainId) { - case 1: return 'https://api.0x.org'; - case 10: return 'https://optimism.api.0x.org'; - case 137: return 'https://polygon.api.0x.org'; + case 1: return ethereumUrl; + case 10: return optimismUrl; + case 137: return polygonUrl; } } } diff --git a/src/types/common.ts b/src/types/common.ts index cdae7a8..067ee7b 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -2,6 +2,7 @@ import { Provider } from '@ethersproject/providers'; import { provider as Web3CoreProvider } from 'web3-core'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; import { BigNumber } from 'ethers/lib/ethers'; +import { ZeroExApiUrls } from './utils'; export { TransactionReceipt } from 'ethereum-types'; @@ -25,6 +26,7 @@ export interface SetJSConfig { governanceModuleAddress: Address; debtIssuanceModuleAddress: Address; zeroExApiKey?: string; + zeroExApiUrls?: ZeroExApiUrls; debtIssuanceModuleV2Address: Address; slippageIssuanceModuleAddress: Address; perpV2LeverageModuleAddress: Address; diff --git a/src/types/utils.ts b/src/types/utils.ts index e611f3a..1a4006e 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -30,7 +30,7 @@ export type CoinPricesParams = { vsCurrencies: string[] }; -export type QuoteOptions = { +export type TradeQuoteOptions = { fromToken: Address, toToken: Address, fromTokenDecimals: number, @@ -49,6 +49,29 @@ export type QuoteOptions = { excludedSources?: string[], }; +export type SwapQuoteOptions = { + fromToken: Address, + toToken: Address, + rawAmount: string, + useBuyAmount: boolean, + fromAddress: Address, + chainId: number, + setToken: SetTokenAPI, + gasPrice?: number, + slippagePercentage?: number, + isFirmQuote?: boolean, + feePercentage?: number, + feeRecipient?: Address, + excludedSources?: string[], +}; + +export type SwapOrderPairs = { + fromToken: Address, + toToken: Address, + rawAmount: string, + ignore?: boolean +}; + export type ZeroExQuote = { fromTokenAmount: BigNumber, fromUnits: BigNumber, @@ -84,15 +107,36 @@ export type TradeQuote = { } }; +export type SwapQuote = { + from: Address, + fromTokenAddress: Address, + toTokenAddress: Address, + calldata: string, + gas: string, + gasPrice: string, + slippagePercentage: string, + fromTokenAmount: string, + toTokenAmount: string, + _quote: any +}; + export type ZeroExTradeQuoterOptions = { chainId: number, - zeroExApiKey: string, + zeroExApiKey?: string, + zeroExApiUrls?: ZeroExApiUrls +}; + +export type ZeroExApiUrls = { + ethereum: string, + optimism: string, + polygon: string }; export type ZeroExQueryParams = { sellToken: Address, buyToken: Address, - sellAmount: string, + sellAmount?: string, + buyAmount?: string, slippagePercentage: number, takerAddress: Address, excludedSources: string, @@ -108,7 +152,9 @@ export type ZeroExTradeQuote = { price: number, sellAmount: BigNumber, buyAmount: BigNumber, - calldata: string + calldata: string, + gas: number, + _quote: any }; export type EthGasStationData = { diff --git a/test/api/TradeAPI.spec.ts b/test/api/TradeAPI.spec.ts index 926e3e5..d120b4e 100644 --- a/test/api/TradeAPI.spec.ts +++ b/test/api/TradeAPI.spec.ts @@ -18,7 +18,6 @@ import axios from 'axios'; 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'; @@ -28,14 +27,10 @@ import TradeModuleWrapper from '@src/wrappers/set-protocol-v2/TradeModuleWrapper import type SetTokenAPI from '@src/api/SetTokenAPI'; import { TradeQuoter, - CoinGeckoDataService, } from '@src/api/utils'; import { expect } from '@test/utils/chai'; import { TradeQuote, - CoinGeckoTokenData, - CoinGeckoTokenMap, - CoinGeckoCoinPrices } from '@src/types'; import { tradeQuoteFixtures as fixture } from '../fixtures/tradeQuote'; @@ -270,7 +265,7 @@ describe('TradeAPI', () => { }; await subject(); - expect(tradeQuoter.generate).to.have.beenCalledWith(expectedQuoteOptions); + expect(tradeQuoter.generateQuoteForTrade).to.have.beenCalledWith(expectedQuoteOptions); }); describe('when the fromToken address is invalid', () => { @@ -323,205 +318,4 @@ 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.coinGeckoTokenResponsePoly.data.tokens); - }); - }); - - 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 = 137; - 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.ethGasStationResponse.data.fast / 10; - 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 49bb31e..3870b7d 100644 --- a/test/api/TradeQuoter.spec.ts +++ b/test/api/TradeQuoter.spec.ts @@ -17,7 +17,7 @@ import axios from 'axios'; import { ethers, BigNumber } from 'ethers'; import { Address } from '@setprotocol/set-protocol-v2/utils/types'; -import { CoinGeckoTokenMap, TradeQuote } from '@src/types'; +import { CoinGeckoTokenMap, TradeQuote, SwapQuote } 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'; @@ -28,6 +28,7 @@ const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545'); const DPI_ETH = '0x1494ca1f11d487c2bbe4543e90080aeba4ba3c2b'; const BUD_POLY = '0xd7dc13984d4fe87f389e50067fb3eedb3f704ea0'; +const MANAGER = '0xddddddddddeeeeeeeeeeeeefffffffffffffffff'; jest.mock('@src/api/SetTokenAPI', () => { return function() { @@ -38,6 +39,9 @@ jest.mock('@src/api/SetTokenAPI', () => { case BUD_POLY: return fixture.setDetailsResponseBUD; } }), + getManagerAddressAsync: jest.fn().mockImplementationOnce(() => { + return MANAGER; + }), }; }; }); @@ -99,7 +103,7 @@ describe('TradeQuoter', () => { tradeQuoter = new TradeQuoter('xyz'); }); - describe('generate a quote', () => { + describe('generate a trade quote', () => { let subjectFromToken: Address; let subjectToToken: Address; let subjectFromTokenDecimals: number; @@ -123,7 +127,7 @@ describe('TradeQuoter', () => { }); async function subject(): Promise { - return await tradeQuoter.generate({ + return await tradeQuoter.generateQuoteForTrade({ fromToken: subjectFromToken, toToken: subjectToToken, fromTokenDecimals: subjectFromTokenDecimals, @@ -140,9 +144,54 @@ describe('TradeQuoter', () => { it('should generate a trade quote correctly', async () => { const quote = await subject(); + expect(quote).to.be.deep.equal(fixture.setTradeQuoteEth); }); }); + + describe('generate a swap quote', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectRawAmount: string; + let subjectUseBuyAmount: boolean; + let subjectSetTokenAddress: Address; + let subjectChainId: number; + let subjectSetToken: SetTokenAPI; + let subjectGasPrice: number; + + beforeEach(async () => { + subjectFromToken = '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2'; // MKR + subjectToToken = '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e'; // YFI + subjectRawAmount = '1'; + subjectUseBuyAmount = false; + subjectSetTokenAddress = DPI_ETH; // DPI + subjectChainId = 1; + subjectSetToken = setTokenAPI; + subjectGasPrice = 10_000_000; + }); + + async function subject(): Promise { + return await tradeQuoter.generateQuoteForSwap({ + fromToken: subjectFromToken, + toToken: subjectToToken, + rawAmount: subjectRawAmount, + useBuyAmount: subjectUseBuyAmount, + fromAddress: subjectSetTokenAddress, + chainId: subjectChainId, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, + }); + } + + it('should generate a swap quote correctly', async () => { + const quote = await subject(); + + // Don't check debugging info attached to response. + delete quote._quote; + + expect(quote).to.be.deep.equal(fixture.setSwapQuoteEth); + }); + }); }); describe('polygon', () => { @@ -156,7 +205,7 @@ describe('TradeQuoter', () => { tradeQuoter = new TradeQuoter('xyz'); }); - describe('generate a quote', () => { + describe('generate a trade quote', () => { let subjectFromToken: Address; let subjectToToken: Address; let subjectFromTokenDecimals: number; @@ -180,7 +229,7 @@ describe('TradeQuoter', () => { }); async function subject(): Promise { - return await tradeQuoter.generate({ + return await tradeQuoter.generateQuoteForTrade({ fromToken: subjectFromToken, toToken: subjectToToken, fromTokenDecimals: subjectFromTokenDecimals, @@ -197,8 +246,53 @@ describe('TradeQuoter', () => { it('should generate a trade quote correctly', async () => { const quote = await subject(); + expect(quote).to.be.deep.equal(fixture.setTradeQuotePoly); }); }); + + describe('generate a swap quote', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectRawAmount: string; + let subjectUseBuyAmount: boolean; + let subjectSetTokenAddress: Address; + let subjectChainId: number; + let subjectSetToken: SetTokenAPI; + let subjectGasPrice: number; + + beforeEach(async () => { + subjectFromToken = '0x2791bca1f2de4661ed88a30c99a7a9449aa84174'; // USDC + subjectToToken = '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6'; // WBTC + subjectRawAmount = '1'; + subjectUseBuyAmount = false; + subjectSetTokenAddress = BUD_POLY; // BUD + subjectChainId = 137; + subjectSetToken = setTokenAPI; + subjectGasPrice = 10_000_000; + }); + + async function subject(): Promise { + return await tradeQuoter.generateQuoteForSwap({ + fromToken: subjectFromToken, + toToken: subjectToToken, + rawAmount: subjectRawAmount, + useBuyAmount: subjectUseBuyAmount, + fromAddress: subjectSetTokenAddress, + chainId: subjectChainId, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, + }); + } + + it('should generate a swap quote correctly', async () => { + const quote = await subject(); + + // Don't check debugging info attached to response. + delete quote._quote; + + expect(quote).to.be.deep.equal(fixture.setSwapQuotePoly); + }); + }); }); }); diff --git a/test/api/UtilsAPI.spec.ts b/test/api/UtilsAPI.spec.ts new file mode 100644 index 0000000..0f993e1 --- /dev/null +++ b/test/api/UtilsAPI.spec.ts @@ -0,0 +1,492 @@ +/* + 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 { Network } from '@ethersproject/providers'; +import { Address } from '@setprotocol/set-protocol-v2/utils/types'; + +import UtilsAPI from '@src/api/UtilsAPI'; +import type SetTokenAPI from '@src/api/SetTokenAPI'; +import { + TradeQuoter, + CoinGeckoDataService, +} from '@src/api/utils'; +import { expect } from '@test/utils/chai'; +import { + SwapQuote, + SwapOrderPairs, + 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/api/utils/tradeQuoter'); +jest.mock('axios'); +jest.mock('graph-results-pager'); + +// @ts-ignore +axios.get.mockImplementation(val => { + switch (val) { + case fixture.ethGasStationRequest: return fixture.ethGasStationResponse; + 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; + } +}); + +describe('UtilsAPI', () => { + let tradeQuoter: TradeQuoter; + let utilsAPI: UtilsAPI; + + beforeEach(async () => { + utilsAPI = new UtilsAPI(provider); + tradeQuoter = (TradeQuoter as any).mock.instances[0]; + }); + + afterEach(async () => { + (TradeQuoter as any).mockClear(); + (axios as any).mockClear(); + }); + + describe('#fetchSwapQuoteAsync', () => { + let subjectFromToken: Address; + let subjectToToken: Address; + let subjectRawAmount: string; + let subjectUseBuyAmount: boolean; + let subjectFromAddress: Address; + let subjectSetToken: SetTokenAPI; + let subjectGasPrice: number; + let subjectFeePercentage: number; + + beforeEach(async () => { + subjectFromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + subjectToToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectRawAmount = '5'; + subjectUseBuyAmount = false; + subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectSetToken = { val: 'settoken' } as SetTokenAPI; + subjectGasPrice = 20; + subjectFeePercentage = 1; + }); + + async function subject(): Promise { + return await utilsAPI.fetchSwapQuoteAsync( + subjectFromToken, + subjectToToken, + subjectRawAmount, + subjectUseBuyAmount, + subjectFromAddress, + subjectSetToken, + subjectGasPrice, + undefined, + undefined, + subjectFeePercentage + ); + } + + it('should call the TradeQuoter with correct params', async () => { + const expectedQuoteOptions = { + fromToken: subjectFromToken, + toToken: subjectToToken, + rawAmount: subjectRawAmount, + useBuyAmount: subjectUseBuyAmount, + fromAddress: subjectFromAddress, + chainId: (await provider.getNetwork()).chainId, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, + slippagePercentage: undefined, + isFirmQuote: undefined, + feePercentage: subjectFeePercentage, + feeRecipient: undefined, + excludedSources: undefined, + }; + await subject(); + + expect(tradeQuoter.generateQuoteForSwap).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 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'); + }); + }); + }); + + describe('#batchFetchSwapQuoteAsync', () => { + let fromToken: Address; + let toToken: Address; + let rawAmount: string; + let ignoredRawAmount: string; + let subjectOrderPairs: SwapOrderPairs[]; + let subjectUseBuyAmount: boolean; + let subjectFromAddress: Address; + let subjectSetToken: SetTokenAPI; + let subjectGasPrice: number; + let subjectFeePercentage: number; + + beforeEach(async () => { + fromToken = '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569'; + toToken = '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + rawAmount = '5'; + ignoredRawAmount = '10'; + + subjectOrderPairs = [ + { + fromToken, + toToken, + rawAmount, + }, + { + fromToken: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + toToken: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + rawAmount: ignoredRawAmount, + ignore: true, + }, + ]; + subjectUseBuyAmount = false; + subjectFromAddress = '0xCCCC262A92581EC09C2d522b48bCcd9E3C8ACf9C'; + subjectSetToken = { val: 'settoken' } as SetTokenAPI; + subjectGasPrice = 20; + subjectFeePercentage = 1; + }); + + async function subject(): Promise { + return await utilsAPI.batchFetchSwapQuoteAsync( + subjectOrderPairs, + subjectUseBuyAmount, + subjectFromAddress, + subjectSetToken, + subjectGasPrice, + undefined, + undefined, + subjectFeePercentage + ); + } + + it('should call the TradeQuoter with correct params', async () => { + const expectedQuoteOptions = { + fromToken, + toToken, + rawAmount, + useBuyAmount: subjectUseBuyAmount, + fromAddress: subjectFromAddress, + chainId: (await provider.getNetwork()).chainId, + setToken: subjectSetToken, + gasPrice: subjectGasPrice, + slippagePercentage: undefined, + isFirmQuote: undefined, + feePercentage: subjectFeePercentage, + feeRecipient: undefined, + excludedSources: undefined, + }; + await subject(); + + expect(tradeQuoter.generateQuoteForSwap).to.have.beenCalledWith(expectedQuoteOptions); + }); + + it('should format ignored orders correctly', async () => { + const expectedQuote = { + calldata: '0x0000000000000000000000000000000000000000000000000000000000000000', + fromTokenAmount: ignoredRawAmount, + toTokenAmount: ignoredRawAmount, + }; + const quotes = await subject(); + + expect(quotes[1]).to.deep.equal(expectedQuote); + }); + + describe('when a fromToken address is invalid', () => { + beforeEach(async () => { + subjectOrderPairs = [ + { + fromToken: '0xInvalidAddress', + toToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', + rawAmount: '5', + }, + ]; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when a toToken address is invalid', () => { + beforeEach(async () => { + subjectOrderPairs = [ + { + fromToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', + toToken: '0xInvalidAddress', + rawAmount: '5', + }, + ]; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + + describe('when a rawAmount quantity is invalid', () => { + beforeEach(async () => { + subjectOrderPairs = [ + { + fromToken: '0xAAAA15AA9B462ed4fC84B5dFc43Fd2a10a54B569', + toToken: '0xBBBB262A92581EC09C2d522b48bCcd9E3C8ACf9C', + rawAmount: 5 as string, + }, + ]; + }); + + it('should throw with invalid params', async () => { + await expect(subject()).to.be.rejectedWith('Validation error'); + }); + }); + }); + + describe('#fetchTokenListAsync', () => { + let subjectChainId; + + async function subject(): Promise { + return await utilsAPI.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.coinGeckoTokenResponsePoly.data.tokens); + }); + }); + + 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 utilsAPI.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 utilsAPI.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 = 137; + provider.getNetwork = jest.fn(() => Promise.resolve({ chainId: subjectChainId } as Network )); + subjectCoinGecko = new CoinGeckoDataService(subjectChainId); + subjectTokenList = await utilsAPI.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 utilsAPI.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 utilsAPI.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.ethGasStationResponse.data.fast / 10; + 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/fixtures/tradeQuote.ts b/test/fixtures/tradeQuote.ts index 9b079b6..8065172 100644 --- a/test/fixtures/tradeQuote.ts +++ b/test/fixtures/tradeQuote.ts @@ -185,6 +185,18 @@ export const tradeQuoteFixtures = { }, }, + setSwapQuoteEth: { + from: '0x1494ca1f11d487c2bbe4543e90080aeba4ba3c2b', + fromTokenAddress: '0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2', + toTokenAddress: '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', + calldata: '0x415565b00000000000000000000000009f8f72aa9304c8b593d555f12ef6589cc3a579a2', + gas: '346000', + gasPrice: '10000000', + slippagePercentage: '2.00%', + fromTokenAmount: '499999999999793729', + toTokenAmount: '41312691160507030', + }, + setTradeQuotePoly: { from: '0xd7dc13984d4fe87f389e50067fb3eedb3f704ea0', fromTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', @@ -210,4 +222,17 @@ export const tradeQuoteFixtures = { feePercentage: '0.00%', slippage: '1.11%' }, }, + + setSwapQuotePoly: { + from: '0xd7dc13984d4fe87f389e50067fb3eedb3f704ea0', + fromTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + toTokenAddress: '0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6', + calldata: + '0x415565b00000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174', + gas: '240000', + gasPrice: '10000000', + slippagePercentage: '2.00%', + fromTokenAmount: '1000000', + toTokenAmount: '2973', + }, };