diff options
author | Leonid Logvinov <logvinov.leon@gmail.com> | 2018-09-26 20:55:52 +0800 |
---|---|---|
committer | Leonid Logvinov <logvinov.leon@gmail.com> | 2018-09-26 20:55:52 +0800 |
commit | 5d73eebf6abe52763ea6984f85102157abea5b6c (patch) | |
tree | 8cec1c58fe22ba62d979dbc1c6ff366e81985b8d /packages/asset-buyer | |
parent | f3deabccf4e6caec57351a09f82b3f786122b5ea (diff) | |
parent | 13aa98f0f3431e4ea4db07794a06304c237e8d45 (diff) | |
download | dexon-0x-contracts-5d73eebf6abe52763ea6984f85102157abea5b6c.tar.gz dexon-0x-contracts-5d73eebf6abe52763ea6984f85102157abea5b6c.tar.zst dexon-0x-contracts-5d73eebf6abe52763ea6984f85102157abea5b6c.zip |
Merge branch 'development' into feature/ts-ethers
Diffstat (limited to 'packages/asset-buyer')
22 files changed, 1337 insertions, 0 deletions
diff --git a/packages/asset-buyer/.npmignore b/packages/asset-buyer/.npmignore new file mode 100644 index 000000000..5333847e7 --- /dev/null +++ b/packages/asset-buyer/.npmignore @@ -0,0 +1,8 @@ +.* +yarn-error.log +/src/ +/scripts/ +/schemas/ +test/ +tsconfig.json +/lib/src/monorepo_scripts/ diff --git a/packages/asset-buyer/CHANGELOG.json b/packages/asset-buyer/CHANGELOG.json new file mode 100644 index 000000000..d673f0a45 --- /dev/null +++ b/packages/asset-buyer/CHANGELOG.json @@ -0,0 +1,28 @@ +[ + { + "timestamp": 1537907159, + "version": "1.0.1", + "changes": [ + { + "note": "Dependencies updated" + } + ] + }, + { + "timestamp": 1537875740, + "version": "1.0.0", + "changes": [ + { + "note": "Dependencies updated" + } + ] + }, + { + "version": "1.0.0-rc.1", + "changes": [ + { + "note": "Init" + } + ] + } +] diff --git a/packages/asset-buyer/CHANGELOG.md b/packages/asset-buyer/CHANGELOG.md new file mode 100644 index 000000000..bea4a551b --- /dev/null +++ b/packages/asset-buyer/CHANGELOG.md @@ -0,0 +1,18 @@ +<!-- +changelogUtils.file is auto-generated using the monorepo-scripts package. Don't edit directly. +Edit the package's CHANGELOG.json file only. +--> + +CHANGELOG + +## v1.0.1 - _September 25, 2018_ + + * Dependencies updated + +## v1.0.0 - _September 25, 2018_ + + * Dependencies updated + +## v1.0.0-rc.1 - _Invalid date_ + + * Init diff --git a/packages/asset-buyer/README.md b/packages/asset-buyer/README.md new file mode 100644 index 000000000..5f7f26f30 --- /dev/null +++ b/packages/asset-buyer/README.md @@ -0,0 +1,83 @@ +## @0xproject/asset-buyer + +Convenience package for buying assets represented on the Ethereum blockchain using 0x. In its simplest form, the package helps in the usage of the [0x forwarder contract](https://github.com/0xProject/0x-protocol-specification/blob/master/v2/forwarder-specification.md), which allows users to execute [Wrapped Ether](https://weth.io/) based 0x orders without having to set allowances, wrap Ether or buy ZRX, meaning they can buy tokens with Ether alone. Given some liquidity (0x signed orders), it helps estimate the Ether cost of buying a certain asset (giving a range) and then buying that asset. + +In its more advanced and useful form, it integrates with the [Standard Relayer API](https://github.com/0xProject/standard-relayer-api) and takes care of sourcing liquidity for you given an SRA compliant endpoint. The final result is a library that tells you what assets are available, provides an Ether based quote for any asset desired, and allows you to buy that asset using Ether alone. + +## Installation + +```bash +yarn add @0xproject/asset-buyer +``` + +**Import** + +```typescript +import { AssetBuyer } from '@0xproject/asset-buyer'; +``` + +or + +```javascript +var AssetBuyer = require('@0xproject/asset-buyer').AssetBuyer; +``` + +If your project is in [TypeScript](https://www.typescriptlang.org/), add the following to your `tsconfig.json`: + +```json +"compilerOptions": { + "typeRoots": ["node_modules/@0xproject/typescript-typings/types", "node_modules/@types"], +} +``` + +## Contributing + +We welcome improvements and fixes from the wider community! To report bugs within this package, please create an issue in this repository. + +Please read our [contribution guidelines](../../CONTRIBUTING.md) before getting started. + +### Install dependencies + +If you don't have yarn workspaces enabled (Yarn < v1.0) - enable them: + +```bash +yarn config set workspaces-experimental true +``` + +Then install dependencies + +```bash +yarn install +``` + +### Build + +To build this package and all other monorepo packages that it depends on, run the following from the monorepo root directory: + +```bash +PKG=@0xproject/asset-buyer yarn build +``` + +Or continuously rebuild on change: + +```bash +PKG=@0xproject/asset-buyer yarn watch +``` + +### Clean + +```bash +yarn clean +``` + +### Lint + +```bash +yarn lint +``` + +### Run Tests + +```bash +yarn test +``` diff --git a/packages/asset-buyer/package.json b/packages/asset-buyer/package.json new file mode 100644 index 000000000..ff0afb782 --- /dev/null +++ b/packages/asset-buyer/package.json @@ -0,0 +1,74 @@ +{ + "name": "@0xproject/asset-buyer", + "version": "1.0.1", + "engines": { + "node": ">=6.12" + }, + "description": "Convenience package for buying assets", + "main": "lib/src/index.js", + "types": "lib/src/index.d.ts", + "scripts": { + "watch_without_deps": "tsc -w", + "lint": "tslint --project .", + "test": "yarn run_mocha", + "rebuild_and_test": "run-s clean build test", + "test:coverage": "nyc npm run test --all && yarn coverage:report:lcov", + "coverage:report:lcov": "nyc report --reporter=text-lcov > coverage/lcov.info", + "test:circleci": "yarn test:coverage", + "run_mocha": "mocha --require source-map-support/register --require make-promises-safe lib/test/**/*_test.js --exit", + "clean": "shx rm -rf lib test_temp scripts", + "build": "tsc && copyfiles -u 3 './lib/src/monorepo_scripts/**/*' ./scripts", + "manual:postpublish": "yarn build; node ./scripts/postpublish.js" + }, + "config": { + "postpublish": { + "assets": [] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/0xProject/0x-monorepo.git" + }, + "author": "", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/0xProject/0x-monorepo/issues" + }, + "homepage": "https://github.com/0xProject/0x-monorepo/packages/asset-buyer/README.md", + "dependencies": { + "@0xproject/assert": "^1.0.11", + "@0xproject/connect": "^2.0.4", + "@0xproject/contract-wrappers": "^2.0.0", + "@0xproject/json-schemas": "^1.0.4", + "@0xproject/order-utils": "^1.0.5", + "@0xproject/subproviders": "^2.0.5", + "@0xproject/types": "^1.1.1", + "@0xproject/typescript-typings": "^2.0.2", + "@0xproject/utils": "^1.0.11", + "@0xproject/web3-wrapper": "^3.0.1", + "ethereum-types": "^1.0.8", + "lodash": "^4.17.10" + }, + "devDependencies": { + "@0xproject/tslint-config": "^1.0.7", + "@types/lodash": "^4.14.116", + "@types/mocha": "^2.2.42", + "@types/node": "*", + "chai": "^4.0.1", + "chai-as-promised": "^7.1.0", + "chai-bignumber": "^2.0.1", + "copyfiles": "^1.2.0", + "dirty-chai": "^2.0.1", + "make-promises-safe": "^1.1.0", + "mocha": "^4.1.0", + "npm-run-all": "^4.1.2", + "nyc": "^11.0.1", + "shx": "^0.2.2", + "tslint": "5.11.0", + "typedoc": "0.12.0", + "typescript": "3.0.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/asset-buyer/src/asset_buyer.ts b/packages/asset-buyer/src/asset_buyer.ts new file mode 100644 index 000000000..409e34e74 --- /dev/null +++ b/packages/asset-buyer/src/asset_buyer.ts @@ -0,0 +1,324 @@ +import { ContractWrappers } from '@0xproject/contract-wrappers'; +import { schemas } from '@0xproject/json-schemas'; +import { SignedOrder } from '@0xproject/order-utils'; +import { BigNumber } from '@0xproject/utils'; +import { Web3Wrapper } from '@0xproject/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, + AssetBuyerOrdersAndFillableAmounts, + BuyQuote, + BuyQuoteRequestOpts, + OrderProvider, + OrderProviderResponse, +} 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'; + +export class AssetBuyer { + public readonly provider: Provider; + public readonly assetData: string; + public readonly orderProvider: OrderProvider; + public readonly networkId: number; + public readonly orderRefreshIntervalMs: number; + public readonly expiryBufferSeconds: number; + private readonly _contractWrappers: ContractWrappers; + private _lastRefreshTimeIfExists?: number; + private _currentOrdersAndFillableAmountsIfExists?: AssetBuyerOrdersAndFillableAmounts; + /** + * 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 networkId The ethereum network id. Defaults to 1 (mainnet). + * @param orderRefreshIntervalMs The interval in ms that getBuyQuoteAsync should trigger an refresh of orders and order states. Defaults to 10000ms (10s). + * @param expiryBufferSeconds The number of seconds to add when calculating whether an order is expired or not. Defaults to 15s. + * + * @return An instance of AssetBuyer + */ + public static getAssetBuyerForProvidedOrders( + provider: Provider, + orders: SignedOrder[], + feeOrders: SignedOrder[] = [], + networkId: number = constants.MAINNET_NETWORK_ID, + orderRefreshIntervalMs: number = constants.DEFAULT_ORDER_REFRESH_INTERVAL_MS, + expiryBufferSeconds: number = constants.DEFAULT_EXPIRY_BUFFER_SECONDS, + ): AssetBuyer { + assert.isWeb3Provider('provider', provider); + assert.doesConformToSchema('orders', orders, schemas.signedOrdersSchema); + assert.doesConformToSchema('feeOrders', feeOrders, schemas.signedOrdersSchema); + assert.isNumber('networkId', networkId); + assert.isNumber('orderRefreshIntervalMs', orderRefreshIntervalMs); + assert.areValidProvidedOrders('orders', orders); + assert.areValidProvidedOrders('feeOrders', feeOrders); + assert.assert(orders.length !== 0, `Expected orders to contain at least one order`); + const assetData = orders[0].makerAssetData; + const orderProvider = new BasicOrderProvider(_.concat(orders, feeOrders)); + const assetBuyer = new AssetBuyer( + provider, + assetData, + orderProvider, + networkId, + orderRefreshIntervalMs, + expiryBufferSeconds, + ); + return assetBuyer; + } + /** + * Instantiates a new AssetBuyer instance given the desired assetData and 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 assetData The assetData that identifies the desired asset to buy. + * @param sraApiUrl The standard relayer API base HTTP url you would like to source orders from. + * @param networkId The ethereum network id. Defaults to 1 (mainnet). + * @param orderRefreshIntervalMs The interval in ms that getBuyQuoteAsync should trigger an refresh of orders and order states. Defaults to 10000ms (10s). + * @param expiryBufferSeconds The number of seconds to add when calculating whether an order is expired or not. Defaults to 15s. + * + * @return An instance of AssetBuyer + */ + public static getAssetBuyerForAssetData( + provider: Provider, + assetData: string, + sraApiUrl: string, + networkId: number = constants.MAINNET_NETWORK_ID, + orderRefreshIntervalMs: number = constants.DEFAULT_ORDER_REFRESH_INTERVAL_MS, + expiryBufferSeconds: number = constants.DEFAULT_EXPIRY_BUFFER_SECONDS, + ): AssetBuyer { + assert.isWeb3Provider('provider', provider); + assert.isHexString('assetData', assetData); + assert.isWebUri('sraApiUrl', sraApiUrl); + assert.isNumber('networkId', networkId); + assert.isNumber('orderRefreshIntervalMs', orderRefreshIntervalMs); + const orderProvider = new StandardRelayerAPIOrderProvider(sraApiUrl); + const assetBuyer = new AssetBuyer( + provider, + assetData, + orderProvider, + networkId, + orderRefreshIntervalMs, + expiryBufferSeconds, + ); + return assetBuyer; + } + /** + * Instantiates a new AssetBuyer instance given the desired ERC20 token address and 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 tokenAddress The ERC20 token address that identifies the desired asset to buy. + * @param sraApiUrl The standard relayer API base HTTP url you would like to source orders from. + * @param networkId The ethereum network id. Defaults to 1 (mainnet). + * @param orderRefreshIntervalMs The interval in ms that getBuyQuoteAsync should trigger an refresh of orders and order states. Defaults to 10000ms (10s). + * @param expiryBufferSeconds The number of seconds to add when calculating whether an order is expired or not. Defaults to 15s. + * @return An instance of AssetBuyer + */ + public static getAssetBuyerForERC20TokenAddress( + provider: Provider, + tokenAddress: string, + sraApiUrl: string, + networkId: number = constants.MAINNET_NETWORK_ID, + orderRefreshIntervalMs: number = constants.DEFAULT_ORDER_REFRESH_INTERVAL_MS, + expiryBufferSeconds: number = constants.DEFAULT_EXPIRY_BUFFER_SECONDS, + ): AssetBuyer { + assert.isWeb3Provider('provider', provider); + assert.isETHAddressHex('tokenAddress', tokenAddress); + assert.isWebUri('sraApiUrl', sraApiUrl); + assert.isNumber('networkId', networkId); + assert.isNumber('orderRefreshIntervalMs', orderRefreshIntervalMs); + const assetData = assetDataUtils.encodeERC20AssetData(tokenAddress); + const assetBuyer = AssetBuyer.getAssetBuyerForAssetData( + provider, + assetData, + sraApiUrl, + networkId, + orderRefreshIntervalMs, + expiryBufferSeconds, + ); + return assetBuyer; + } + /** + * Instantiates a new AssetBuyer instance + * @param provider The Provider instance you would like to use for interacting with the Ethereum network. + * @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 orderProvider An object that conforms to OrderProvider, see type for definition. + * @param networkId The ethereum network id. Defaults to 1 (mainnet). + * @param orderRefreshIntervalMs The interval in ms that getBuyQuoteAsync should trigger an refresh of orders and order states. Defaults to 10000ms (10s). + * @param expiryBufferSeconds The number of seconds to add when calculating whether an order is expired or not. Defaults to 15s. + * + * @return An instance of AssetBuyer + */ + constructor( + provider: Provider, + assetData: string, + orderProvider: OrderProvider, + networkId: number = constants.MAINNET_NETWORK_ID, + orderRefreshIntervalMs: number = constants.DEFAULT_ORDER_REFRESH_INTERVAL_MS, + expiryBufferSeconds: number = constants.DEFAULT_EXPIRY_BUFFER_SECONDS, + ) { + assert.isWeb3Provider('provider', provider); + assert.isString('assetData', assetData); + assert.isValidOrderProvider('orderProvider', orderProvider); + assert.isNumber('networkId', networkId); + assert.isNumber('orderRefreshIntervalMs', orderRefreshIntervalMs); + this.provider = provider; + this.assetData = assetData; + this.orderProvider = orderProvider; + this.networkId = networkId; + this.expiryBufferSeconds = expiryBufferSeconds; + this.orderRefreshIntervalMs = orderRefreshIntervalMs; + this._contractWrappers = new ContractWrappers(this.provider, { + networkId, + }); + } + /** + * Get a `BuyQuote` containing all information relevant to fulfilling a buy. + * You can then pass the `BuyQuote` to `executeBuyQuoteAsync` to execute the buy. + * @param assetBuyAmount The amount of asset to buy. + * @param feePercentage The affiliate fee percentage. Defaults to 0. + * @param forceOrderRefresh If set to true, new orders and state will be fetched instead of waiting for + * the next orderRefreshIntervalMs. Defaults to false. + * @return An object that conforms to BuyQuote that satisfies the request. See type definition for more information. + */ + public async getBuyQuoteAsync(assetBuyAmount: BigNumber, options: Partial<BuyQuoteRequestOpts>): Promise<BuyQuote> { + const { feePercentage, shouldForceOrderRefresh, slippagePercentage } = { + ...options, + ...constants.DEFAULT_BUY_QUOTE_REQUEST_OPTS, + }; + assert.isBigNumber('assetBuyAmount', assetBuyAmount); + assert.isValidPercentage('feePercentage', feePercentage); + assert.isBoolean('shouldForceOrderRefresh', shouldForceOrderRefresh); + // 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(this._currentOrdersAndFillableAmountsIfExists) || + shouldForceOrderRefresh || + (!_.isUndefined(this._lastRefreshTimeIfExists) && + this._lastRefreshTimeIfExists + this.orderRefreshIntervalMs < Date.now()); + let ordersAndFillableAmounts: AssetBuyerOrdersAndFillableAmounts; + if (shouldRefresh) { + ordersAndFillableAmounts = await this._getLatestOrdersAndFillableAmountsAsync(); + this._lastRefreshTimeIfExists = Date.now(); + this._currentOrdersAndFillableAmountsIfExists = ordersAndFillableAmounts; + } else { + // it is safe to cast to AssetBuyerOrdersAndFillableAmounts because shouldRefresh catches the undefined case above + ordersAndFillableAmounts = this + ._currentOrdersAndFillableAmountsIfExists as AssetBuyerOrdersAndFillableAmounts; + } + const buyQuote = buyQuoteCalculator.calculate( + ordersAndFillableAmounts, + assetBuyAmount, + feePercentage, + slippagePercentage, + ); + 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 rate The desired rate to execute the buy at. Affects the amount of ETH sent with the transaction, defaults to buyQuote.maxRate. + * @param takerAddress The address to perform the buy. Defaults to the first available address from the provider. + * @param feeRecipient The address where affiliate fees are sent. Defaults to null address (0x000...000). + * @return A promise of the txHash. + */ + public async executeBuyQuoteAsync( + buyQuote: BuyQuote, + rate?: BigNumber, + takerAddress?: string, + feeRecipient: string = constants.NULL_ADDRESS, + ): Promise<string> { + assert.isValidBuyQuote('buyQuote', buyQuote); + if (!_.isUndefined(rate)) { + assert.isBigNumber('rate', rate); + } + if (!_.isUndefined(takerAddress)) { + assert.isETHAddressHex('takerAddress', takerAddress); + } + assert.isETHAddressHex('feeRecipient', feeRecipient); + const { orders, feeOrders, feePercentage, assetBuyAmount, maxRate } = 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); + } + } + // if no rate is provided, default to the maxRate from buyQuote + const desiredRate = rate || maxRate; + // calculate how much eth is required to buy assetBuyAmount at the desired rate + const ethAmount = assetBuyAmount.dividedToIntegerBy(desiredRate); + const txHash = await this._contractWrappers.forwarder.marketBuyOrdersWithEthAsync( + orders, + assetBuyAmount, + finalTakerAddress, + ethAmount, + feeOrders, + feePercentage, + feeRecipient, + ); + return txHash; + } + /** + * Ask the order Provider for orders and process them. + */ + private async _getLatestOrdersAndFillableAmountsAsync(): Promise<AssetBuyerOrdersAndFillableAmounts> { + const etherTokenAssetData = this._getEtherTokenAssetDataOrThrow(); + const zrxTokenAssetData = this._getZrxTokenAssetDataOrThrow(); + // construct order Provider requests + const targetOrderProviderRequest = { + makerAssetData: this.assetData, + takerAssetData: etherTokenAssetData, + networkId: this.networkId, + }; + const feeOrderProviderRequest = { + makerAssetData: zrxTokenAssetData, + takerAssetData: etherTokenAssetData, + networkId: this.networkId, + }; + const requests = [targetOrderProviderRequest, feeOrderProviderRequest]; + // fetch orders and possible fillable amounts + const [targetOrderProviderResponse, feeOrderProviderResponse] = await Promise.all( + _.map(requests, async request => 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(targetOrderProviderResponse, targetOrderProviderRequest); + orderProviderResponseProcessor.throwIfInvalidResponse(feeOrderProviderResponse, feeOrderProviderRequest); + // process the responses into one object + const ordersAndFillableAmounts = await orderProviderResponseProcessor.processAsync( + targetOrderProviderResponse, + feeOrderProviderResponse, + zrxTokenAssetData, + this.expiryBufferSeconds, + this._contractWrappers.orderValidator, + ); + 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.getEtherTokenAssetDataOrThrow(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 assetDataUtils.getZrxTokenAssetDataOrThrow(this._contractWrappers); + } +} diff --git a/packages/asset-buyer/src/constants.ts b/packages/asset-buyer/src/constants.ts new file mode 100644 index 000000000..79b5d9052 --- /dev/null +++ b/packages/asset-buyer/src/constants.ts @@ -0,0 +1,20 @@ +import { BigNumber } from '@0xproject/utils'; + +import { BuyQuoteRequestOpts } from './types'; + +const DEFAULT_BUY_QUOTE_REQUEST_OPTS: BuyQuoteRequestOpts = { + feePercentage: 0, + shouldForceOrderRefresh: false, + slippagePercentage: 0.2, // 20% slippage protection +}; + +export const constants = { + ZERO_AMOUNT: new BigNumber(0), + NULL_ADDRESS: '0x0000000000000000000000000000000000000000', + MAINNET_NETWORK_ID: 1, + DEFAULT_ORDER_REFRESH_INTERVAL_MS: 10000, // 10 seconds + ETHER_TOKEN_DECIMALS: 18, + DEFAULT_BUY_QUOTE_REQUEST_OPTS, + MAX_PER_PAGE: 10000, + DEFAULT_EXPIRY_BUFFER_SECONDS: 15, +}; 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..8ef529ac0 --- /dev/null +++ b/packages/asset-buyer/src/index.ts @@ -0,0 +1,17 @@ +export { Provider } from 'ethereum-types'; +export { SignedOrder } from '@0xproject/types'; +export { BigNumber } from '@0xproject/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 { StandardRelayerAPIAssetBuyerManager } from './standard_relayer_api_asset_buyer_manager'; +export { + AssetBuyerError, + BuyQuote, + OrderProvider, + OrderProviderRequest, + OrderProviderResponse, + SignedOrderWithRemainingFillableMakerAssetAmount, + StandardRelayerApiAssetBuyerManagerError, +} 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..9bb2d90ac --- /dev/null +++ b/packages/asset-buyer/src/order_providers/basic_order_provider.ts @@ -0,0 +1,32 @@ +import { schemas } from '@0xproject/json-schemas'; +import { SignedOrder } from '@0xproject/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 }; + } +} 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..31942c25b --- /dev/null +++ b/packages/asset-buyer/src/order_providers/standard_relayer_api_order_provider.ts @@ -0,0 +1,79 @@ +import { HttpClient } from '@0xproject/connect'; +import { APIOrder, OrderbookResponse } from '@0xproject/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; + 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.calculateRemainingMakerAssetAmount( + 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. + * @return An instance of StandardRelayerAPIOrderProvider + */ + constructor(apiUrl: string) { + assert.isWebUri('apiUrl', apiUrl); + this.apiUrl = apiUrl; + 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, networkId } = orderProviderRequest; + const orderbookRequest = { baseAssetData: makerAssetData, quoteAssetData: takerAssetData }; + const requestOpts = { 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, + }; + } +} diff --git a/packages/asset-buyer/src/standard_relayer_api_asset_buyer_manager.ts b/packages/asset-buyer/src/standard_relayer_api_asset_buyer_manager.ts new file mode 100644 index 000000000..947c738a1 --- /dev/null +++ b/packages/asset-buyer/src/standard_relayer_api_asset_buyer_manager.ts @@ -0,0 +1,133 @@ +import { HttpClient } from '@0xproject/connect'; +import { ContractWrappers } from '@0xproject/contract-wrappers'; +import { ObjectMap } from '@0xproject/types'; +import { Provider } from 'ethereum-types'; +import * as _ from 'lodash'; + +import { AssetBuyer } from './asset_buyer'; +import { constants } from './constants'; +import { assert } from './utils/assert'; +import { assetDataUtils } from './utils/asset_data_utils'; + +import { OrderProvider, StandardRelayerApiAssetBuyerManagerError } from './types'; + +export class StandardRelayerAPIAssetBuyerManager { + // Map of assetData to AssetBuyer for that assetData + private readonly _assetBuyerMap: ObjectMap<AssetBuyer>; + /** + * Returns an array of all assetDatas available at the provided sraApiUrl + * @param sraApiUrl The standard relayer API base HTTP url you would like to source orders from. + * @param pairedWithAssetData Optional filter argument to return assetDatas that only pair with this assetData value. + * + * @return An array of all assetDatas available at the provider sraApiUrl + */ + public static async getAllAvailableAssetDatasAsync( + sraApiUrl: string, + pairedWithAssetData?: string, + ): Promise<string[]> { + const client = new HttpClient(sraApiUrl); + const params = { + assetDataA: pairedWithAssetData, + perPage: constants.MAX_PER_PAGE, + }; + const assetPairsResponse = await client.getAssetPairsAsync(params); + return _.uniq(_.map(assetPairsResponse.records, pairsItem => pairsItem.assetDataB.assetData)); + } + /** + * Instantiates a new StandardRelayerAPIAssetBuyerManager instance with all available assetDatas at the provided sraApiUrl + * @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 orderProvider An object that conforms to OrderProvider, see type for definition. + * @param networkId The ethereum network id. Defaults to 1 (mainnet). + * @param orderRefreshIntervalMs The interval in ms that getBuyQuoteAsync should trigger an refresh of orders and order states. + * Defaults to 10000ms (10s). + * @return An promise of an instance of StandardRelayerAPIAssetBuyerManager + */ + public static async getAssetBuyerManagerWithAllAvailableAssetDatasAsync( + provider: Provider, + sraApiUrl: string, + orderProvider: OrderProvider, + networkId: number = constants.MAINNET_NETWORK_ID, + orderRefreshIntervalMs?: number, + ): Promise<StandardRelayerAPIAssetBuyerManager> { + const contractWrappers = new ContractWrappers(provider, { networkId }); + const etherTokenAssetData = assetDataUtils.getEtherTokenAssetDataOrThrow(contractWrappers); + const assetDatas = await StandardRelayerAPIAssetBuyerManager.getAllAvailableAssetDatasAsync( + sraApiUrl, + etherTokenAssetData, + ); + return new StandardRelayerAPIAssetBuyerManager( + provider, + assetDatas, + orderProvider, + networkId, + orderRefreshIntervalMs, + ); + } + /** + * Instantiates a new StandardRelayerAPIAssetBuyerManager instance + * @param provider The Provider instance you would like to use for interacting with the Ethereum network. + * @param assetDatas The assetDatas of the desired assets to buy (for more info: https://github.com/0xProject/0x-protocol-specification/blob/master/v2/v2-specification.md). + * @param orderProvider An object that conforms to OrderProvider, see type for definition. + * @param networkId The ethereum network id. Defaults to 1 (mainnet). + * @param orderRefreshIntervalMs The interval in ms that getBuyQuoteAsync should trigger an refresh of orders and order states. + * Defaults to 10000ms (10s). + * @return An instance of StandardRelayerAPIAssetBuyerManager + */ + constructor( + provider: Provider, + assetDatas: string[], + orderProvider: OrderProvider, + networkId?: number, + orderRefreshIntervalMs?: number, + ) { + assert.assert(assetDatas.length > 0, `Expected 'assetDatas' to be a non-empty array.`); + this._assetBuyerMap = _.reduce( + assetDatas, + (accAssetBuyerMap: ObjectMap<AssetBuyer>, assetData: string) => { + accAssetBuyerMap[assetData] = new AssetBuyer( + provider, + assetData, + orderProvider, + networkId, + orderRefreshIntervalMs, + ); + return accAssetBuyerMap; + }, + {}, + ); + } + /** + * Get an AssetBuyer for the provided assetData + * @param assetData The desired assetData. + * + * @return An instance of AssetBuyer + */ + public getAssetBuyerFromAssetData(assetData: string): AssetBuyer { + const assetBuyer = this._assetBuyerMap[assetData]; + if (_.isUndefined(assetBuyer)) { + throw new Error( + `${StandardRelayerApiAssetBuyerManagerError.AssetBuyerNotFound}: For assetData ${assetData}`, + ); + } + return assetBuyer; + } + /** + * Get an AssetBuyer for the provided ERC20 tokenAddress + * @param tokenAddress The desired tokenAddress. + * + * @return An instance of AssetBuyer + */ + public getAssetBuyerFromERC20TokenAddress(tokenAddress: string): AssetBuyer { + const assetData = assetDataUtils.encodeERC20AssetData(tokenAddress); + return this.getAssetBuyerFromAssetData(assetData); + } + /** + * Get a list of all the assetDatas that the instance supports + * + * @return An array of assetData strings + */ + public getAssetDatas(): string[] { + return _.keys(this._assetBuyerMap); + } +} diff --git a/packages/asset-buyer/src/types.ts b/packages/asset-buyer/src/types.ts new file mode 100644 index 000000000..ee6858525 --- /dev/null +++ b/packages/asset-buyer/src/types.ts @@ -0,0 +1,86 @@ +import { SignedOrder } from '@0xproject/types'; +import { BigNumber } from '@0xproject/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; + networkId: number; +} + +/** + * 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; +} +/** + * Given an OrderProviderRequest, get an OrderProviderResponse. + */ +export interface OrderProvider { + getOrdersAsync: (orderProviderRequest: OrderProviderRequest) => Promise<OrderProviderResponse>; +} + +/** + * assetData: String that represents a specific asset (for more info: https://github.com/0xProject/0x-protocol-specification/blob/master/v2/v2-specification.md). + * 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. + * minRate: Min rate that needs to be paid in order to execute the buy. + * maxRate: Max rate that can be paid in order to execute the buy. + * assetBuyAmount: The amount of asset to buy. + * feePercentage: Optional affiliate fee percentage used to calculate the eth amounts above. + */ +export interface BuyQuote { + assetData: string; + orders: SignedOrder[]; + feeOrders: SignedOrder[]; + minRate: BigNumber; + maxRate: BigNumber; + assetBuyAmount: BigNumber; + feePercentage?: number; +} + +export interface BuyQuoteRequestOpts { + feePercentage: number; + shouldForceOrderRefresh: boolean; + slippagePercentage: 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', +} + +/** + * Possible errors thrown by an StandardRelayerApiAssetBuyerManager instance or associated static methods. + */ +export enum StandardRelayerApiAssetBuyerManagerError { + AssetBuyerNotFound = 'ASSET_BUYER_NOT_FOUND', +} + +export interface AssetBuyerOrdersAndFillableAmounts { + orders: SignedOrder[]; + feeOrders: SignedOrder[]; + remainingFillableMakerAssetAmounts: BigNumber[]; + remainingFillableFeeAmounts: 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..04f425237 --- /dev/null +++ b/packages/asset-buyer/src/utils/assert.ts @@ -0,0 +1,51 @@ +import { assert as sharedAssert } from '@0xproject/assert'; +import { schemas } from '@0xproject/json-schemas'; +import { SignedOrder } from '@0xproject/types'; +import * as _ from 'lodash'; + +import { BuyQuote, 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); + sharedAssert.isBigNumber(`${variableName}.minRate`, buyQuote.minRate); + sharedAssert.isBigNumber(`${variableName}.maxRate`, buyQuote.maxRate); + sharedAssert.isBigNumber(`${variableName}.assetBuyAmount`, buyQuote.assetBuyAmount); + if (!_.isUndefined(buyQuote.feePercentage)) { + sharedAssert.isNumber(`${variableName}.feePercentage`, buyQuote.feePercentage); + } + }, + 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); + sharedAssert.isNumber(`${variableName}.networkId`, orderFetcherRequest.networkId); + }, + areValidProvidedOrders(variableName: string, orders: SignedOrder[]): void { + if (orders.length === 0) { + return; + } + const makerAssetData = orders[0].makerAssetData; + const takerAssetData = orders[0].takerAssetData; + const filteredOrders = _.filter( + orders, + order => order.makerAssetData === makerAssetData && order.takerAssetData === takerAssetData, + ); + sharedAssert.assert( + orders.length === filteredOrders.length, + `Expected all orders in ${variableName} to have the same makerAssetData and 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..d05ff2504 --- /dev/null +++ b/packages/asset-buyer/src/utils/asset_data_utils.ts @@ -0,0 +1,26 @@ +import { ContractWrappers } from '@0xproject/contract-wrappers'; +import { assetDataUtils as sharedAssetDataUtils } from '@0xproject/order-utils'; +import * as _ from 'lodash'; + +import { AssetBuyerError } from '../types'; + +export const assetDataUtils = { + ...sharedAssetDataUtils, + getEtherTokenAssetDataOrThrow(contractWrappers: ContractWrappers): string { + const etherTokenAddressIfExists = contractWrappers.etherToken.getContractAddressIfExists(); + if (_.isUndefined(etherTokenAddressIfExists)) { + throw new Error(AssetBuyerError.NoEtherTokenContractFound); + } + const etherTokenAssetData = sharedAssetDataUtils.encodeERC20AssetData(etherTokenAddressIfExists); + return etherTokenAssetData; + }, + getZrxTokenAssetDataOrThrow(contractWrappers: ContractWrappers): string { + let zrxTokenAssetData: string; + try { + zrxTokenAssetData = contractWrappers.exchange.getZRXAssetData(); + } catch (err) { + throw new Error(AssetBuyerError.NoZrxTokenContractFound); + } + return zrxTokenAssetData; + }, +}; 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..9946924ef --- /dev/null +++ b/packages/asset-buyer/src/utils/buy_quote_calculator.ts @@ -0,0 +1,89 @@ +import { marketUtils } from '@0xproject/order-utils'; +import { BigNumber } from '@0xproject/utils'; +import * as _ from 'lodash'; + +import { constants } from '../constants'; +import { AssetBuyerError, AssetBuyerOrdersAndFillableAmounts, BuyQuote } from '../types'; + +import { orderUtils } from './order_utils'; + +// Calculates a buy quote for orders that have WETH as the takerAsset +export const buyQuoteCalculator = { + calculate( + ordersAndFillableAmounts: AssetBuyerOrdersAndFillableAmounts, + assetBuyAmount: BigNumber, + feePercentage: number, + slippagePercentage: number, + ): BuyQuote { + const { + orders, + feeOrders, + remainingFillableMakerAssetAmounts, + remainingFillableFeeAmounts, + } = ordersAndFillableAmounts; + const slippageBufferAmount = assetBuyAmount.mul(slippagePercentage).round(); + const { + resultOrders, + remainingFillAmount, + ordersRemainingFillableMakerAssetAmounts, + } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(orders, assetBuyAmount, { + remainingFillableMakerAssetAmounts, + slippageBufferAmount, + }); + if (remainingFillAmount.gt(constants.ZERO_AMOUNT)) { + throw new Error(AssetBuyerError.InsufficientAssetLiquidity); + } + // 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 + const { + resultFeeOrders, + remainingFeeAmount, + feeOrdersRemainingFillableMakerAssetAmounts, + } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(resultOrders, feeOrders, { + remainingFillableMakerAssetAmounts, + remainingFillableFeeAmounts, + }); + if (remainingFeeAmount.gt(constants.ZERO_AMOUNT)) { + throw new Error(AssetBuyerError.InsufficientZrxLiquidity); + } + const assetData = orders[0].makerAssetData; + + // calculate minRate and maxRate by calculating min and max eth usage and then dividing into + // assetBuyAmount to get assetData / WETH, needs to take into account feePercentage as well + // minEthAmount = (sum(takerAssetAmount[i]) until sum(makerAssetAmount[i]) >= assetBuyAmount ) * (1 + feePercentage) + // maxEthAmount = (sum(takerAssetAmount[i]) until i == orders.length) * (1 + feePercentage) + const allOrders = _.concat(resultOrders, resultFeeOrders); + const allRemainingAmounts = _.concat( + ordersRemainingFillableMakerAssetAmounts, + feeOrdersRemainingFillableMakerAssetAmounts, + ); + let minEthAmount = constants.ZERO_AMOUNT; + let maxEthAmount = constants.ZERO_AMOUNT; + let cumulativeMakerAmount = constants.ZERO_AMOUNT; + _.forEach(allOrders, (order, index) => { + const remainingFillableMakerAssetAmount = allRemainingAmounts[index]; + const claimableTakerAssetAmount = orderUtils.calculateRemainingTakerAssetAmount( + order, + remainingFillableMakerAssetAmount, + ); + // taker asset is always assumed to be WETH + maxEthAmount = maxEthAmount.plus(claimableTakerAssetAmount); + if (cumulativeMakerAmount.lessThan(assetBuyAmount)) { + minEthAmount = minEthAmount.plus(claimableTakerAssetAmount); + } + cumulativeMakerAmount = cumulativeMakerAmount.plus(remainingFillableMakerAssetAmount); + }); + const feeAdjustedMinRate = minEthAmount.mul(feePercentage + 1).div(assetBuyAmount); + const feeAdjustedMaxRate = minEthAmount.mul(feePercentage + 1).div(assetBuyAmount); + return { + assetData, + orders: resultOrders, + feeOrders: resultFeeOrders, + minRate: feeAdjustedMinRate, + maxRate: feeAdjustedMaxRate, + assetBuyAmount, + feePercentage, + }; + }, +}; 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..31fdcc182 --- /dev/null +++ b/packages/asset-buyer/src/utils/order_provider_response_processor.ts @@ -0,0 +1,202 @@ +import { OrderAndTraderInfo, OrderStatus, OrderValidatorWrapper } from '@0xproject/contract-wrappers'; +import { sortingUtils } from '@0xproject/order-utils'; +import { RemainingFillableCalculator } from '@0xproject/order-utils/lib/src/remaining_fillable_calculator'; +import { SignedOrder } from '@0xproject/types'; +import { BigNumber } from '@0xproject/utils'; +import * as _ from 'lodash'; + +import { constants } from '../constants'; +import { + AssetBuyerError, + AssetBuyerOrdersAndFillableAmounts, + OrderProviderRequest, + OrderProviderResponse, + SignedOrderWithRemainingFillableMakerAssetAmount, +} from '../types'; + +import { orderUtils } from './order_utils'; + +interface OrdersAndRemainingFillableMakerAssetAmounts { + orders: SignedOrder[]; + remainingFillableMakerAssetAmounts: BigNumber[]; +} + +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( + targetOrderProviderResponse: OrderProviderResponse, + feeOrderProviderResponse: OrderProviderResponse, + zrxTokenAssetData: string, + expiryBufferSeconds: number, + orderValidator?: OrderValidatorWrapper, + ): Promise<AssetBuyerOrdersAndFillableAmounts> { + // drop orders that are expired or not open + const filteredTargetOrders = filterOutExpiredAndNonOpenOrders( + targetOrderProviderResponse.orders, + expiryBufferSeconds, + ); + const filteredFeeOrders = filterOutExpiredAndNonOpenOrders( + feeOrderProviderResponse.orders, + expiryBufferSeconds, + ); + // set the orders to be sorted equal to the filtered orders + let unsortedTargetOrders = filteredTargetOrders; + let unsortedFeeOrders = filteredFeeOrders; + // if an orderValidator is provided, use on chain information to calculate remaining fillable makerAsset amounts + if (!_.isUndefined(orderValidator)) { + // TODO(bmillman): improvement + // try/catch these requests and throw a more domain specific error + // TODO(bmillman): optimization + // reduce this to once RPC call buy combining orders into one array and then splitting up the response + const [targetOrdersAndTradersInfo, feeOrdersAndTradersInfo] = await Promise.all( + _.map([filteredTargetOrders, filteredFeeOrders], ordersToBeValidated => { + const takerAddresses = _.map(ordersToBeValidated, () => constants.NULL_ADDRESS); + return orderValidator.getOrdersAndTradersInfoAsync(ordersToBeValidated, takerAddresses); + }), + ); + // take orders + on chain information and find the valid orders and remaining fillable maker asset amounts + unsortedTargetOrders = getValidOrdersWithRemainingFillableMakerAssetAmountsFromOnChain( + filteredTargetOrders, + targetOrdersAndTradersInfo, + zrxTokenAssetData, + ); + // take orders + on chain information and find the valid orders and remaining fillable maker asset amounts + unsortedFeeOrders = getValidOrdersWithRemainingFillableMakerAssetAmountsFromOnChain( + filteredFeeOrders, + feeOrdersAndTradersInfo, + zrxTokenAssetData, + ); + } + // 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 sortedTargetOrders = sortingUtils.sortOrdersByFeeAdjustedRate(unsortedTargetOrders); + const sortedFeeOrders = sortingUtils.sortFeeOrdersByFeeAdjustedRate(unsortedFeeOrders); + // unbundle orders and fillable amounts and compile final result + const targetOrdersAndRemainingFillableMakerAssetAmounts = unbundleOrdersWithAmounts(sortedTargetOrders); + const feeOrdersAndRemainingFillableMakerAssetAmounts = unbundleOrdersWithAmounts(sortedFeeOrders); + return { + orders: targetOrdersAndRemainingFillableMakerAssetAmounts.orders, + feeOrders: feeOrdersAndRemainingFillableMakerAssetAmounts.orders, + remainingFillableMakerAssetAmounts: + targetOrdersAndRemainingFillableMakerAssetAmounts.remainingFillableMakerAssetAmounts, + remainingFillableFeeAmounts: + feeOrdersAndRemainingFillableMakerAssetAmounts.remainingFillableMakerAssetAmounts, + }; + }, +}; + +/** + * 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[], + zrxAssetData: string, +): 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.calculateRemainingMakerAssetAmount( + order, + remainingTakerAssetAmount, + ); + const remainingFillableCalculator = new RemainingFillableCalculator( + order.makerFee, + order.makerAssetAmount, + order.makerAssetData === zrxAssetData, + 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[], +): OrdersAndRemainingFillableMakerAssetAmounts { + 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..62166eb76 --- /dev/null +++ b/packages/asset-buyer/src/utils/order_utils.ts @@ -0,0 +1,30 @@ +import { SignedOrder } from '@0xproject/types'; +import { BigNumber } from '@0xproject/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.minus(secondsFromNow)); + }, + calculateRemainingMakerAssetAmount(order: SignedOrder, remainingTakerAssetAmount: BigNumber): BigNumber { + if (remainingTakerAssetAmount.eq(0)) { + return constants.ZERO_AMOUNT; + } + return remainingTakerAssetAmount.times(order.makerAssetAmount).dividedToIntegerBy(order.takerAssetAmount); + }, + calculateRemainingTakerAssetAmount(order: SignedOrder, remainingMakerAssetAmount: BigNumber): BigNumber { + if (remainingMakerAssetAmount.eq(0)) { + return constants.ZERO_AMOUNT; + } + return remainingMakerAssetAmount.times(order.takerAssetAmount).dividedToIntegerBy(order.makerAssetAmount); + }, + isOpenOrder(order: SignedOrder): boolean { + return order.takerAddress === constants.NULL_ADDRESS; + }, +}; diff --git a/packages/asset-buyer/test/utils/chai_setup.ts b/packages/asset-buyer/test/utils/chai_setup.ts new file mode 100644 index 000000000..1a8733093 --- /dev/null +++ b/packages/asset-buyer/test/utils/chai_setup.ts @@ -0,0 +1,13 @@ +import * as chai from 'chai'; +import chaiAsPromised = require('chai-as-promised'); +import ChaiBigNumber = require('chai-bignumber'); +import * as dirtyChai from 'dirty-chai'; + +export const chaiSetup = { + configure(): void { + chai.config.includeStack = true; + chai.use(ChaiBigNumber()); + chai.use(dirtyChai); + chai.use(chaiAsPromised); + }, +}; diff --git a/packages/asset-buyer/tsconfig.json b/packages/asset-buyer/tsconfig.json new file mode 100644 index 000000000..2ee711adc --- /dev/null +++ b/packages/asset-buyer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "." + }, + "include": ["./src/**/*", "./test/**/*"] +} diff --git a/packages/asset-buyer/tslint.json b/packages/asset-buyer/tslint.json new file mode 100644 index 000000000..ffaefe83a --- /dev/null +++ b/packages/asset-buyer/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": ["@0xproject/tslint-config"] +} diff --git a/packages/asset-buyer/typedoc-tsconfig.json b/packages/asset-buyer/typedoc-tsconfig.json new file mode 100644 index 000000000..c9b0af1ae --- /dev/null +++ b/packages/asset-buyer/typedoc-tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../typedoc-tsconfig", + "compilerOptions": { + "outDir": "lib" + }, + "include": ["./src/**/*", "./test/**/*"] +} |