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..064ae985 --- /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"