import { ContractWrappers, ContractWrappersError, ForwarderWrapperError } from '@0x/contract-wrappers'; import { schemas } from '@0x/json-schemas'; import { SignedOrder } from '@0x/order-utils'; import { ObjectMap } from '@0x/types'; import { BigNumber } from '@0x/utils'; import { Web3Wrapper } from '@0x/web3-wrapper'; import { Provider } from 'ethereum-types'; import * as _ from 'lodash'; import { constants } from './constants'; import { BasicOrderProvider } from './order_providers/basic_order_provider'; import { StandardRelayerAPIOrderProvider } from './order_providers/standard_relayer_api_order_provider'; import { AssetBuyerError, AssetBuyerOpts, BuyQuote, BuyQuoteExecutionOpts, BuyQuoteRequestOpts, OrderProvider, OrderProviderResponse, OrdersAndFillableAmounts, } from './types'; import { assert } from './utils/assert'; import { assetDataUtils } from './utils/asset_data_utils'; import { buyQuoteCalculator } from './utils/buy_quote_calculator'; import { orderProviderResponseProcessor } from './utils/order_provider_response_processor'; interface OrdersEntry { ordersAndFillableAmounts: OrdersAndFillableAmounts; lastRefreshTime: number; } export class AssetBuyer { public readonly provider: Provider; public readonly orderProvider: OrderProvider; public readonly networkId: number; public readonly orderRefreshIntervalMs: number; public readonly expiryBufferSeconds: number; private readonly _contractWrappers: ContractWrappers; // cache of orders along with the time last updated keyed by assetData private readonly _ordersEntryMap: ObjectMap = {}; /** * Instantiates a new AssetBuyer instance given existing liquidity in the form of orders and feeOrders. * @param provider The Provider instance you would like to use for interacting with the Ethereum network. * @param orders A non-empty array of objects that conform to SignedOrder. All orders must have the same makerAssetData and takerAssetData (WETH). * @param feeOrders A array of objects that conform to SignedOrder. All orders must have the same makerAssetData (ZRX) and takerAssetData (WETH). Defaults to an empty array. * @param options Initialization options for the AssetBuyer. See type definition for details. * * @return An instance of AssetBuyer */ public static getAssetBuyerForProvidedOrders( provider: Provider, orders: SignedOrder[], options: Partial = {}, ): AssetBuyer { assert.isWeb3Provider('provider', provider); assert.doesConformToSchema('orders', orders, schemas.signedOrdersSchema); assert.assert(orders.length !== 0, `Expected orders to contain at least one order`); const orderProvider = new BasicOrderProvider(orders); const assetBuyer = new AssetBuyer(provider, orderProvider, options); return assetBuyer; } /** * Instantiates a new AssetBuyer instance given a [Standard Relayer API](https://github.com/0xProject/standard-relayer-api) endpoint * @param provider The Provider instance you would like to use for interacting with the Ethereum network. * @param sraApiUrl The standard relayer API base HTTP url you would like to source orders from. * @param options Initialization options for the AssetBuyer. See type definition for details. * * @return An instance of AssetBuyer */ public static getAssetBuyerForStandardRelayerAPIUrl( provider: Provider, sraApiUrl: string, options: Partial = {}, ): AssetBuyer { assert.isWeb3Provider('provider', provider); assert.isWebUri('sraApiUrl', sraApiUrl); const networkId = options.networkId || constants.DEFAULT_ASSET_BUYER_OPTS.networkId; const orderProvider = new StandardRelayerAPIOrderProvider(sraApiUrl, networkId); const assetBuyer = new AssetBuyer(provider, orderProvider, options); return assetBuyer; } /** * Instantiates a new AssetBuyer instance * @param provider The Provider instance you would like to use for interacting with the Ethereum network. * @param orderProvider An object that conforms to OrderProvider, see type for definition. * @param options Initialization options for the AssetBuyer. See type definition for details. * * @return An instance of AssetBuyer */ constructor(provider: Provider, orderProvider: OrderProvider, options: Partial = {}) { const { networkId, orderRefreshIntervalMs, expiryBufferSeconds } = _.merge( {}, constants.DEFAULT_ASSET_BUYER_OPTS, options, ); assert.isWeb3Provider('provider', provider); assert.isValidOrderProvider('orderProvider', orderProvider); assert.isNumber('networkId', networkId); assert.isNumber('orderRefreshIntervalMs', orderRefreshIntervalMs); assert.isNumber('expiryBufferSeconds', expiryBufferSeconds); this.provider = provider; this.orderProvider = orderProvider; this.networkId = networkId; this.orderRefreshIntervalMs = orderRefreshIntervalMs; this.expiryBufferSeconds = expiryBufferSeconds; this._contractWrappers = new ContractWrappers(this.provider, { networkId, }); } /** * Get a `BuyQuote` containing all information relevant to fulfilling a buy given a desired assetData. * You can then pass the `BuyQuote` to `executeBuyQuoteAsync` to execute the buy. * @param assetData The assetData of the desired asset to buy (for more info: https://github.com/0xProject/0x-protocol-specification/blob/master/v2/v2-specification.md). * @param assetBuyAmount The amount of asset to buy. * @param options Options for the request. See type definition for more information. * * @return An object that conforms to BuyQuote that satisfies the request. See type definition for more information. */ public async getBuyQuoteAsync( assetData: string, assetBuyAmount: BigNumber, options: Partial = {}, ): Promise { const { feePercentage, shouldForceOrderRefresh, slippagePercentage } = _.merge( {}, constants.DEFAULT_BUY_QUOTE_REQUEST_OPTS, options, ); assert.isString('assetData', assetData); assert.isBigNumber('assetBuyAmount', assetBuyAmount); assert.isValidPercentage('feePercentage', feePercentage); assert.isBoolean('shouldForceOrderRefresh', shouldForceOrderRefresh); assert.isNumber('slippagePercentage', slippagePercentage); const zrxTokenAssetData = this._getZrxTokenAssetDataOrThrow(); const isMakerAssetZrxToken = assetData === zrxTokenAssetData; // get the relevant orders for the makerAsset and fees // if the requested assetData is ZRX, don't get the fee info const [ordersAndFillableAmounts, feeOrdersAndFillableAmounts] = await Promise.all([ this._getOrdersAndFillableAmountsAsync(assetData, shouldForceOrderRefresh), isMakerAssetZrxToken ? Promise.resolve(constants.EMPTY_ORDERS_AND_FILLABLE_AMOUNTS) : this._getOrdersAndFillableAmountsAsync(zrxTokenAssetData, shouldForceOrderRefresh), shouldForceOrderRefresh, ]); if (ordersAndFillableAmounts.orders.length === 0) { throw new Error(`${AssetBuyerError.AssetUnavailable}: For assetData ${assetData}`); } const buyQuote = buyQuoteCalculator.calculate( ordersAndFillableAmounts, feeOrdersAndFillableAmounts, assetBuyAmount, feePercentage, slippagePercentage, isMakerAssetZrxToken, ); return buyQuote; } /** * Get a `BuyQuote` containing all information relevant to fulfilling a buy given a desired ERC20 token address. * You can then pass the `BuyQuote` to `executeBuyQuoteAsync` to execute the buy. * @param tokenAddress The ERC20 token address. * @param assetBuyAmount The amount of asset to buy. * @param options Options for the request. See type definition for more information. * * @return An object that conforms to BuyQuote that satisfies the request. See type definition for more information. */ public async getBuyQuoteForERC20TokenAddressAsync( tokenAddress: string, assetBuyAmount: BigNumber, options: Partial = {}, ): Promise { assert.isETHAddressHex('tokenAddress', tokenAddress); assert.isBigNumber('assetBuyAmount', assetBuyAmount); const assetData = assetDataUtils.encodeERC20AssetData(tokenAddress); const buyQuote = this.getBuyQuoteAsync(assetData, assetBuyAmount, options); return buyQuote; } /** * Given a BuyQuote and desired rate, attempt to execute the buy. * @param buyQuote An object that conforms to BuyQuote. See type definition for more information. * @param options Options for the execution of the BuyQuote. See type definition for more information. * * @return A promise of the txHash. */ public async executeBuyQuoteAsync( buyQuote: BuyQuote, options: Partial = {}, ): Promise { const { ethAmount, takerAddress, feeRecipient, gasLimit, gasPrice } = _.merge( {}, constants.DEFAULT_BUY_QUOTE_EXECUTION_OPTS, options, ); assert.isValidBuyQuote('buyQuote', buyQuote); if (!_.isUndefined(ethAmount)) { assert.isBigNumber('ethAmount', ethAmount); } if (!_.isUndefined(takerAddress)) { assert.isETHAddressHex('takerAddress', takerAddress); } assert.isETHAddressHex('feeRecipient', feeRecipient); if (!_.isUndefined(gasLimit)) { assert.isNumber('gasLimit', gasLimit); } if (!_.isUndefined(gasPrice)) { assert.isBigNumber('gasPrice', gasPrice); } const { orders, feeOrders, feePercentage, assetBuyAmount, worstCaseQuoteInfo } = buyQuote; // if no takerAddress is provided, try to get one from the provider let finalTakerAddress; if (!_.isUndefined(takerAddress)) { finalTakerAddress = takerAddress; } else { const web3Wrapper = new Web3Wrapper(this.provider); const availableAddresses = await web3Wrapper.getAvailableAddressesAsync(); const firstAvailableAddress = _.head(availableAddresses); if (!_.isUndefined(firstAvailableAddress)) { finalTakerAddress = firstAvailableAddress; } else { throw new Error(AssetBuyerError.NoAddressAvailable); } } try { // if no ethAmount is provided, default to the worst ethAmount from buyQuote const txHash = await this._contractWrappers.forwarder.marketBuyOrdersWithEthAsync( orders, assetBuyAmount, finalTakerAddress, ethAmount || worstCaseQuoteInfo.totalEthAmount, feeOrders, feePercentage, feeRecipient, { gasLimit, gasPrice, shouldValidate: true, }, ); return txHash; } catch (err) { if (_.includes(err.message, ContractWrappersError.SignatureRequestDenied)) { throw new Error(AssetBuyerError.SignatureRequestDenied); } else if (_.includes(err.message, ForwarderWrapperError.CompleteFillFailed)) { throw new Error(AssetBuyerError.TransactionValueTooLow); } else { throw err; } } } /** * Get the asset data of all assets that are purchaseable with ether token (wETH) in the order provider passed in at init. * * @return An array of asset data strings that can be purchased using wETH. */ public async getAvailableAssetDatasAsync(): Promise { const etherTokenAssetData = this._getEtherTokenAssetDataOrThrow(); return this.orderProvider.getAvailableMakerAssetDatasAsync(etherTokenAssetData); } /** * Grab orders from the map, if there is a miss or it is time to refresh, fetch and process the orders */ private async _getOrdersAndFillableAmountsAsync( assetData: string, shouldForceOrderRefresh: boolean, ): Promise { // try to get ordersEntry from the map const ordersEntryIfExists = this._ordersEntryMap[assetData]; // we should refresh if: // we do not have any orders OR // we are forced to OR // we have some last refresh time AND that time was sufficiently long ago const shouldRefresh = _.isUndefined(ordersEntryIfExists) || shouldForceOrderRefresh || // tslint:disable:restrict-plus-operands ordersEntryIfExists.lastRefreshTime + this.orderRefreshIntervalMs < Date.now(); if (!shouldRefresh) { const result = ordersEntryIfExists.ordersAndFillableAmounts; return result; } const etherTokenAssetData = this._getEtherTokenAssetDataOrThrow(); const zrxTokenAssetData = this._getZrxTokenAssetDataOrThrow(); // construct orderProvider request const orderProviderRequest = { makerAssetData: assetData, takerAssetData: etherTokenAssetData, networkId: this.networkId, }; const request = orderProviderRequest; // get provider response const response = await this.orderProvider.getOrdersAsync(request); // since the order provider is an injected dependency, validate that it respects the API // ie. it should only return maker/taker assetDatas that are specified orderProviderResponseProcessor.throwIfInvalidResponse(response, request); // process the responses into one object const isMakerAssetZrxToken = assetData === zrxTokenAssetData; const ordersAndFillableAmounts = await orderProviderResponseProcessor.processAsync( response, isMakerAssetZrxToken, this.expiryBufferSeconds, this._contractWrappers.orderValidator, ); const lastRefreshTime = Date.now(); const updatedOrdersEntry = { ordersAndFillableAmounts, lastRefreshTime, }; this._ordersEntryMap[assetData] = updatedOrdersEntry; return ordersAndFillableAmounts; } /** * Get the assetData that represents the WETH token. * Will throw if WETH does not exist for the current network. */ private _getEtherTokenAssetDataOrThrow(): string { return assetDataUtils.getEtherTokenAssetData(this._contractWrappers); } /** * Get the assetData that represents the ZRX token. * Will throw if ZRX does not exist for the current network. */ private _getZrxTokenAssetDataOrThrow(): string { return this._contractWrappers.exchange.getZRXAssetData(); } }