Merge pull request #937 from 0xProject/feature/contract-wrappers/forwarder-estimation-utils

Add marketUtils object for assisting with market buy calculations
This commit is contained in:
Brandon Millman
2018-08-06 16:36:32 -04:00
committed by GitHub
9 changed files with 462 additions and 2 deletions

View File

@@ -1,4 +1,13 @@
[
{
"version": "1.0.1-rc.4",
"changes": [
{
"note": "Change hexSchema to match `0x`",
"pr": 937
}
]
},
{
"version": "1.0.1-rc.3",
"changes": [

View File

@@ -7,7 +7,7 @@ export const addressSchema = {
export const hexSchema = {
id: '/Hex',
type: 'string',
pattern: '^0x([0-9a-f][0-9a-f])+$',
pattern: '^0x(([0-9a-f][0-9a-f])+)?$',
};
export const numberSchema = {

View File

@@ -89,7 +89,7 @@ describe('Schema', () => {
validateAgainstSchema(testCases, hexSchema);
});
it('should fail for invalid hex string', () => {
const testCases = ['0x', '0', '0xzzzzzzB11a196601eD2ce54B665CaFEca0347D42'];
const testCases = ['0', '0xzzzzzzB11a196601eD2ce54B665CaFEca0347D42'];
const shouldFail = true;
validateAgainstSchema(testCases, hexSchema, shouldFail);
});

View File

@@ -6,6 +6,10 @@
"note":
"Added a synchronous `createOrder` method in `orderFactory`, updated public interfaces to support some optional parameters",
"pr": 936
},
{
"note": "Added marketUtils",
"pr": 937
}
]
},

View File

@@ -2,6 +2,7 @@ import { BigNumber } from '@0xproject/utils';
export const constants = {
NULL_ADDRESS: '0x0000000000000000000000000000000000000000',
NULL_BYTES: '0x',
// tslint:disable-next-line:custom-no-magic-numbers
UNLIMITED_ALLOWANCE_IN_BASE_UNITS: new BigNumber(2).pow(256).minus(1),
TESTRPC_NETWORK_ID: 50,

View File

@@ -32,3 +32,4 @@ export { assetDataUtils } from './asset_data_utils';
export { EIP712Utils } from './eip712_utils';
export { OrderValidationUtils } from './order_validation_utils';
export { ExchangeTransferSimulator } from './exchange_transfer_simulator';
export { marketUtils } from './market_utils';

View File

@@ -0,0 +1,133 @@
import { schemas } from '@0xproject/json-schemas';
import { SignedOrder } from '@0xproject/types';
import { BigNumber } from '@0xproject/utils';
import * as _ from 'lodash';
import { assert } from './assert';
import { constants } from './constants';
export const marketUtils = {
/**
* Takes an array of orders and returns a subset of those orders that has enough makerAssetAmount (taking into account on-chain balances,
* allowances, and partial fills) in order to fill the input makerAssetFillAmount plus slippageBufferAmount. Iterates from first order to last.
* Sort the input by ascending rate in order to get the subset of orders that will cost the least ETH.
* @param signedOrders An array of objects that conform to the SignedOrder interface. All orders should specify the same makerAsset.
* All orders should specify WETH as the takerAsset.
* @param remainingFillableMakerAssetAmounts An array of BigNumbers corresponding to the signedOrders parameter.
* You can use OrderStateUtils @0xproject/order-utils to perform blockchain lookups
* for these values.
* @param makerAssetFillAmount The amount of makerAsset desired to be filled.
* @param slippageBufferAmount An additional amount of makerAsset to be covered by the result in case of trade collisions or partial fills.
* @return Resulting orders and remaining fill amount that could not be covered by the input.
*/
findOrdersThatCoverMakerAssetFillAmount(
signedOrders: SignedOrder[],
remainingFillableMakerAssetAmounts: BigNumber[],
makerAssetFillAmount: BigNumber,
slippageBufferAmount: BigNumber = constants.ZERO_AMOUNT,
): { resultOrders: SignedOrder[]; remainingFillAmount: BigNumber } {
assert.doesConformToSchema('signedOrders', signedOrders, schemas.signedOrdersSchema);
_.forEach(remainingFillableMakerAssetAmounts, (amount, index) =>
assert.isValidBaseUnitAmount(`remainingFillableMakerAssetAmount[${index}]`, amount),
);
assert.isValidBaseUnitAmount('makerAssetFillAmount', makerAssetFillAmount);
assert.isValidBaseUnitAmount('slippageBufferAmount', slippageBufferAmount);
assert.assert(
signedOrders.length === remainingFillableMakerAssetAmounts.length,
'Expected signedOrders.length to equal remainingFillableMakerAssetAmounts.length',
);
// calculate total amount of makerAsset needed to be filled
const totalFillAmount = makerAssetFillAmount.plus(slippageBufferAmount);
// iterate through the signedOrders input from left to right until we have enough makerAsset to fill totalFillAmount
const result = _.reduce(
signedOrders,
({ resultOrders, remainingFillAmount }, order, index) => {
if (remainingFillAmount.lessThanOrEqualTo(constants.ZERO_AMOUNT)) {
return { resultOrders, remainingFillAmount: constants.ZERO_AMOUNT };
} else {
const makerAssetAmountAvailable = remainingFillableMakerAssetAmounts[index];
// if there is no makerAssetAmountAvailable do not append order to resultOrders
// if we have exceeded the total amount we want to fill set remainingFillAmount to 0
return {
resultOrders: makerAssetAmountAvailable.gt(constants.ZERO_AMOUNT)
? _.concat(resultOrders, order)
: resultOrders,
remainingFillAmount: BigNumber.max(
constants.ZERO_AMOUNT,
remainingFillAmount.minus(makerAssetAmountAvailable),
),
};
}
},
{ resultOrders: [] as SignedOrder[], remainingFillAmount: totalFillAmount },
);
return result;
},
/**
* Takes an array of orders and an array of feeOrders. Returns a subset of the feeOrders that has enough ZRX (taking into account
* on-chain balances, allowances, and partial fills) in order to fill the takerFees required by signedOrders plus a
* slippageBufferAmount. Iterates from first feeOrder to last. Sort the feeOrders by ascending rate in order to get the subset of
* feeOrders that will cost the least ETH.
* @param signedOrders An array of objects that conform to the SignedOrder interface. All orders should specify ZRX as
* the makerAsset and WETH as the takerAsset.
* @param remainingFillableMakerAssetAmounts An array of BigNumbers corresponding to the signedOrders parameter.
* You can use OrderStateUtils @0xproject/order-utils to perform blockchain lookups
* for these values.
* @param signedFeeOrders An array of objects that conform to the SignedOrder interface. All orders should specify ZRX as
* the makerAsset and WETH as the takerAsset.
* @param remainingFillableFeeAmounts An array of BigNumbers corresponding to the signedFeeOrders parameter.
* You can use OrderStateUtils @0xproject/order-utils to perform blockchain lookups
* for these values.
* @param slippageBufferAmount An additional amount of fee to be covered by the result in case of trade collisions or partial fills.
* @return Resulting orders and remaining fee amount that could not be covered by the input.
*/
findFeeOrdersThatCoverFeesForTargetOrders(
signedOrders: SignedOrder[],
remainingFillableMakerAssetAmounts: BigNumber[],
signedFeeOrders: SignedOrder[],
remainingFillableFeeAmounts: BigNumber[],
slippageBufferAmount: BigNumber = constants.ZERO_AMOUNT,
): { resultOrders: SignedOrder[]; remainingFeeAmount: BigNumber } {
assert.doesConformToSchema('signedOrders', signedOrders, schemas.signedOrdersSchema);
_.forEach(remainingFillableMakerAssetAmounts, (amount, index) =>
assert.isValidBaseUnitAmount(`remainingFillableMakerAssetAmount[${index}]`, amount),
);
assert.doesConformToSchema('signedFeeOrders', signedFeeOrders, schemas.signedOrdersSchema);
_.forEach(remainingFillableFeeAmounts, (amount, index) =>
assert.isValidBaseUnitAmount(`remainingFillableFeeAmounts[${index}]`, amount),
);
assert.isValidBaseUnitAmount('slippageBufferAmount', slippageBufferAmount);
assert.assert(
signedOrders.length === remainingFillableMakerAssetAmounts.length,
'Expected signedOrders.length to equal remainingFillableMakerAssetAmounts.length',
);
assert.assert(
signedOrders.length === remainingFillableMakerAssetAmounts.length,
'Expected signedFeeOrders.length to equal remainingFillableFeeAmounts.length',
);
// calculate total amount of ZRX needed to fill signedOrders
const totalFeeAmount = _.reduce(
signedOrders,
(accFees, order, index) => {
const makerAssetAmountAvailable = remainingFillableMakerAssetAmounts[index];
const feeToFillMakerAssetAmountAvailable = makerAssetAmountAvailable
.mul(order.takerFee)
.div(order.makerAssetAmount);
return accFees.plus(feeToFillMakerAssetAmountAvailable);
},
constants.ZERO_AMOUNT,
);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
signedFeeOrders,
remainingFillableFeeAmounts,
totalFeeAmount,
slippageBufferAmount,
);
return {
resultOrders,
remainingFeeAmount: remainingFillAmount,
};
// TODO: add more orders here to cover rounding
// https://github.com/0xProject/0x-protocol-specification/blob/master/v2/forwarding-contract-specification.md#over-buying-zrx
},
};

View File

@@ -0,0 +1,280 @@
import { BigNumber } from '@0xproject/utils';
import * as chai from 'chai';
import 'mocha';
import { constants, marketUtils } from '../src';
import { chaiSetup } from './utils/chai_setup';
import { testOrderFactory } from './utils/test_order_factory';
chaiSetup.configure();
const expect = chai.expect;
// tslint:disable: no-unused-expression
describe('marketUtils', () => {
describe('#findOrdersThatCoverMakerAssetFillAmount', () => {
describe('no orders', () => {
it('returns empty and unchanged remainingFillAmount', async () => {
const fillAmount = new BigNumber(10);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
[],
[],
fillAmount,
);
expect(resultOrders).to.be.empty;
expect(remainingFillAmount).to.be.bignumber.equal(fillAmount);
});
});
describe('orders are completely fillable', () => {
// generate three signed orders each with 10 units of makerAsset, 30 total
const makerAssetAmount = new BigNumber(10);
const inputOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount,
},
3,
);
// generate remainingFillableMakerAssetAmounts that equal the makerAssetAmount
const remainingFillableMakerAssetAmounts = [makerAssetAmount, makerAssetAmount, makerAssetAmount];
it('returns input orders and zero remainingFillAmount when input exactly matches requested fill amount', async () => {
// try to fill 20 units of makerAsset
// include 10 units of slippageBufferAmount
const fillAmount = new BigNumber(20);
const slippageBufferAmount = new BigNumber(10);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
inputOrders,
remainingFillableMakerAssetAmounts,
fillAmount,
slippageBufferAmount,
);
expect(resultOrders).to.be.deep.equal(inputOrders);
expect(remainingFillAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
it('returns input orders and zero remainingFillAmount when input has more than requested fill amount', async () => {
// try to fill 15 units of makerAsset
// include 10 units of slippageBufferAmount
const fillAmount = new BigNumber(15);
const slippageBufferAmount = new BigNumber(10);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
inputOrders,
remainingFillableMakerAssetAmounts,
fillAmount,
slippageBufferAmount,
);
expect(resultOrders).to.be.deep.equal(inputOrders);
expect(remainingFillAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
it('returns input orders and non-zero remainingFillAmount when input has less than requested fill amount', async () => {
// try to fill 30 units of makerAsset
// include 5 units of slippageBufferAmount
const fillAmount = new BigNumber(30);
const slippageBufferAmount = new BigNumber(5);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
inputOrders,
remainingFillableMakerAssetAmounts,
fillAmount,
slippageBufferAmount,
);
expect(resultOrders).to.be.deep.equal(inputOrders);
expect(remainingFillAmount).to.be.bignumber.equal(new BigNumber(5));
});
it('returns first order and zero remainingFillAmount when requested fill amount is exactly covered by the first order', async () => {
// try to fill 10 units of makerAsset
const fillAmount = new BigNumber(10);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
inputOrders,
remainingFillableMakerAssetAmounts,
fillAmount,
);
expect(resultOrders).to.be.deep.equal([inputOrders[0]]);
expect(remainingFillAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
it('returns first two orders and zero remainingFillAmount when requested fill amount is over covered by the first two order', async () => {
// try to fill 15 units of makerAsset
const fillAmount = new BigNumber(15);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
inputOrders,
remainingFillableMakerAssetAmounts,
fillAmount,
);
expect(resultOrders).to.be.deep.equal([inputOrders[0], inputOrders[1]]);
expect(remainingFillAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
});
describe('orders are partially fillable', () => {
// generate three signed orders each with 10 units of makerAsset, 30 total
const makerAssetAmount = new BigNumber(10);
const inputOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount,
},
3,
);
// generate remainingFillableMakerAssetAmounts that cover different partial fill scenarios
// 1. order is completely filled already
// 2. order is partially fillable
// 3. order is completely fillable
const remainingFillableMakerAssetAmounts = [constants.ZERO_AMOUNT, new BigNumber(5), makerAssetAmount];
it('returns last two orders and non-zero remainingFillAmount when trying to fill original makerAssetAmounts', async () => {
// try to fill 30 units of makerAsset
const fillAmount = new BigNumber(30);
const { resultOrders, remainingFillAmount } = marketUtils.findOrdersThatCoverMakerAssetFillAmount(
inputOrders,
remainingFillableMakerAssetAmounts,
fillAmount,
);
expect(resultOrders).to.be.deep.equal([inputOrders[1], inputOrders[2]]);
expect(remainingFillAmount).to.be.bignumber.equal(new BigNumber(15));
});
});
});
describe('#findFeeOrdersThatCoverFeesForTargetOrders', () => {
// generate three signed fee orders each with 10 units of ZRX, 30 total
const zrxAmount = new BigNumber(10);
const inputFeeOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount: zrxAmount,
},
3,
);
// generate remainingFillableFeeAmounts that equal the zrxAmount
const remainingFillableFeeAmounts = [zrxAmount, zrxAmount, zrxAmount];
describe('no target orders', () => {
it('returns empty and zero remainingFeeAmount', async () => {
const { resultOrders, remainingFeeAmount } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(
[],
[],
inputFeeOrders,
remainingFillableFeeAmounts,
);
expect(resultOrders).to.be.empty;
expect(remainingFeeAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
});
describe('no fee orders', () => {
// generate three signed orders each with 10 units of makerAsset, 30 total
// each signed order requires 10 units of takerFee
const makerAssetAmount = new BigNumber(10);
const takerFee = new BigNumber(10);
const inputOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount,
takerFee,
},
3,
);
// generate remainingFillableMakerAssetAmounts that equal the makerAssetAmount
const remainingFillableMakerAssetAmounts = [makerAssetAmount, makerAssetAmount, makerAssetAmount];
it('returns empty and non-zero remainingFeeAmount', async () => {
const { resultOrders, remainingFeeAmount } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(
inputOrders,
remainingFillableMakerAssetAmounts,
[],
[],
);
expect(resultOrders).to.be.empty;
expect(remainingFeeAmount).to.be.bignumber.equal(new BigNumber(30));
});
});
describe('target orders have no fees', () => {
// generate three signed orders each with 10 units of makerAsset, 30 total
const makerAssetAmount = new BigNumber(10);
const inputOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount,
},
3,
);
// generate remainingFillableMakerAssetAmounts that equal the makerAssetAmount
const remainingFillableMakerAssetAmounts = [makerAssetAmount, makerAssetAmount, makerAssetAmount];
it('returns empty and zero remainingFeeAmount', async () => {
const { resultOrders, remainingFeeAmount } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(
inputOrders,
remainingFillableMakerAssetAmounts,
inputFeeOrders,
remainingFillableFeeAmounts,
);
expect(resultOrders).to.be.empty;
expect(remainingFeeAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
});
describe('target orders require fees and are completely fillable', () => {
// generate three signed orders each with 10 units of makerAsset, 30 total
// each signed order requires 10 units of takerFee
const makerAssetAmount = new BigNumber(10);
const takerFee = new BigNumber(10);
const inputOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount,
takerFee,
},
3,
);
// generate remainingFillableMakerAssetAmounts that equal the makerAssetAmount
const remainingFillableMakerAssetAmounts = [makerAssetAmount, makerAssetAmount, makerAssetAmount];
it('returns input fee orders and zero remainingFeeAmount', async () => {
const { resultOrders, remainingFeeAmount } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(
inputOrders,
remainingFillableMakerAssetAmounts,
inputFeeOrders,
remainingFillableFeeAmounts,
);
expect(resultOrders).to.be.deep.equal(inputFeeOrders);
expect(remainingFeeAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
});
describe('target orders require fees and are partially fillable', () => {
// generate three signed orders each with 10 units of makerAsset, 30 total
// each signed order requires 10 units of takerFee
const makerAssetAmount = new BigNumber(10);
const takerFee = new BigNumber(10);
const inputOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount,
takerFee,
},
3,
);
// generate remainingFillableMakerAssetAmounts that cover different partial fill scenarios
// 1. order is completely filled already
// 2. order is partially fillable
// 3. order is completely fillable
const remainingFillableMakerAssetAmounts = [constants.ZERO_AMOUNT, new BigNumber(5), makerAssetAmount];
it('returns first two input fee orders and zero remainingFeeAmount', async () => {
const { resultOrders, remainingFeeAmount } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(
inputOrders,
remainingFillableMakerAssetAmounts,
inputFeeOrders,
remainingFillableFeeAmounts,
);
expect(resultOrders).to.be.deep.equal([inputFeeOrders[0], inputFeeOrders[1]]);
expect(remainingFeeAmount).to.be.bignumber.equal(constants.ZERO_AMOUNT);
});
});
describe('target orders require more fees than available', () => {
// generate three signed orders each with 10 units of makerAsset, 30 total
// each signed order requires 20 units of takerFee
const makerAssetAmount = new BigNumber(10);
const takerFee = new BigNumber(20);
const inputOrders = testOrderFactory.generateTestSignedOrders(
{
makerAssetAmount,
takerFee,
},
3,
);
// generate remainingFillableMakerAssetAmounts that equal the makerAssetAmount
const remainingFillableMakerAssetAmounts = [makerAssetAmount, makerAssetAmount, makerAssetAmount];
it('returns input fee orders and non-zero remainingFeeAmount', async () => {
const { resultOrders, remainingFeeAmount } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(
inputOrders,
remainingFillableMakerAssetAmounts,
inputFeeOrders,
remainingFillableFeeAmounts,
);
expect(resultOrders).to.be.deep.equal(inputFeeOrders);
expect(remainingFeeAmount).to.be.bignumber.equal(new BigNumber(30));
});
});
});
});

View File

@@ -0,0 +1,32 @@
import { Order, SignedOrder } from '@0xproject/types';
import * as _ from 'lodash';
import { constants, orderFactory } from '../../src';
const BASE_TEST_ORDER: Order = orderFactory.createOrder(
constants.NULL_ADDRESS,
constants.ZERO_AMOUNT,
constants.NULL_ADDRESS,
constants.ZERO_AMOUNT,
constants.NULL_ADDRESS,
constants.NULL_ADDRESS,
);
const BASE_TEST_SIGNED_ORDER: SignedOrder = {
...BASE_TEST_ORDER,
signature: constants.NULL_BYTES,
};
export const testOrderFactory = {
generateTestSignedOrder(partialOrder: Partial<SignedOrder>): SignedOrder {
return transformObject(BASE_TEST_SIGNED_ORDER, partialOrder);
},
generateTestSignedOrders(partialOrder: Partial<SignedOrder>, numOrders: number): SignedOrder[] {
const baseTestOrders = _.map(_.range(numOrders), () => BASE_TEST_SIGNED_ORDER);
return _.map(baseTestOrders, order => transformObject(order, partialOrder));
},
};
function transformObject<T>(input: T, transformation: Partial<T>): T {
const copy = _.cloneDeep(input);
return _.assign(copy, transformation);
}