diff options
Diffstat (limited to 'packages/asset-buyer/src')
-rw-r--r-- | packages/asset-buyer/src/asset_buyer.ts | 327 | ||||
-rw-r--r-- | packages/asset-buyer/src/constants.ts | 40 | ||||
-rw-r--r-- | packages/asset-buyer/src/globals.d.ts | 6 | ||||
-rw-r--r-- | packages/asset-buyer/src/index.ts | 25 | ||||
-rw-r--r-- | packages/asset-buyer/src/order_providers/basic_order_provider.ts | 41 | ||||
-rw-r--r-- | packages/asset-buyer/src/order_providers/standard_relayer_api_order_provider.ts | 105 | ||||
-rw-r--r-- | packages/asset-buyer/src/types.ts | 123 | ||||
-rw-r--r-- | packages/asset-buyer/src/utils/assert.ts | 39 | ||||
-rw-r--r-- | packages/asset-buyer/src/utils/asset_data_utils.ts | 12 | ||||
-rw-r--r-- | packages/asset-buyer/src/utils/buy_quote_calculator.ts | 207 | ||||
-rw-r--r-- | packages/asset-buyer/src/utils/order_provider_response_processor.ts | 169 | ||||
-rw-r--r-- | packages/asset-buyer/src/utils/order_utils.ts | 74 |
12 files changed, 1168 insertions, 0 deletions
diff --git a/packages/asset-buyer/src/asset_buyer.ts b/packages/asset-buyer/src/asset_buyer.ts new file mode 100644 index 000000000..934410c55 --- /dev/null +++ b/packages/asset-buyer/src/asset_buyer.ts @@ -0,0 +1,327 @@ +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<OrdersEntry> = {}; + /** + * 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<AssetBuyerOpts> = {}, + ): 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<AssetBuyerOpts> = {}, + ): 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<AssetBuyerOpts> = {}) { + 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<BuyQuoteRequestOpts> = {}, + ): Promise<BuyQuote> { + 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<BuyQuoteRequestOpts> = {}, + ): Promise<BuyQuote> { + 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<BuyQuoteExecutionOpts> = {}, + ): Promise<string> { + 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<string[]> { + 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<OrdersAndFillableAmounts> { + // 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(); + } +} diff --git a/packages/asset-buyer/src/constants.ts b/packages/asset-buyer/src/constants.ts new file mode 100644 index 000000000..c0e1bf27d --- /dev/null +++ b/packages/asset-buyer/src/constants.ts @@ -0,0 +1,40 @@ +import { SignedOrder } from '@0x/types'; +import { BigNumber } from '@0x/utils'; + +import { AssetBuyerOpts, BuyQuoteExecutionOpts, BuyQuoteRequestOpts, OrdersAndFillableAmounts } from './types'; + +const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'; +const MAINNET_NETWORK_ID = 1; + +const DEFAULT_ASSET_BUYER_OPTS: AssetBuyerOpts = { + networkId: MAINNET_NETWORK_ID, + orderRefreshIntervalMs: 10000, // 10 seconds + expiryBufferSeconds: 120, // 2 minutes +}; + +const DEFAULT_BUY_QUOTE_REQUEST_OPTS: BuyQuoteRequestOpts = { + feePercentage: 0, + shouldForceOrderRefresh: false, + slippagePercentage: 0.2, // 20% slippage protection +}; + +// Other default values are dynamically determined +const DEFAULT_BUY_QUOTE_EXECUTION_OPTS: BuyQuoteExecutionOpts = { + feeRecipient: NULL_ADDRESS, +}; + +const EMPTY_ORDERS_AND_FILLABLE_AMOUNTS: OrdersAndFillableAmounts = { + orders: [] as SignedOrder[], + remainingFillableMakerAssetAmounts: [] as BigNumber[], +}; + +export const constants = { + ZERO_AMOUNT: new BigNumber(0), + NULL_ADDRESS, + MAINNET_NETWORK_ID, + ETHER_TOKEN_DECIMALS: 18, + DEFAULT_ASSET_BUYER_OPTS, + DEFAULT_BUY_QUOTE_EXECUTION_OPTS, + DEFAULT_BUY_QUOTE_REQUEST_OPTS, + EMPTY_ORDERS_AND_FILLABLE_AMOUNTS, +}; diff --git a/packages/asset-buyer/src/globals.d.ts b/packages/asset-buyer/src/globals.d.ts new file mode 100644 index 000000000..94e63a32d --- /dev/null +++ b/packages/asset-buyer/src/globals.d.ts @@ -0,0 +1,6 @@ +declare module '*.json' { + const json: any; + /* tslint:disable */ + export default json; + /* tslint:enable */ +} diff --git a/packages/asset-buyer/src/index.ts b/packages/asset-buyer/src/index.ts new file mode 100644 index 000000000..8418edb42 --- /dev/null +++ b/packages/asset-buyer/src/index.ts @@ -0,0 +1,25 @@ +export { + JSONRPCRequestPayload, + JSONRPCResponsePayload, + JSONRPCResponseError, + JSONRPCErrorCallback, + Provider, +} from 'ethereum-types'; +export { SignedOrder } from '@0x/types'; +export { BigNumber } from '@0x/utils'; + +export { AssetBuyer } from './asset_buyer'; +export { BasicOrderProvider } from './order_providers/basic_order_provider'; +export { StandardRelayerAPIOrderProvider } from './order_providers/standard_relayer_api_order_provider'; +export { + AssetBuyerError, + AssetBuyerOpts, + BuyQuote, + BuyQuoteExecutionOpts, + BuyQuoteInfo, + BuyQuoteRequestOpts, + OrderProvider, + OrderProviderRequest, + OrderProviderResponse, + SignedOrderWithRemainingFillableMakerAssetAmount, +} from './types'; diff --git a/packages/asset-buyer/src/order_providers/basic_order_provider.ts b/packages/asset-buyer/src/order_providers/basic_order_provider.ts new file mode 100644 index 000000000..76685f27a --- /dev/null +++ b/packages/asset-buyer/src/order_providers/basic_order_provider.ts @@ -0,0 +1,41 @@ +import { schemas } from '@0x/json-schemas'; +import { SignedOrder } from '@0x/types'; +import * as _ from 'lodash'; + +import { OrderProvider, OrderProviderRequest, OrderProviderResponse } from '../types'; +import { assert } from '../utils/assert'; + +export class BasicOrderProvider implements OrderProvider { + public readonly orders: SignedOrder[]; + /** + * Instantiates a new BasicOrderProvider instance + * @param orders An array of objects that conform to SignedOrder to fetch from. + * @return An instance of BasicOrderProvider + */ + constructor(orders: SignedOrder[]) { + assert.doesConformToSchema('orders', orders, schemas.signedOrdersSchema); + this.orders = orders; + } + /** + * Given an object that conforms to OrderFetcherRequest, return the corresponding OrderProviderResponse that satisfies the request. + * @param orderProviderRequest An instance of OrderFetcherRequest. See type for more information. + * @return An instance of OrderProviderResponse. See type for more information. + */ + public async getOrdersAsync(orderProviderRequest: OrderProviderRequest): Promise<OrderProviderResponse> { + assert.isValidOrderProviderRequest('orderProviderRequest', orderProviderRequest); + const { makerAssetData, takerAssetData } = orderProviderRequest; + const orders = _.filter(this.orders, order => { + return order.makerAssetData === makerAssetData && order.takerAssetData === takerAssetData; + }); + return { orders }; + } + /** + * Given a taker asset data string, return all availabled paired maker asset data strings. + * @param takerAssetData A string representing the taker asset data. + * @return An array of asset data strings that can be purchased using takerAssetData. + */ + public async getAvailableMakerAssetDatasAsync(takerAssetData: string): Promise<string[]> { + const ordersWithTakerAssetData = _.filter(this.orders, { takerAssetData }); + return _.map(ordersWithTakerAssetData, order => order.makerAssetData); + } +} diff --git a/packages/asset-buyer/src/order_providers/standard_relayer_api_order_provider.ts b/packages/asset-buyer/src/order_providers/standard_relayer_api_order_provider.ts new file mode 100644 index 000000000..be1fc55d6 --- /dev/null +++ b/packages/asset-buyer/src/order_providers/standard_relayer_api_order_provider.ts @@ -0,0 +1,105 @@ +import { HttpClient } from '@0x/connect'; +import { APIOrder, AssetPairsResponse, OrderbookResponse } from '@0x/types'; +import * as _ from 'lodash'; + +import { + AssetBuyerError, + OrderProvider, + OrderProviderRequest, + OrderProviderResponse, + SignedOrderWithRemainingFillableMakerAssetAmount, +} from '../types'; +import { assert } from '../utils/assert'; +import { orderUtils } from '../utils/order_utils'; + +export class StandardRelayerAPIOrderProvider implements OrderProvider { + public readonly apiUrl: string; + public readonly networkId: number; + private readonly _sraClient: HttpClient; + /** + * Given an array of APIOrder objects from a standard relayer api, return an array + * of SignedOrderWithRemainingFillableMakerAssetAmounts + */ + private static _getSignedOrderWithRemainingFillableMakerAssetAmountFromApi( + apiOrders: APIOrder[], + ): SignedOrderWithRemainingFillableMakerAssetAmount[] { + const result = _.map(apiOrders, apiOrder => { + const { order, metaData } = apiOrder; + // calculate remainingFillableMakerAssetAmount from api metadata, else assume order is completely fillable + const remainingFillableTakerAssetAmount = _.get( + metaData, + 'remainingTakerAssetAmount', + order.takerAssetAmount, + ); + const remainingFillableMakerAssetAmount = orderUtils.getRemainingMakerAmount( + order, + remainingFillableTakerAssetAmount, + ); + const newOrder = { + ...order, + remainingFillableMakerAssetAmount, + }; + return newOrder; + }); + return result; + } + /** + * Instantiates a new StandardRelayerAPIOrderProvider instance + * @param apiUrl The standard relayer API base HTTP url you would like to source orders from. + * @param networkId The ethereum network id. + * @return An instance of StandardRelayerAPIOrderProvider + */ + constructor(apiUrl: string, networkId: number) { + assert.isWebUri('apiUrl', apiUrl); + assert.isNumber('networkId', networkId); + this.apiUrl = apiUrl; + this.networkId = networkId; + this._sraClient = new HttpClient(apiUrl); + } + /** + * Given an object that conforms to OrderProviderRequest, return the corresponding OrderProviderResponse that satisfies the request. + * @param orderProviderRequest An instance of OrderProviderRequest. See type for more information. + * @return An instance of OrderProviderResponse. See type for more information. + */ + public async getOrdersAsync(orderProviderRequest: OrderProviderRequest): Promise<OrderProviderResponse> { + assert.isValidOrderProviderRequest('orderProviderRequest', orderProviderRequest); + const { makerAssetData, takerAssetData } = orderProviderRequest; + const orderbookRequest = { baseAssetData: makerAssetData, quoteAssetData: takerAssetData }; + const requestOpts = { networkId: this.networkId }; + let orderbook: OrderbookResponse; + try { + orderbook = await this._sraClient.getOrderbookAsync(orderbookRequest, requestOpts); + } catch (err) { + throw new Error(AssetBuyerError.StandardRelayerApiError); + } + const apiOrders = orderbook.asks.records; + const orders = StandardRelayerAPIOrderProvider._getSignedOrderWithRemainingFillableMakerAssetAmountFromApi( + apiOrders, + ); + return { + orders, + }; + } + /** + * Given a taker asset data string, return all availabled paired maker asset data strings. + * @param takerAssetData A string representing the taker asset data. + * @return An array of asset data strings that can be purchased using takerAssetData. + */ + public async getAvailableMakerAssetDatasAsync(takerAssetData: string): Promise<string[]> { + // Return a maximum of 1000 asset datas + const maxPerPage = 1000; + const requestOpts = { networkId: this.networkId, perPage: maxPerPage }; + const assetPairsRequest = { assetDataA: takerAssetData }; + const fullRequest = { + ...requestOpts, + ...assetPairsRequest, + }; + let response: AssetPairsResponse; + try { + response = await this._sraClient.getAssetPairsAsync(fullRequest); + } catch (err) { + throw new Error(AssetBuyerError.StandardRelayerApiError); + } + return _.map(response.records, item => item.assetDataB.assetData); + } +} diff --git a/packages/asset-buyer/src/types.ts b/packages/asset-buyer/src/types.ts new file mode 100644 index 000000000..3f1e6ff21 --- /dev/null +++ b/packages/asset-buyer/src/types.ts @@ -0,0 +1,123 @@ +import { SignedOrder } from '@0x/types'; +import { BigNumber } from '@0x/utils'; + +/** + * makerAssetData: The assetData representing the desired makerAsset. + * takerAssetData: The assetData representing the desired takerAsset. + * networkId: The networkId that the desired orders should be for. + */ +export interface OrderProviderRequest { + makerAssetData: string; + takerAssetData: string; +} + +/** + * orders: An array of orders with optional remaining fillable makerAsset amounts. See type for more info. + */ +export interface OrderProviderResponse { + orders: SignedOrderWithRemainingFillableMakerAssetAmount[]; +} + +/** + * A normal SignedOrder with one extra optional property `remainingFillableMakerAssetAmount` + * remainingFillableMakerAssetAmount: The amount of the makerAsset that is available to be filled + */ +export interface SignedOrderWithRemainingFillableMakerAssetAmount extends SignedOrder { + remainingFillableMakerAssetAmount?: BigNumber; +} +/** + * gerOrdersAsync: Given an OrderProviderRequest, get an OrderProviderResponse. + * getAvailableMakerAssetDatasAsync: Given a taker asset data string, return all availabled paired maker asset data strings. + */ +export interface OrderProvider { + getOrdersAsync: (orderProviderRequest: OrderProviderRequest) => Promise<OrderProviderResponse>; + getAvailableMakerAssetDatasAsync: (takerAssetData: string) => Promise<string[]>; +} + +/** + * assetData: String that represents a specific asset (for more info: https://github.com/0xProject/0x-protocol-specification/blob/master/v2/v2-specification.md). + * assetBuyAmount: The amount of asset to buy. + * orders: An array of objects conforming to SignedOrder. These orders can be used to cover the requested assetBuyAmount plus slippage. + * feeOrders: An array of objects conforming to SignedOrder. These orders can be used to cover the fees for the orders param above. + * feePercentage: Optional affiliate fee percentage used to calculate the eth amounts above. + * bestCaseQuoteInfo: Info about the best case price for the asset. + * worstCaseQuoteInfo: Info about the worst case price for the asset. + */ +export interface BuyQuote { + assetData: string; + assetBuyAmount: BigNumber; + orders: SignedOrder[]; + feeOrders: SignedOrder[]; + feePercentage?: number; + bestCaseQuoteInfo: BuyQuoteInfo; + worstCaseQuoteInfo: BuyQuoteInfo; +} + +/** + * ethPerAssetPrice: The price of one unit of the desired asset in ETH + * feeEthAmount: The amount of eth required to pay the affiliate fee. + * totalEthAmount: the total amount of eth required to complete the buy. (Filling orders, feeOrders, and paying affiliate fee) + */ +export interface BuyQuoteInfo { + ethPerAssetPrice: BigNumber; + feeEthAmount: BigNumber; + totalEthAmount: BigNumber; +} + +/** + * feePercentage: The affiliate fee percentage. Defaults to 0. + * shouldForceOrderRefresh: If set to true, new orders and state will be fetched instead of waiting for the next orderRefreshIntervalMs. Defaults to false. + * slippagePercentage: The percentage buffer to add to account for slippage. Affects max ETH price estimates. Defaults to 0.2 (20%). + */ +export interface BuyQuoteRequestOpts { + feePercentage: number; + shouldForceOrderRefresh: boolean; + slippagePercentage: number; +} + +/** + * ethAmount: The desired amount of eth to spend. Defaults to buyQuote.worstCaseQuoteInfo.totalEthAmount. + * takerAddress: The address to perform the buy. Defaults to the first available address from the provider. + * gasLimit: The amount of gas to send with a transaction (in Gwei). Defaults to an eth_estimateGas rpc call. + * gasPrice: Gas price in Wei to use for a transaction + * feeRecipient: The address where affiliate fees are sent. Defaults to null address (0x000...000). + */ +export interface BuyQuoteExecutionOpts { + ethAmount?: BigNumber; + takerAddress?: string; + gasLimit?: number; + gasPrice?: BigNumber; + feeRecipient: string; +} + +/** + * networkId: The ethereum network id. Defaults to 1 (mainnet). + * orderRefreshIntervalMs: The interval in ms that getBuyQuoteAsync should trigger an refresh of orders and order states. Defaults to 10000ms (10s). + * expiryBufferSeconds: The number of seconds to add when calculating whether an order is expired or not. Defaults to 300s (5m). + */ +export interface AssetBuyerOpts { + networkId: number; + orderRefreshIntervalMs: number; + expiryBufferSeconds: number; +} + +/** + * Possible errors thrown by an AssetBuyer instance or associated static methods. + */ +export enum AssetBuyerError { + NoEtherTokenContractFound = 'NO_ETHER_TOKEN_CONTRACT_FOUND', + NoZrxTokenContractFound = 'NO_ZRX_TOKEN_CONTRACT_FOUND', + StandardRelayerApiError = 'STANDARD_RELAYER_API_ERROR', + InsufficientAssetLiquidity = 'INSUFFICIENT_ASSET_LIQUIDITY', + InsufficientZrxLiquidity = 'INSUFFICIENT_ZRX_LIQUIDITY', + NoAddressAvailable = 'NO_ADDRESS_AVAILABLE', + InvalidOrderProviderResponse = 'INVALID_ORDER_PROVIDER_RESPONSE', + AssetUnavailable = 'ASSET_UNAVAILABLE', + SignatureRequestDenied = 'SIGNATURE_REQUEST_DENIED', + TransactionValueTooLow = 'TRANSACTION_VALUE_TOO_LOW', +} + +export interface OrdersAndFillableAmounts { + orders: SignedOrder[]; + remainingFillableMakerAssetAmounts: BigNumber[]; +} diff --git a/packages/asset-buyer/src/utils/assert.ts b/packages/asset-buyer/src/utils/assert.ts new file mode 100644 index 000000000..2466f53a4 --- /dev/null +++ b/packages/asset-buyer/src/utils/assert.ts @@ -0,0 +1,39 @@ +import { assert as sharedAssert } from '@0x/assert'; +import { schemas } from '@0x/json-schemas'; +import * as _ from 'lodash'; + +import { BuyQuote, BuyQuoteInfo, OrderProvider, OrderProviderRequest } from '../types'; + +export const assert = { + ...sharedAssert, + isValidBuyQuote(variableName: string, buyQuote: BuyQuote): void { + sharedAssert.isHexString(`${variableName}.assetData`, buyQuote.assetData); + sharedAssert.doesConformToSchema(`${variableName}.orders`, buyQuote.orders, schemas.signedOrdersSchema); + sharedAssert.doesConformToSchema(`${variableName}.feeOrders`, buyQuote.feeOrders, schemas.signedOrdersSchema); + assert.isValidBuyQuoteInfo(`${variableName}.bestCaseQuoteInfo`, buyQuote.bestCaseQuoteInfo); + assert.isValidBuyQuoteInfo(`${variableName}.worstCaseQuoteInfo`, buyQuote.worstCaseQuoteInfo); + sharedAssert.isBigNumber(`${variableName}.assetBuyAmount`, buyQuote.assetBuyAmount); + if (!_.isUndefined(buyQuote.feePercentage)) { + sharedAssert.isNumber(`${variableName}.feePercentage`, buyQuote.feePercentage); + } + }, + isValidBuyQuoteInfo(variableName: string, buyQuoteInfo: BuyQuoteInfo): void { + sharedAssert.isBigNumber(`${variableName}.ethPerAssetPrice`, buyQuoteInfo.ethPerAssetPrice); + sharedAssert.isBigNumber(`${variableName}.feeEthAmount`, buyQuoteInfo.feeEthAmount); + sharedAssert.isBigNumber(`${variableName}.totalEthAmount`, buyQuoteInfo.totalEthAmount); + }, + isValidOrderProvider(variableName: string, orderFetcher: OrderProvider): void { + sharedAssert.isFunction(`${variableName}.getOrdersAsync`, orderFetcher.getOrdersAsync); + }, + isValidOrderProviderRequest(variableName: string, orderFetcherRequest: OrderProviderRequest): void { + sharedAssert.isHexString(`${variableName}.makerAssetData`, orderFetcherRequest.makerAssetData); + sharedAssert.isHexString(`${variableName}.takerAssetData`, orderFetcherRequest.takerAssetData); + }, + isValidPercentage(variableName: string, percentage: number): void { + assert.isNumber(variableName, percentage); + assert.assert( + percentage >= 0 && percentage <= 1, + `Expected ${variableName} to be between 0 and 1, but is ${percentage}`, + ); + }, +}; diff --git a/packages/asset-buyer/src/utils/asset_data_utils.ts b/packages/asset-buyer/src/utils/asset_data_utils.ts new file mode 100644 index 000000000..70f646902 --- /dev/null +++ b/packages/asset-buyer/src/utils/asset_data_utils.ts @@ -0,0 +1,12 @@ +import { ContractWrappers } from '@0x/contract-wrappers'; +import { assetDataUtils as sharedAssetDataUtils } from '@0x/order-utils'; +import * as _ from 'lodash'; + +export const assetDataUtils = { + ...sharedAssetDataUtils, + getEtherTokenAssetData(contractWrappers: ContractWrappers): string { + const etherTokenAddress = contractWrappers.forwarder.etherTokenAddress; + const etherTokenAssetData = sharedAssetDataUtils.encodeERC20AssetData(etherTokenAddress); + return etherTokenAssetData; + }, +}; diff --git a/packages/asset-buyer/src/utils/buy_quote_calculator.ts b/packages/asset-buyer/src/utils/buy_quote_calculator.ts new file mode 100644 index 000000000..6a67ed1ed --- /dev/null +++ b/packages/asset-buyer/src/utils/buy_quote_calculator.ts @@ -0,0 +1,207 @@ +import { marketUtils, SignedOrder } from '@0x/order-utils'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { constants } from '../constants'; +import { AssetBuyerError, BuyQuote, BuyQuoteInfo, OrdersAndFillableAmounts } from '../types'; + +import { orderUtils } from './order_utils'; + +// Calculates a buy quote for orders that have WETH as the takerAsset +export const buyQuoteCalculator = { + calculate( + ordersAndFillableAmounts: OrdersAndFillableAmounts, + feeOrdersAndFillableAmounts: OrdersAndFillableAmounts, + assetBuyAmount: BigNumber, + feePercentage: number, + slippagePercentage: number, + isMakerAssetZrxToken: boolean, + ): BuyQuote { + const orders = ordersAndFillableAmounts.orders; + const remainingFillableMakerAssetAmounts = ordersAndFillableAmounts.remainingFillableMakerAssetAmounts; + const feeOrders = feeOrdersAndFillableAmounts.orders; + const remainingFillableFeeAmounts = feeOrdersAndFillableAmounts.remainingFillableMakerAssetAmounts; + const slippageBufferAmount = assetBuyAmount.mul(slippagePercentage).round(); + // find the orders that cover the desired assetBuyAmount (with slippage) + const { + resultOrders, + remainingFillAmount, + ordersRemainingFillableMakerAssetAmounts, + } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(orders, assetBuyAmount, { + remainingFillableMakerAssetAmounts, + slippageBufferAmount, + }); + // if we do not have enough orders to cover the desired assetBuyAmount, throw + if (remainingFillAmount.gt(constants.ZERO_AMOUNT)) { + throw new Error(AssetBuyerError.InsufficientAssetLiquidity); + } + // if we are not buying ZRX: + // given the orders calculated above, find the fee-orders that cover the desired assetBuyAmount (with slippage) + // TODO(bmillman): optimization + // update this logic to find the minimum amount of feeOrders to cover the worst case as opposed to + // finding order that cover all fees, this will help with estimating ETH and minimizing gas usage + let resultFeeOrders = [] as SignedOrder[]; + let feeOrdersRemainingFillableMakerAssetAmounts = [] as BigNumber[]; + if (!isMakerAssetZrxToken) { + const feeOrdersAndRemainingFeeAmount = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders( + resultOrders, + feeOrders, + { + remainingFillableMakerAssetAmounts: ordersRemainingFillableMakerAssetAmounts, + remainingFillableFeeAmounts, + }, + ); + // if we do not have enough feeOrders to cover the fees, throw + if (feeOrdersAndRemainingFeeAmount.remainingFeeAmount.gt(constants.ZERO_AMOUNT)) { + throw new Error(AssetBuyerError.InsufficientZrxLiquidity); + } + resultFeeOrders = feeOrdersAndRemainingFeeAmount.resultFeeOrders; + feeOrdersRemainingFillableMakerAssetAmounts = + feeOrdersAndRemainingFeeAmount.feeOrdersRemainingFillableMakerAssetAmounts; + } + + // assetData information for the result + const assetData = orders[0].makerAssetData; + // compile the resulting trimmed set of orders for makerAsset and feeOrders that are needed for assetBuyAmount + const trimmedOrdersAndFillableAmounts: OrdersAndFillableAmounts = { + orders: resultOrders, + remainingFillableMakerAssetAmounts: ordersRemainingFillableMakerAssetAmounts, + }; + const trimmedFeeOrdersAndFillableAmounts: OrdersAndFillableAmounts = { + orders: resultFeeOrders, + remainingFillableMakerAssetAmounts: feeOrdersRemainingFillableMakerAssetAmounts, + }; + const bestCaseQuoteInfo = calculateQuoteInfo( + trimmedOrdersAndFillableAmounts, + trimmedFeeOrdersAndFillableAmounts, + assetBuyAmount, + feePercentage, + isMakerAssetZrxToken, + ); + // in order to calculate the maxRate, reverse the ordersAndFillableAmounts such that they are sorted from worst rate to best rate + const worstCaseQuoteInfo = calculateQuoteInfo( + reverseOrdersAndFillableAmounts(trimmedOrdersAndFillableAmounts), + reverseOrdersAndFillableAmounts(trimmedFeeOrdersAndFillableAmounts), + assetBuyAmount, + feePercentage, + isMakerAssetZrxToken, + ); + return { + assetData, + orders: resultOrders, + feeOrders: resultFeeOrders, + bestCaseQuoteInfo, + worstCaseQuoteInfo, + assetBuyAmount, + feePercentage, + }; + }, +}; + +function calculateQuoteInfo( + ordersAndFillableAmounts: OrdersAndFillableAmounts, + feeOrdersAndFillableAmounts: OrdersAndFillableAmounts, + assetBuyAmount: BigNumber, + feePercentage: number, + isMakerAssetZrxToken: boolean, +): BuyQuoteInfo { + // find the total eth and zrx needed to buy assetAmount from the resultOrders from left to right + let ethAmountToBuyAsset = constants.ZERO_AMOUNT; + let ethAmountToBuyZrx = constants.ZERO_AMOUNT; + if (isMakerAssetZrxToken) { + ethAmountToBuyAsset = findEthAmountNeededToBuyZrx(ordersAndFillableAmounts, assetBuyAmount); + } else { + // find eth and zrx amounts needed to buy + const ethAndZrxAmountToBuyAsset = findEthAndZrxAmountNeededToBuyAsset(ordersAndFillableAmounts, assetBuyAmount); + ethAmountToBuyAsset = ethAndZrxAmountToBuyAsset[0]; + const zrxAmountToBuyAsset = ethAndZrxAmountToBuyAsset[1]; + // find eth amount needed to buy zrx + ethAmountToBuyZrx = findEthAmountNeededToBuyZrx(feeOrdersAndFillableAmounts, zrxAmountToBuyAsset); + } + /// find the eth amount needed to buy the affiliate fee + const ethAmountToBuyAffiliateFee = ethAmountToBuyAsset.mul(feePercentage).ceil(); + const totalEthAmountWithoutAffiliateFee = ethAmountToBuyAsset.plus(ethAmountToBuyZrx); + const ethAmountTotal = totalEthAmountWithoutAffiliateFee.plus(ethAmountToBuyAffiliateFee); + // divide into the assetBuyAmount in order to find rate of makerAsset / WETH + const ethPerAssetPrice = totalEthAmountWithoutAffiliateFee.div(assetBuyAmount); + return { + totalEthAmount: ethAmountTotal, + feeEthAmount: ethAmountToBuyAffiliateFee, + ethPerAssetPrice, + }; +} + +// given an OrdersAndFillableAmounts, reverse the orders and remainingFillableMakerAssetAmounts properties +function reverseOrdersAndFillableAmounts(ordersAndFillableAmounts: OrdersAndFillableAmounts): OrdersAndFillableAmounts { + const ordersCopy = _.clone(ordersAndFillableAmounts.orders); + const remainingFillableMakerAssetAmountsCopy = _.clone(ordersAndFillableAmounts.remainingFillableMakerAssetAmounts); + return { + orders: ordersCopy.reverse(), + remainingFillableMakerAssetAmounts: remainingFillableMakerAssetAmountsCopy.reverse(), + }; +} + +function findEthAmountNeededToBuyZrx( + feeOrdersAndFillableAmounts: OrdersAndFillableAmounts, + zrxBuyAmount: BigNumber, +): BigNumber { + const { orders, remainingFillableMakerAssetAmounts } = feeOrdersAndFillableAmounts; + const result = _.reduce( + orders, + (acc, order, index) => { + const { totalEthAmount, remainingZrxBuyAmount } = acc; + const remainingFillableMakerAssetAmount = remainingFillableMakerAssetAmounts[index]; + const makerFillAmount = BigNumber.min(remainingZrxBuyAmount, remainingFillableMakerAssetAmount); + const [takerFillAmount, adjustedMakerFillAmount] = orderUtils.getTakerFillAmountForFeeOrder( + order, + makerFillAmount, + ); + const extraFeeAmount = remainingFillableMakerAssetAmount.greaterThanOrEqualTo(adjustedMakerFillAmount) + ? constants.ZERO_AMOUNT + : adjustedMakerFillAmount.sub(makerFillAmount); + return { + totalEthAmount: totalEthAmount.plus(takerFillAmount), + remainingZrxBuyAmount: BigNumber.max( + constants.ZERO_AMOUNT, + remainingZrxBuyAmount.minus(makerFillAmount).plus(extraFeeAmount), + ), + }; + }, + { + totalEthAmount: constants.ZERO_AMOUNT, + remainingZrxBuyAmount: zrxBuyAmount, + }, + ); + return result.totalEthAmount; +} + +function findEthAndZrxAmountNeededToBuyAsset( + ordersAndFillableAmounts: OrdersAndFillableAmounts, + assetBuyAmount: BigNumber, +): [BigNumber, BigNumber] { + const { orders, remainingFillableMakerAssetAmounts } = ordersAndFillableAmounts; + const result = _.reduce( + orders, + (acc, order, index) => { + const { totalEthAmount, totalZrxAmount, remainingAssetBuyAmount } = acc; + const remainingFillableMakerAssetAmount = remainingFillableMakerAssetAmounts[index]; + const makerFillAmount = BigNumber.min(acc.remainingAssetBuyAmount, remainingFillableMakerAssetAmount); + const takerFillAmount = orderUtils.getTakerFillAmount(order, makerFillAmount); + const takerFeeAmount = orderUtils.getTakerFeeAmount(order, takerFillAmount); + return { + totalEthAmount: totalEthAmount.plus(takerFillAmount), + totalZrxAmount: totalZrxAmount.plus(takerFeeAmount), + remainingAssetBuyAmount: BigNumber.max( + constants.ZERO_AMOUNT, + remainingAssetBuyAmount.minus(makerFillAmount), + ), + }; + }, + { + totalEthAmount: constants.ZERO_AMOUNT, + totalZrxAmount: constants.ZERO_AMOUNT, + remainingAssetBuyAmount: assetBuyAmount, + }, + ); + return [result.totalEthAmount, result.totalZrxAmount]; +} diff --git a/packages/asset-buyer/src/utils/order_provider_response_processor.ts b/packages/asset-buyer/src/utils/order_provider_response_processor.ts new file mode 100644 index 000000000..28f684f3c --- /dev/null +++ b/packages/asset-buyer/src/utils/order_provider_response_processor.ts @@ -0,0 +1,169 @@ +import { OrderAndTraderInfo, OrderStatus, OrderValidatorWrapper } from '@0x/contract-wrappers'; +import { sortingUtils } from '@0x/order-utils'; +import { RemainingFillableCalculator } from '@0x/order-utils/lib/src/remaining_fillable_calculator'; +import { SignedOrder } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; + +import { constants } from '../constants'; +import { + AssetBuyerError, + OrderProviderRequest, + OrderProviderResponse, + OrdersAndFillableAmounts, + SignedOrderWithRemainingFillableMakerAssetAmount, +} from '../types'; + +import { orderUtils } from './order_utils'; + +export const orderProviderResponseProcessor = { + throwIfInvalidResponse(response: OrderProviderResponse, request: OrderProviderRequest): void { + const { makerAssetData, takerAssetData } = request; + _.forEach(response.orders, order => { + if (order.makerAssetData !== makerAssetData || order.takerAssetData !== takerAssetData) { + throw new Error(AssetBuyerError.InvalidOrderProviderResponse); + } + }); + }, + /** + * Take the responses for the target orders to buy and fee orders and process them. + * Processing includes: + * - Drop orders that are expired or not open orders (null taker address) + * - If shouldValidateOnChain, attempt to grab fillable amounts from on-chain otherwise assume completely fillable + * - Sort by rate + */ + async processAsync( + orderProviderResponse: OrderProviderResponse, + isMakerAssetZrxToken: boolean, + expiryBufferSeconds: number, + orderValidator?: OrderValidatorWrapper, + ): Promise<OrdersAndFillableAmounts> { + // drop orders that are expired or not open + const filteredOrders = filterOutExpiredAndNonOpenOrders(orderProviderResponse.orders, expiryBufferSeconds); + // set the orders to be sorted equal to the filtered orders + let unsortedOrders = filteredOrders; + // if an orderValidator is provided, use on chain information to calculate remaining fillable makerAsset amounts + if (!_.isUndefined(orderValidator)) { + // TODO(bmillman): improvement + // try/catch this request and throw a more domain specific error + const takerAddresses = _.map(filteredOrders, () => constants.NULL_ADDRESS); + const ordersAndTradersInfo = await orderValidator.getOrdersAndTradersInfoAsync( + filteredOrders, + takerAddresses, + ); + // take orders + on chain information and find the valid orders and remaining fillable maker asset amounts + unsortedOrders = getValidOrdersWithRemainingFillableMakerAssetAmountsFromOnChain( + filteredOrders, + ordersAndTradersInfo, + isMakerAssetZrxToken, + ); + } + // sort orders by rate + // TODO(bmillman): optimization + // provide a feeRate to the sorting function to more accurately sort based on the current market for ZRX tokens + const sortedOrders = isMakerAssetZrxToken + ? sortingUtils.sortFeeOrdersByFeeAdjustedRate(unsortedOrders) + : sortingUtils.sortOrdersByFeeAdjustedRate(unsortedOrders); + // unbundle orders and fillable amounts and compile final result + const result = unbundleOrdersWithAmounts(sortedOrders); + return result; + }, +}; + +/** + * Given an array of orders, return a new array with expired and non open orders filtered out. + */ +function filterOutExpiredAndNonOpenOrders( + orders: SignedOrderWithRemainingFillableMakerAssetAmount[], + expiryBufferSeconds: number, +): SignedOrderWithRemainingFillableMakerAssetAmount[] { + const result = _.filter(orders, order => { + return orderUtils.isOpenOrder(order) && !orderUtils.willOrderExpire(order, expiryBufferSeconds); + }); + return result; +} + +/** + * Given an array of orders and corresponding on-chain infos, return a subset of the orders + * that are still fillable orders with their corresponding remainingFillableMakerAssetAmounts. + */ +function getValidOrdersWithRemainingFillableMakerAssetAmountsFromOnChain( + inputOrders: SignedOrder[], + ordersAndTradersInfo: OrderAndTraderInfo[], + isMakerAssetZrxToken: boolean, +): SignedOrderWithRemainingFillableMakerAssetAmount[] { + // iterate through the input orders and find the ones that are still fillable + // for the orders that are still fillable, calculate the remaining fillable maker asset amount + const result = _.reduce( + inputOrders, + (accOrders, order, index) => { + // get corresponding on-chain state for the order + const { orderInfo, traderInfo } = ordersAndTradersInfo[index]; + // if the order IS NOT fillable, do not add anything to the accumulations and continue iterating + if (orderInfo.orderStatus !== OrderStatus.FILLABLE) { + return accOrders; + } + // if the order IS fillable, add the order and calculate the remaining fillable amount + const transferrableAssetAmount = BigNumber.min([traderInfo.makerAllowance, traderInfo.makerBalance]); + const transferrableFeeAssetAmount = BigNumber.min([ + traderInfo.makerZrxAllowance, + traderInfo.makerZrxBalance, + ]); + const remainingTakerAssetAmount = order.takerAssetAmount.minus(orderInfo.orderTakerAssetFilledAmount); + const remainingMakerAssetAmount = orderUtils.getRemainingMakerAmount(order, remainingTakerAssetAmount); + const remainingFillableCalculator = new RemainingFillableCalculator( + order.makerFee, + order.makerAssetAmount, + isMakerAssetZrxToken, + transferrableAssetAmount, + transferrableFeeAssetAmount, + remainingMakerAssetAmount, + ); + const remainingFillableAmount = remainingFillableCalculator.computeRemainingFillable(); + // if the order does not have any remaining fillable makerAsset, do not add anything to the accumulations and continue iterating + if (remainingFillableAmount.lte(constants.ZERO_AMOUNT)) { + return accOrders; + } + const orderWithRemainingFillableMakerAssetAmount = { + ...order, + remainingFillableMakerAssetAmount: remainingFillableAmount, + }; + const newAccOrders = _.concat(accOrders, orderWithRemainingFillableMakerAssetAmount); + return newAccOrders; + }, + [] as SignedOrderWithRemainingFillableMakerAssetAmount[], + ); + return result; +} + +/** + * Given an array of orders with remaining fillable maker asset amounts. Unbundle into an instance of OrdersAndRemainingFillableMakerAssetAmounts. + * If an order is missing a corresponding remainingFillableMakerAssetAmount, assume it is completely fillable. + */ +function unbundleOrdersWithAmounts( + ordersWithAmounts: SignedOrderWithRemainingFillableMakerAssetAmount[], +): OrdersAndFillableAmounts { + const result = _.reduce( + ordersWithAmounts, + (acc, orderWithAmount) => { + const { orders, remainingFillableMakerAssetAmounts } = acc; + const { remainingFillableMakerAssetAmount, ...order } = orderWithAmount; + // if we are still missing a remainingFillableMakerAssetAmount, assume the order is completely fillable + const newRemainingAmount = remainingFillableMakerAssetAmount || order.makerAssetAmount; + // if remaining amount is less than or equal to zero, do not add it + if (newRemainingAmount.lte(constants.ZERO_AMOUNT)) { + return acc; + } + const newAcc = { + orders: _.concat(orders, order), + remainingFillableMakerAssetAmounts: _.concat(remainingFillableMakerAssetAmounts, newRemainingAmount), + }; + return newAcc; + }, + { + orders: [] as SignedOrder[], + remainingFillableMakerAssetAmounts: [] as BigNumber[], + }, + ); + return result; +} diff --git a/packages/asset-buyer/src/utils/order_utils.ts b/packages/asset-buyer/src/utils/order_utils.ts new file mode 100644 index 000000000..1cc2cf95f --- /dev/null +++ b/packages/asset-buyer/src/utils/order_utils.ts @@ -0,0 +1,74 @@ +import { SignedOrder } from '@0x/types'; +import { BigNumber } from '@0x/utils'; + +import { constants } from '../constants'; + +export const orderUtils = { + isOrderExpired(order: SignedOrder): boolean { + return orderUtils.willOrderExpire(order, 0); + }, + willOrderExpire(order: SignedOrder, secondsFromNow: number): boolean { + const millisecondsInSecond = 1000; + const currentUnixTimestampSec = new BigNumber(Date.now() / millisecondsInSecond).round(); + return order.expirationTimeSeconds.lessThan(currentUnixTimestampSec.plus(secondsFromNow)); + }, + isOpenOrder(order: SignedOrder): boolean { + return order.takerAddress === constants.NULL_ADDRESS; + }, + // given a remaining amount of takerAsset, calculate how much makerAsset is available + getRemainingMakerAmount(order: SignedOrder, remainingTakerAmount: BigNumber): BigNumber { + const remainingMakerAmount = remainingTakerAmount + .times(order.makerAssetAmount) + .div(order.takerAssetAmount) + .floor(); + return remainingMakerAmount; + }, + // given a desired amount of makerAsset, calculate how much takerAsset is required to fill that amount + getTakerFillAmount(order: SignedOrder, makerFillAmount: BigNumber): BigNumber { + // Round up because exchange rate favors Maker + const takerFillAmount = makerFillAmount + .mul(order.takerAssetAmount) + .div(order.makerAssetAmount) + .ceil(); + return takerFillAmount; + }, + // given a desired amount of takerAsset to fill, calculate how much fee is required by the taker to fill that amount + getTakerFeeAmount(order: SignedOrder, takerFillAmount: BigNumber): BigNumber { + // Round down because Taker fee rate favors Taker + const takerFeeAmount = takerFillAmount + .mul(order.takerFee) + .div(order.takerAssetAmount) + .floor(); + return takerFeeAmount; + }, + // given a desired amount of takerAsset to fill, calculate how much makerAsset will be filled + getMakerFillAmount(order: SignedOrder, takerFillAmount: BigNumber): BigNumber { + // Round down because exchange rate favors Maker + const makerFillAmount = takerFillAmount + .mul(order.makerAssetAmount) + .div(order.takerAssetAmount) + .floor(); + return makerFillAmount; + }, + // given a desired amount of makerAsset, calculate how much fee is required by the maker to fill that amount + getMakerFeeAmount(order: SignedOrder, makerFillAmount: BigNumber): BigNumber { + // Round down because Maker fee rate favors Maker + const makerFeeAmount = makerFillAmount + .mul(order.makerFee) + .div(order.makerAssetAmount) + .floor(); + return makerFeeAmount; + }, + // given a desired amount of ZRX from a fee order, calculate how much takerAsset is required to fill that amount + // also calculate how much ZRX needs to be bought in order fill the desired amount + takerFee + getTakerFillAmountForFeeOrder(order: SignedOrder, makerFillAmount: BigNumber): [BigNumber, BigNumber] { + // For each unit of TakerAsset we buy (MakerAsset - TakerFee) + const adjustedTakerFillAmount = makerFillAmount + .mul(order.takerAssetAmount) + .div(order.makerAssetAmount.sub(order.takerFee)) + .ceil(); + // The amount that we buy will be greater than makerFillAmount, since we buy some amount for fees. + const adjustedMakerFillAmount = orderUtils.getMakerFillAmount(order, adjustedTakerFillAmount); + return [adjustedTakerFillAmount, adjustedMakerFillAmount]; + }, +}; |