diff options
author | Alex Browne <stephenalexbrowne@gmail.com> | 2018-07-27 13:09:55 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-07-27 13:09:55 +0800 |
commit | 554d5f97df55779beed35ef73214e80b386d7927 (patch) | |
tree | eaf73ce6cbd61d96d541c08d7e2d640f02f32083 /packages/contracts | |
parent | 95c627f581ec8d724a0c737cbbf5b53b96765f6e (diff) | |
download | dexon-0x-contracts-554d5f97df55779beed35ef73214e80b386d7927.tar dexon-0x-contracts-554d5f97df55779beed35ef73214e80b386d7927.tar.gz dexon-0x-contracts-554d5f97df55779beed35ef73214e80b386d7927.tar.bz2 dexon-0x-contracts-554d5f97df55779beed35ef73214e80b386d7927.tar.lz dexon-0x-contracts-554d5f97df55779beed35ef73214e80b386d7927.tar.xz dexon-0x-contracts-554d5f97df55779beed35ef73214e80b386d7927.tar.zst dexon-0x-contracts-554d5f97df55779beed35ef73214e80b386d7927.zip |
Add combinatorial tests for internal Exchange functions (#807)
* WIP add combinatorial tests for internal Exchange functions
* Change combinitorial testing strategy based on feedback
* Check value of filled[orderHash] in updateFilledState tests
* Add combinatorial tests for addFillResults
* Add combinatorial tests for getPartialAmount
* Implement generic `testWithReferenceFuncAsync`
* Implement generic `testCombinatoriallyWithReferenceFuncAsync`
* Add combinatorial tests for isRoundingError
* Add combinatorial tests for calculateFillResults
* Add support for Geth in internal contract tests
* Fix contract artifacts
* Change DECIMAL_PLACES to 78 and add a note.
* Document new functions in utils
* Optimize tests by only reseting state when needed
* Rename/move some files
* Print parameter names on failure in testWithReferenceFuncAsync
* Add to changelog for utils package
* Appease various linters
* Rename some more things related to FillOrderCombinatorialUtils
* Remove .only from test/exchange/internal.ts
* Remove old test for isRoundingError and getPartialAmount
* Appease linters again
* Remove old todos
* Fix typos, add comments, rename some things
* Re-add some LibMath tests
* Update contract internal tests to use new SafeMath revert reasons
* Apply PR feedback from Amir
* Apply PR feedback from Remco
* Re-add networks to ZRXToken artifact
* Remove duplicate Whitelist in compiler.json
Diffstat (limited to 'packages/contracts')
-rw-r--r-- | packages/contracts/compiler.json | 3 | ||||
-rw-r--r-- | packages/contracts/package.json | 4 | ||||
-rw-r--r-- | packages/contracts/src/2.0.0/protocol/Exchange/MixinExchangeCore.sol | 1 | ||||
-rw-r--r-- | packages/contracts/src/2.0.0/test/TestExchangeInternals/TestExchangeInternals.sol | 120 | ||||
-rw-r--r-- | packages/contracts/test/exchange/fill_order.ts | 51 | ||||
-rw-r--r-- | packages/contracts/test/exchange/internal.ts | 305 | ||||
-rw-r--r-- | packages/contracts/test/exchange/libs.ts | 121 | ||||
-rw-r--r-- | packages/contracts/test/utils/artifacts.ts | 2 | ||||
-rw-r--r-- | packages/contracts/test/utils/assertions.ts | 27 | ||||
-rw-r--r-- | packages/contracts/test/utils/combinatorial_utils.ts | 113 | ||||
-rw-r--r-- | packages/contracts/test/utils/fill_order_combinatorial_utils.ts (renamed from packages/contracts/test/utils/core_combinatorial_utils.ts) | 18 | ||||
-rw-r--r-- | packages/contracts/test/utils/test_with_reference.ts | 119 |
12 files changed, 756 insertions, 128 deletions
diff --git a/packages/contracts/compiler.json b/packages/contracts/compiler.json index dba836bde..ad35fc5b3 100644 --- a/packages/contracts/compiler.json +++ b/packages/contracts/compiler.json @@ -42,12 +42,13 @@ "TestConstants", "TestLibBytes", "TestLibs", + "TestExchangeInternals", "TestSignatureValidator", "TokenRegistry", "Validator", "Wallet", - "Whitelist", "WETH9", + "Whitelist", "ZRXToken" ] } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9fe8046b1..014210d33 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -33,7 +33,7 @@ "lint-contracts": "solhint src/2.0.0/**/**/**/**/*.sol" }, "config": { - "abis": "../migrations/artifacts/2.0.0/@(AssetProxyOwner|DummyERC20Token|DummyERC721Receiver|DummyERC721Token|ERC20Proxy|ERC721Proxy|Forwarder|Exchange|ExchangeWrapper|IAssetData|IAssetProxy|MixinAuthorizable|MultiSigWallet|MultiSigWalletWithTimeLock|TestAssetProxyOwner|TestAssetProxyDispatcher|TestConstants|TestLibBytes|TestLibs|TestSignatureValidator|Validator|Wallet|TokenRegistry|Whitelist|WETH9|ZRXToken).json" + "abis": "../migrations/artifacts/2.0.0/@(AssetProxyOwner|DummyERC20Token|DummyERC721Receiver|DummyERC721Token|ERC20Proxy|ERC721Proxy|Forwarder|Exchange|ExchangeWrapper|IAssetData|IAssetProxy|MixinAuthorizable|MultiSigWallet|MultiSigWalletWithTimeLock|TestAssetProxyOwner|TestAssetProxyDispatcher|TestConstants|TestExchangeInternals|TestLibBytes|TestLibs|TestSignatureValidator|Validator|Wallet|TokenRegistry|Whitelist|WETH9|ZRXToken).json" }, "repository": { "type": "git", @@ -79,11 +79,13 @@ "@0xproject/typescript-typings": "^1.0.3", "@0xproject/utils": "^1.0.4", "@0xproject/web3-wrapper": "^1.1.2", + "@types/js-combinatorics": "^0.5.29", "bn.js": "^4.11.8", "ethereum-types": "^1.0.3", "ethereumjs-abi": "0.6.5", "ethereumjs-util": "^5.1.1", "ethers": "3.0.22", + "js-combinatorics": "^0.5.3", "lodash": "^4.17.4" } } diff --git a/packages/contracts/src/2.0.0/protocol/Exchange/MixinExchangeCore.sol b/packages/contracts/src/2.0.0/protocol/Exchange/MixinExchangeCore.sol index 354d0e9c3..ab5c6e507 100644 --- a/packages/contracts/src/2.0.0/protocol/Exchange/MixinExchangeCore.sol +++ b/packages/contracts/src/2.0.0/protocol/Exchange/MixinExchangeCore.sol @@ -207,7 +207,6 @@ contract MixinExchangeCore is /// @param order that was filled. /// @param takerAddress Address of taker who filled the order. /// @param orderTakerAssetFilledAmount Amount of order already filled. - /// @return fillResults Amounts filled and fees paid by maker and taker. function updateFilledState( Order memory order, address takerAddress, diff --git a/packages/contracts/src/2.0.0/test/TestExchangeInternals/TestExchangeInternals.sol b/packages/contracts/src/2.0.0/test/TestExchangeInternals/TestExchangeInternals.sol new file mode 100644 index 000000000..923bac97d --- /dev/null +++ b/packages/contracts/src/2.0.0/test/TestExchangeInternals/TestExchangeInternals.sol @@ -0,0 +1,120 @@ +/* + + Copyright 2018 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity 0.4.24; +pragma experimental ABIEncoderV2; + +import "../../protocol/Exchange/Exchange.sol"; + + +contract TestExchangeInternals is + Exchange +{ + constructor () + public + Exchange("") + {} + + /// @dev Adds properties of both FillResults instances. + /// Modifies the first FillResults instance specified. + /// Note that this function has been modified from the original + // internal version to return the FillResults. + /// @param totalFillResults Fill results instance that will be added onto. + /// @param singleFillResults Fill results instance that will be added to totalFillResults. + /// @return newTotalFillResults The result of adding singleFillResults to totalFilResults. + function publicAddFillResults(FillResults memory totalFillResults, FillResults memory singleFillResults) + public + pure + returns (FillResults memory) + { + addFillResults(totalFillResults, singleFillResults); + return totalFillResults; + } + + /// @dev Calculates amounts filled and fees paid by maker and taker. + /// @param order to be filled. + /// @param takerAssetFilledAmount Amount of takerAsset that will be filled. + /// @return fillResults Amounts filled and fees paid by maker and taker. + function publicCalculateFillResults( + Order memory order, + uint256 takerAssetFilledAmount + ) + public + pure + returns (FillResults memory fillResults) + { + return calculateFillResults(order, takerAssetFilledAmount); + } + + /// @dev Calculates partial value given a numerator and denominator. + /// @param numerator Numerator. + /// @param denominator Denominator. + /// @param target Value to calculate partial of. + /// @return Partial value of target. + function publicGetPartialAmount( + uint256 numerator, + uint256 denominator, + uint256 target + ) + public + pure + returns (uint256 partialAmount) + { + return getPartialAmount(numerator, denominator, target); + } + + /// @dev Checks if rounding error > 0.1%. + /// @param numerator Numerator. + /// @param denominator Denominator. + /// @param target Value to multiply with numerator/denominator. + /// @return Rounding error is present. + function publicIsRoundingError( + uint256 numerator, + uint256 denominator, + uint256 target + ) + public + pure + returns (bool isError) + { + return isRoundingError(numerator, denominator, target); + } + + /// @dev Updates state with results of a fill order. + /// @param order that was filled. + /// @param takerAddress Address of taker who filled the order. + /// @param orderTakerAssetFilledAmount Amount of order already filled. + /// @return fillResults Amounts filled and fees paid by maker and taker. + function publicUpdateFilledState( + Order memory order, + address takerAddress, + bytes32 orderHash, + uint256 orderTakerAssetFilledAmount, + FillResults memory fillResults + ) + public + { + updateFilledState( + order, + takerAddress, + orderHash, + orderTakerAssetFilledAmount, + fillResults + ); + } +} diff --git a/packages/contracts/test/exchange/fill_order.ts b/packages/contracts/test/exchange/fill_order.ts index 029bd66e2..1494fe093 100644 --- a/packages/contracts/test/exchange/fill_order.ts +++ b/packages/contracts/test/exchange/fill_order.ts @@ -2,7 +2,10 @@ import { BlockchainLifecycle } from '@0xproject/dev-utils'; import * as _ from 'lodash'; import { chaiSetup } from '../utils/chai_setup'; -import { CoreCombinatorialUtils, coreCombinatorialUtilsFactoryAsync } from '../utils/core_combinatorial_utils'; +import { + FillOrderCombinatorialUtils, + fillOrderCombinatorialUtilsFactoryAsync, +} from '../utils/fill_order_combinatorial_utils'; import { AllowanceAmountScenario, AssetDataScenario, @@ -47,11 +50,11 @@ const defaultFillScenario = { }; describe('FillOrder Tests', () => { - let coreCombinatorialUtils: CoreCombinatorialUtils; + let fillOrderCombinatorialUtils: FillOrderCombinatorialUtils; before(async () => { await blockchainLifecycle.startAsync(); - coreCombinatorialUtils = await coreCombinatorialUtilsFactoryAsync(web3Wrapper, txDefaults); + fillOrderCombinatorialUtils = await fillOrderCombinatorialUtilsFactoryAsync(web3Wrapper, txDefaults); }); after(async () => { await blockchainLifecycle.revertAsync(); @@ -67,19 +70,19 @@ describe('FillOrder Tests', () => { _.forEach(fillScenarios, fillScenario => { const description = `Combinatorial OrderFill: ${JSON.stringify(fillScenario)}`; it(description, async () => { - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); }); }; - const allFillScenarios = CoreCombinatorialUtils.generateFillOrderCombinations(); + const allFillScenarios = FillOrderCombinatorialUtils.generateFillOrderCombinations(); describe('Combinatorially generated fills orders', () => test(allFillScenarios)); it('should transfer the correct amounts when makerAssetAmount === takerAssetAmount', async () => { const fillScenario = { ...defaultFillScenario, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should transfer the correct amounts when makerAssetAmount > takerAssetAmount', async () => { const fillScenario = { @@ -89,7 +92,7 @@ describe('FillOrder Tests', () => { takerAssetAmountScenario: OrderAssetAmountScenario.Small, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should transfer the correct amounts when makerAssetAmount < takerAssetAmount', async () => { const fillScenario = { @@ -99,7 +102,7 @@ describe('FillOrder Tests', () => { makerAssetAmountScenario: OrderAssetAmountScenario.Small, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should transfer the correct amounts when taker is specified and order is claimed by taker', async () => { const fillScenario = { @@ -109,14 +112,14 @@ describe('FillOrder Tests', () => { takerScenario: TakerScenario.CorrectlySpecified, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should fill remaining value if takerAssetFillAmount > remaining takerAssetAmount', async () => { const fillScenario = { ...defaultFillScenario, takerAssetFillAmountScenario: TakerAssetFillAmountScenario.GreaterThanRemainingFillableTakerAssetAmount, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw when taker is specified and order is claimed by other', async () => { const fillScenario = { @@ -126,7 +129,7 @@ describe('FillOrder Tests', () => { takerScenario: TakerScenario.IncorrectlySpecified, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if makerAssetAmount is 0', async () => { @@ -138,7 +141,7 @@ describe('FillOrder Tests', () => { }, takerAssetFillAmountScenario: TakerAssetFillAmountScenario.GreaterThanRemainingFillableTakerAssetAmount, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if takerAssetAmount is 0', async () => { @@ -150,7 +153,7 @@ describe('FillOrder Tests', () => { }, takerAssetFillAmountScenario: TakerAssetFillAmountScenario.GreaterThanRemainingFillableTakerAssetAmount, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if takerAssetFillAmount is 0', async () => { @@ -158,7 +161,7 @@ describe('FillOrder Tests', () => { ...defaultFillScenario, takerAssetFillAmountScenario: TakerAssetFillAmountScenario.Zero, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if an order is expired', async () => { @@ -169,7 +172,7 @@ describe('FillOrder Tests', () => { expirationTimeSecondsScenario: ExpirationTimeSecondsScenario.InPast, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if maker erc20Balances are too low to fill order', async () => { @@ -180,7 +183,7 @@ describe('FillOrder Tests', () => { traderAssetBalance: BalanceAmountScenario.TooLow, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if taker erc20Balances are too low to fill order', async () => { @@ -191,7 +194,7 @@ describe('FillOrder Tests', () => { traderAssetBalance: BalanceAmountScenario.TooLow, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if maker allowances are too low to fill order', async () => { @@ -202,7 +205,7 @@ describe('FillOrder Tests', () => { traderAssetAllowance: AllowanceAmountScenario.TooLow, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should throw if taker allowances are too low to fill order', async () => { @@ -213,7 +216,7 @@ describe('FillOrder Tests', () => { traderAssetAllowance: AllowanceAmountScenario.TooLow, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); }); @@ -228,7 +231,7 @@ describe('FillOrder Tests', () => { }, takerAssetFillAmountScenario: TakerAssetFillAmountScenario.ExactlyRemainingFillableTakerAssetAmount, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should successfully fill order when makerAsset is ERC721 and takerAsset is ERC20', async () => { @@ -241,7 +244,7 @@ describe('FillOrder Tests', () => { }, takerAssetFillAmountScenario: TakerAssetFillAmountScenario.ExactlyRemainingFillableTakerAssetAmount, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario, true); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario, true); }); it('should successfully fill order when makerAsset is ERC20 and takerAsset is ERC721', async () => { @@ -254,7 +257,7 @@ describe('FillOrder Tests', () => { }, takerAssetFillAmountScenario: TakerAssetFillAmountScenario.ExactlyRemainingFillableTakerAssetAmount, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should successfully fill order when makerAsset is ERC721 and approveAll is set for it', async () => { @@ -271,7 +274,7 @@ describe('FillOrder Tests', () => { traderAssetAllowance: AllowanceAmountScenario.Unlimited, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); it('should successfully fill order when makerAsset and takerAsset are ERC721 and approveAll is set for them', async () => { @@ -292,7 +295,7 @@ describe('FillOrder Tests', () => { traderAssetAllowance: AllowanceAmountScenario.Unlimited, }, }; - await coreCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); + await fillOrderCombinatorialUtils.testFillOrderScenarioAsync(provider, fillScenario); }); }); }); diff --git a/packages/contracts/test/exchange/internal.ts b/packages/contracts/test/exchange/internal.ts new file mode 100644 index 000000000..67d1d2d2c --- /dev/null +++ b/packages/contracts/test/exchange/internal.ts @@ -0,0 +1,305 @@ +import { BlockchainLifecycle } from '@0xproject/dev-utils'; +import { Order, RevertReason, SignedOrder } from '@0xproject/types'; +import { BigNumber } from '@0xproject/utils'; +import * as _ from 'lodash'; + +import { TestExchangeInternalsContract } from '../../generated_contract_wrappers/test_exchange_internals'; +import { artifacts } from '../utils/artifacts'; +import { + getInvalidOpcodeErrorMessageForCallAsync, + getRevertReasonOrErrorMessageForSendTransactionAsync, +} from '../utils/assertions'; +import { chaiSetup } from '../utils/chai_setup'; +import { bytes32Values, testCombinatoriallyWithReferenceFuncAsync, uint256Values } from '../utils/combinatorial_utils'; +import { constants } from '../utils/constants'; +import { FillResults } from '../utils/types'; +import { provider, txDefaults, web3Wrapper } from '../utils/web3_wrapper'; + +chaiSetup.configure(); +const blockchainLifecycle = new BlockchainLifecycle(web3Wrapper); + +const MAX_UINT256 = new BigNumber(2).pow(256).minus(1); + +const emptyOrder: Order = { + senderAddress: constants.NULL_ADDRESS, + makerAddress: constants.NULL_ADDRESS, + takerAddress: constants.NULL_ADDRESS, + makerFee: new BigNumber(0), + takerFee: new BigNumber(0), + makerAssetAmount: new BigNumber(0), + takerAssetAmount: new BigNumber(0), + makerAssetData: '0x', + takerAssetData: '0x', + salt: new BigNumber(0), + exchangeAddress: constants.NULL_ADDRESS, + feeRecipientAddress: constants.NULL_ADDRESS, + expirationTimeSeconds: new BigNumber(0), +}; + +const emptySignedOrder: SignedOrder = { + ...emptyOrder, + signature: '', +}; + +const overflowErrorForCall = new Error(RevertReason.Uint256Overflow); + +async function referenceGetPartialAmountAsync( + numerator: BigNumber, + denominator: BigNumber, + target: BigNumber, +): Promise<BigNumber> { + const invalidOpcodeErrorForCall = new Error(await getInvalidOpcodeErrorMessageForCallAsync()); + const product = numerator.mul(target); + if (product.greaterThan(MAX_UINT256)) { + throw overflowErrorForCall; + } + if (denominator.eq(0)) { + throw invalidOpcodeErrorForCall; + } + return product.dividedToIntegerBy(denominator); +} + +describe('Exchange core internal functions', () => { + let testExchange: TestExchangeInternalsContract; + let invalidOpcodeErrorForCall: Error | undefined; + let overflowErrorForSendTransaction: Error | undefined; + + before(async () => { + await blockchainLifecycle.startAsync(); + }); + after(async () => { + await blockchainLifecycle.revertAsync(); + }); + before(async () => { + testExchange = await TestExchangeInternalsContract.deployFrom0xArtifactAsync( + artifacts.TestExchangeInternals, + provider, + txDefaults, + ); + overflowErrorForSendTransaction = new Error( + await getRevertReasonOrErrorMessageForSendTransactionAsync(RevertReason.Uint256Overflow), + ); + invalidOpcodeErrorForCall = new Error(await getInvalidOpcodeErrorMessageForCallAsync()); + }); + // Note(albrow): Don't forget to add beforeEach and afterEach calls to reset + // the blockchain state for any tests which modify it! + + describe('addFillResults', async () => { + function makeFillResults(value: BigNumber): FillResults { + return { + makerAssetFilledAmount: value, + takerAssetFilledAmount: value, + makerFeePaid: value, + takerFeePaid: value, + }; + } + async function referenceAddFillResultsAsync( + totalValue: BigNumber, + singleValue: BigNumber, + ): Promise<FillResults> { + // Note(albrow): Here, each of totalFillResults and + // singleFillResults will consist of fields with the same values. + // This should be safe because none of the fields in a given + // FillResults are ever used together in a mathemetical operation. + // They are only used with the corresponding field from *the other* + // FillResults, which are different. + const totalFillResults = makeFillResults(totalValue); + const singleFillResults = makeFillResults(singleValue); + // HACK(albrow): _.mergeWith mutates the first argument! To + // workaround this we use _.cloneDeep. + return _.mergeWith( + _.cloneDeep(totalFillResults), + singleFillResults, + (totalVal: BigNumber, singleVal: BigNumber) => { + const newTotal = totalVal.add(singleVal); + if (newTotal.greaterThan(MAX_UINT256)) { + throw overflowErrorForCall; + } + return newTotal; + }, + ); + } + async function testAddFillResultsAsync(totalValue: BigNumber, singleValue: BigNumber): Promise<FillResults> { + const totalFillResults = makeFillResults(totalValue); + const singleFillResults = makeFillResults(singleValue); + return testExchange.publicAddFillResults.callAsync(totalFillResults, singleFillResults); + } + await testCombinatoriallyWithReferenceFuncAsync( + 'addFillResults', + referenceAddFillResultsAsync, + testAddFillResultsAsync, + [uint256Values, uint256Values], + ); + }); + + describe('calculateFillResults', async () => { + function makeOrder( + makerAssetAmount: BigNumber, + takerAssetAmount: BigNumber, + makerFee: BigNumber, + takerFee: BigNumber, + ): Order { + return { + ...emptyOrder, + makerAssetAmount, + takerAssetAmount, + makerFee, + takerFee, + }; + } + async function referenceCalculateFillResultsAsync( + orderTakerAssetAmount: BigNumber, + takerAssetFilledAmount: BigNumber, + otherAmount: BigNumber, + ): Promise<FillResults> { + // Note(albrow): Here we are re-using the same value (otherAmount) + // for order.makerAssetAmount, order.makerFee, and order.takerFee. + // This should be safe because they are never used with each other + // in any mathematical operation in either the reference TypeScript + // implementation or the Solidity implementation of + // calculateFillResults. + return { + makerAssetFilledAmount: await referenceGetPartialAmountAsync( + takerAssetFilledAmount, + orderTakerAssetAmount, + otherAmount, + ), + takerAssetFilledAmount, + makerFeePaid: await referenceGetPartialAmountAsync( + takerAssetFilledAmount, + orderTakerAssetAmount, + otherAmount, + ), + takerFeePaid: await referenceGetPartialAmountAsync( + takerAssetFilledAmount, + orderTakerAssetAmount, + otherAmount, + ), + }; + } + async function testCalculateFillResultsAsync( + orderTakerAssetAmount: BigNumber, + takerAssetFilledAmount: BigNumber, + otherAmount: BigNumber, + ): Promise<FillResults> { + const order = makeOrder(otherAmount, orderTakerAssetAmount, otherAmount, otherAmount); + return testExchange.publicCalculateFillResults.callAsync(order, takerAssetFilledAmount); + } + await testCombinatoriallyWithReferenceFuncAsync( + 'calculateFillResults', + referenceCalculateFillResultsAsync, + testCalculateFillResultsAsync, + [uint256Values, uint256Values, uint256Values], + ); + }); + + describe('getPartialAmount', async () => { + async function testGetPartialAmountAsync( + numerator: BigNumber, + denominator: BigNumber, + target: BigNumber, + ): Promise<BigNumber> { + return testExchange.publicGetPartialAmount.callAsync(numerator, denominator, target); + } + await testCombinatoriallyWithReferenceFuncAsync( + 'getPartialAmount', + referenceGetPartialAmountAsync, + testGetPartialAmountAsync, + [uint256Values, uint256Values, uint256Values], + ); + }); + + describe('isRoundingError', async () => { + async function referenceIsRoundingErrorAsync( + numerator: BigNumber, + denominator: BigNumber, + target: BigNumber, + ): Promise<boolean> { + const product = numerator.mul(target); + if (denominator.eq(0)) { + throw invalidOpcodeErrorForCall; + } + const remainder = product.mod(denominator); + if (remainder.eq(0)) { + return false; + } + if (product.greaterThan(MAX_UINT256)) { + throw overflowErrorForCall; + } + if (product.eq(0)) { + throw invalidOpcodeErrorForCall; + } + const remainderTimes1000000 = remainder.mul('1000000'); + if (remainderTimes1000000.greaterThan(MAX_UINT256)) { + throw overflowErrorForCall; + } + const errPercentageTimes1000000 = remainderTimes1000000.dividedToIntegerBy(product); + return errPercentageTimes1000000.greaterThan('1000'); + } + async function testIsRoundingErrorAsync( + numerator: BigNumber, + denominator: BigNumber, + target: BigNumber, + ): Promise<boolean> { + return testExchange.publicIsRoundingError.callAsync(numerator, denominator, target); + } + await testCombinatoriallyWithReferenceFuncAsync( + 'isRoundingError', + referenceIsRoundingErrorAsync, + testIsRoundingErrorAsync, + [uint256Values, uint256Values, uint256Values], + ); + }); + + describe('updateFilledState', async () => { + // Note(albrow): Since updateFilledState modifies the state by calling + // sendTransaction, we must reset the state after each test. + beforeEach(async () => { + await blockchainLifecycle.startAsync(); + }); + afterEach(async () => { + await blockchainLifecycle.revertAsync(); + }); + async function referenceUpdateFilledStateAsync( + takerAssetFilledAmount: BigNumber, + orderTakerAssetFilledAmount: BigNumber, + // tslint:disable-next-line:no-unused-variable + orderHash: string, + ): Promise<BigNumber> { + const totalFilledAmount = takerAssetFilledAmount.add(orderTakerAssetFilledAmount); + if (totalFilledAmount.greaterThan(MAX_UINT256)) { + throw overflowErrorForSendTransaction; + } + return totalFilledAmount; + } + async function testUpdateFilledStateAsync( + takerAssetFilledAmount: BigNumber, + orderTakerAssetFilledAmount: BigNumber, + orderHash: string, + ): Promise<BigNumber> { + const fillResults = { + makerAssetFilledAmount: new BigNumber(0), + takerAssetFilledAmount, + makerFeePaid: new BigNumber(0), + takerFeePaid: new BigNumber(0), + }; + await web3Wrapper.awaitTransactionSuccessAsync( + await testExchange.publicUpdateFilledState.sendTransactionAsync( + emptySignedOrder, + constants.NULL_ADDRESS, + orderHash, + orderTakerAssetFilledAmount, + fillResults, + ), + constants.AWAIT_TRANSACTION_MINED_MS, + ); + return testExchange.filled.callAsync(orderHash); + } + await testCombinatoriallyWithReferenceFuncAsync( + 'updateFilledState', + referenceUpdateFilledStateAsync, + testUpdateFilledStateAsync, + [uint256Values, uint256Values, bytes32Values], + ); + }); +}); diff --git a/packages/contracts/test/exchange/libs.ts b/packages/contracts/test/exchange/libs.ts index 51794d8a3..5c9f9aac7 100644 --- a/packages/contracts/test/exchange/libs.ts +++ b/packages/contracts/test/exchange/libs.ts @@ -67,6 +67,35 @@ describe('Exchange libs', () => { }); }); }); + // Note(albrow): These tests are designed to be supplemental to the + // combinatorial tests in test/exchange/internal. They test specific edge + // cases that are not covered by the combinatorial tests. + describe('LibMath', () => { + it('should return false if there is a rounding error of 0.1%', async () => { + const numerator = new BigNumber(20); + const denominator = new BigNumber(999); + const target = new BigNumber(50); + // rounding error = ((20*50/999) - floor(20*50/999)) / (20*50/999) = 0.1% + const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); + expect(isRoundingError).to.be.false(); + }); + it('should return false if there is a rounding of 0.09%', async () => { + const numerator = new BigNumber(20); + const denominator = new BigNumber(9991); + const target = new BigNumber(500); + // rounding error = ((20*500/9991) - floor(20*500/9991)) / (20*500/9991) = 0.09% + const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); + expect(isRoundingError).to.be.false(); + }); + it('should return true if there is a rounding error of 0.11%', async () => { + const numerator = new BigNumber(20); + const denominator = new BigNumber(9989); + const target = new BigNumber(500); + // rounding error = ((20*500/9989) - floor(20*500/9989)) / (20*500/9989) = 0.011% + const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); + expect(isRoundingError).to.be.true(); + }); + }); describe('LibOrder', () => { describe('getOrderSchema', () => { @@ -93,96 +122,4 @@ describe('Exchange libs', () => { }); }); }); - - describe('LibMath', () => { - describe('isRoundingError', () => { - it('should return false if there is a rounding error of 0.1%', async () => { - const numerator = new BigNumber(20); - const denominator = new BigNumber(999); - const target = new BigNumber(50); - // rounding error = ((20*50/999) - floor(20*50/999)) / (20*50/999) = 0.1% - const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); - expect(isRoundingError).to.be.false(); - }); - - it('should return false if there is a rounding of 0.09%', async () => { - const numerator = new BigNumber(20); - const denominator = new BigNumber(9991); - const target = new BigNumber(500); - // rounding error = ((20*500/9991) - floor(20*500/9991)) / (20*500/9991) = 0.09% - const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); - expect(isRoundingError).to.be.false(); - }); - - it('should return true if there is a rounding error of 0.11%', async () => { - const numerator = new BigNumber(20); - const denominator = new BigNumber(9989); - const target = new BigNumber(500); - // rounding error = ((20*500/9989) - floor(20*500/9989)) / (20*500/9989) = 0.011% - const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); - expect(isRoundingError).to.be.true(); - }); - - it('should return true if there is a rounding error > 0.1%', async () => { - const numerator = new BigNumber(3); - const denominator = new BigNumber(7); - const target = new BigNumber(10); - // rounding error = ((3*10/7) - floor(3*10/7)) / (3*10/7) = 6.67% - const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); - expect(isRoundingError).to.be.true(); - }); - - it('should return false when there is no rounding error', async () => { - const numerator = new BigNumber(1); - const denominator = new BigNumber(2); - const target = new BigNumber(10); - - const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); - expect(isRoundingError).to.be.false(); - }); - - it('should return false when there is rounding error <= 0.1%', async () => { - // randomly generated numbers - const numerator = new BigNumber(76564); - const denominator = new BigNumber(676373677); - const target = new BigNumber(105762562); - // rounding error = ((76564*105762562/676373677) - floor(76564*105762562/676373677)) / - // (76564*105762562/676373677) = 0.0007% - const isRoundingError = await libs.publicIsRoundingError.callAsync(numerator, denominator, target); - expect(isRoundingError).to.be.false(); - }); - }); - - describe('getPartialAmount', () => { - it('should return the numerator/denominator*target', async () => { - const numerator = new BigNumber(1); - const denominator = new BigNumber(2); - const target = new BigNumber(10); - - const partialAmount = await libs.publicGetPartialAmount.callAsync(numerator, denominator, target); - const expectedPartialAmount = 5; - expect(partialAmount).to.be.bignumber.equal(expectedPartialAmount); - }); - - it('should round down', async () => { - const numerator = new BigNumber(2); - const denominator = new BigNumber(3); - const target = new BigNumber(10); - - const partialAmount = await libs.publicGetPartialAmount.callAsync(numerator, denominator, target); - const expectedPartialAmount = 6; - expect(partialAmount).to.be.bignumber.equal(expectedPartialAmount); - }); - - it('should round .5 down', async () => { - const numerator = new BigNumber(1); - const denominator = new BigNumber(20); - const target = new BigNumber(10); - - const partialAmount = await libs.publicGetPartialAmount.callAsync(numerator, denominator, target); - const expectedPartialAmount = 0; - expect(partialAmount).to.be.bignumber.equal(expectedPartialAmount); - }); - }); - }); }); diff --git a/packages/contracts/test/utils/artifacts.ts b/packages/contracts/test/utils/artifacts.ts index 63bd555a4..e608ee174 100644 --- a/packages/contracts/test/utils/artifacts.ts +++ b/packages/contracts/test/utils/artifacts.ts @@ -16,6 +16,7 @@ import * as MultiSigWalletWithTimeLock from '../../artifacts/MultiSigWalletWithT import * as TestAssetProxyDispatcher from '../../artifacts/TestAssetProxyDispatcher.json'; import * as TestAssetProxyOwner from '../../artifacts/TestAssetProxyOwner.json'; import * as TestConstants from '../../artifacts/TestConstants.json'; +import * as TestExchangeInternals from '../../artifacts/TestExchangeInternals.json'; import * as TestLibBytes from '../../artifacts/TestLibBytes.json'; import * as TestLibs from '../../artifacts/TestLibs.json'; import * as TestSignatureValidator from '../../artifacts/TestSignatureValidator.json'; @@ -46,6 +47,7 @@ export const artifacts = { TestConstants: (TestConstants as any) as ContractArtifact, TestLibBytes: (TestLibBytes as any) as ContractArtifact, TestLibs: (TestLibs as any) as ContractArtifact, + TestExchangeInternals: (TestExchangeInternals as any) as ContractArtifact, TestSignatureValidator: (TestSignatureValidator as any) as ContractArtifact, Validator: (Validator as any) as ContractArtifact, Wallet: (Wallet as any) as ContractArtifact, diff --git a/packages/contracts/test/utils/assertions.ts b/packages/contracts/test/utils/assertions.ts index 112a470f6..61df800c8 100644 --- a/packages/contracts/test/utils/assertions.ts +++ b/packages/contracts/test/utils/assertions.ts @@ -15,6 +15,14 @@ let nodeType: NodeType | undefined; // resolve with either a transaction receipt or a transaction hash. export type sendTransactionResult = Promise<TransactionReceipt | TransactionReceiptWithDecodedLogs | string>; +/** + * Returns ganacheError if the backing Ethereum node is Ganache and gethError + * if it is Geth. + * @param ganacheError the error to be returned if the backing node is Ganache. + * @param gethError the error to be returned if the backing node is Geth. + * @returns either the given ganacheError or gethError depending on the backing + * node. + */ async function _getGanacheOrGethError(ganacheError: string, gethError: string): Promise<string> { if (_.isUndefined(nodeType)) { nodeType = await web3Wrapper.getNodeTypeAsync(); @@ -42,6 +50,25 @@ async function _getContractCallFailedErrorMessageAsync(): Promise<string> { } /** + * Returns the expected error message for an 'invalid opcode' resulting from a + * contract call. The exact error message depends on the backing Ethereum node. + */ +export async function getInvalidOpcodeErrorMessageForCallAsync(): Promise<string> { + return _getGanacheOrGethError('invalid opcode', 'Contract call failed'); +} + +/** + * Returns the expected error message for the given revert reason resulting from + * a sendTransaction call. The exact error message depends on the backing + * Ethereum node and whether it supports revert reasons. + * @param reason a specific revert reason. + * @returns the expected error message. + */ +export async function getRevertReasonOrErrorMessageForSendTransactionAsync(reason: RevertReason): Promise<string> { + return _getGanacheOrGethError(reason, 'always failing transaction'); +} + +/** * Rejects if the given Promise does not reject with an error indicating * insufficient funds. * @param p a promise resulting from a contract call or sendTransaction call. diff --git a/packages/contracts/test/utils/combinatorial_utils.ts b/packages/contracts/test/utils/combinatorial_utils.ts new file mode 100644 index 000000000..d72b41f8a --- /dev/null +++ b/packages/contracts/test/utils/combinatorial_utils.ts @@ -0,0 +1,113 @@ +import { BigNumber } from '@0xproject/utils'; +import * as combinatorics from 'js-combinatorics'; + +import { testWithReferenceFuncAsync } from './test_with_reference'; + +// A set of values corresponding to the uint256 type in Solidity. This set +// contains some notable edge cases, including some values which will overflow +// the uint256 type when used in different mathematical operations. +export const uint256Values = [ + new BigNumber(0), + new BigNumber(1), + new BigNumber(2), + // Non-trivial big number. + new BigNumber(2).pow(64), + // Max that does not overflow when squared. + new BigNumber(2).pow(128).minus(1), + // Min that does overflow when squared. + new BigNumber(2).pow(128), + // Max that does not overflow when doubled. + new BigNumber(2).pow(255).minus(1), + // Min that does overflow when doubled. + new BigNumber(2).pow(255), + // Max that does not overflow. + new BigNumber(2).pow(256).minus(1), +]; + +// A set of values corresponding to the bytes32 type in Solidity. +export const bytes32Values = [ + // Min + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000000000000000000000000000002', + // Non-trivial big number. + '0x000000000000f000000000000000000000000000000000000000000000000000', + // Max + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', +]; + +export async function testCombinatoriallyWithReferenceFuncAsync<P0, P1, R>( + name: string, + referenceFunc: (p0: P0, p1: P1) => Promise<R>, + testFunc: (p0: P0, p1: P1) => Promise<R>, + allValues: [P0[], P1[]], +): Promise<void>; +export async function testCombinatoriallyWithReferenceFuncAsync<P0, P1, P2, R>( + name: string, + referenceFunc: (p0: P0, p1: P1, p2: P2) => Promise<R>, + testFunc: (p0: P0, p1: P1, p2: P2) => Promise<R>, + allValues: [P0[], P1[], P2[]], +): Promise<void>; +export async function testCombinatoriallyWithReferenceFuncAsync<P0, P1, P2, P3, R>( + name: string, + referenceFunc: (p0: P0, p1: P1, p2: P2, p3: P3) => Promise<R>, + testFunc: (p0: P0, p1: P1, p2: P2, p3: P3) => Promise<R>, + allValues: [P0[], P1[], P2[], P3[]], +): Promise<void>; +export async function testCombinatoriallyWithReferenceFuncAsync<P0, P1, P2, P3, P4, R>( + name: string, + referenceFunc: (p0: P0, p1: P1, p2: P2, p3: P3, p4: P4) => Promise<R>, + testFunc: (p0: P0, p1: P1, p2: P2, p3: P3, p4: P4) => Promise<R>, + allValues: [P0[], P1[], P2[], P3[], P4[]], +): Promise<void>; + +/** + * Uses combinatorics to test the behavior of a test function by comparing it to + * the expected behavior (defined by a reference function) for a large number of + * possible input values. + * + * First generates test cases by taking the cartesian product of the given + * values. Each test case is a set of N values corresponding to the N arguments + * for the test func and the reference func. For each test case, first the + * reference function will be called to obtain an "expected result", or if the + * reference function throws/rejects, an "expected error". Next, the test + * function will be called to obtain an "actual result", or if the test function + * throws/rejects, an "actual error". Each test case passes if at least one of + * the following conditions is met: + * + * 1) Neither the reference function or the test function throw and the + * "expected result" equals the "actual result". + * + * 2) Both the reference function and the test function throw and the "actual + * error" message *contains* the "expected error" message. + * + * The first test case which does not meet one of these conditions will cause + * the entire test to fail and this function will throw/reject. + * + * @param referenceFuncAsync a reference function implemented in pure + * JavaScript/TypeScript which accepts N arguments and returns the "expected + * result" or "expected error" for a given test case. + * @param testFuncAsync a test function which, e.g., makes a call or sends a + * transaction to a contract. It accepts the same N arguments returns the + * "actual result" or "actual error" for a given test case. + * @param values an array of N arrays. Each inner array is a set of possible + * values which are passed into both the reference function and the test + * function. + * @return A Promise that resolves if the test passes and rejects if the test + * fails, according to the rules described above. + */ +export async function testCombinatoriallyWithReferenceFuncAsync( + name: string, + referenceFuncAsync: (...args: any[]) => Promise<any>, + testFuncAsync: (...args: any[]) => Promise<any>, + allValues: any[], +): Promise<void> { + const testCases = combinatorics.cartesianProduct(...allValues); + let counter = 0; + testCases.forEach(async testCase => { + counter += 1; + it(`${name} ${counter}/${testCases.length}`, async () => { + await testWithReferenceFuncAsync(referenceFuncAsync, testFuncAsync, testCase as any); + }); + }); +} diff --git a/packages/contracts/test/utils/core_combinatorial_utils.ts b/packages/contracts/test/utils/fill_order_combinatorial_utils.ts index 44a5199c0..bdd1e62a2 100644 --- a/packages/contracts/test/utils/core_combinatorial_utils.ts +++ b/packages/contracts/test/utils/fill_order_combinatorial_utils.ts @@ -46,16 +46,16 @@ chaiSetup.configure(); const expect = chai.expect; /** - * Instantiates a new instance of CoreCombinatorialUtils. Since this method has some + * Instantiates a new instance of FillOrderCombinatorialUtils. Since this method has some * required async setup, a factory method is required. * @param web3Wrapper Web3Wrapper instance * @param txDefaults Default Ethereum tx options - * @return CoreCombinatorialUtils instance + * @return FillOrderCombinatorialUtils instance */ -export async function coreCombinatorialUtilsFactoryAsync( +export async function fillOrderCombinatorialUtilsFactoryAsync( web3Wrapper: Web3Wrapper, txDefaults: Partial<TxData>, -): Promise<CoreCombinatorialUtils> { +): Promise<FillOrderCombinatorialUtils> { const accounts = await web3Wrapper.getAvailableAddressesAsync(); const userAddresses = _.slice(accounts, 0, 5); const [ownerAddress, makerAddress, takerAddress] = userAddresses; @@ -123,7 +123,7 @@ export async function coreCombinatorialUtilsFactoryAsync( exchangeContract.address, ); - const coreCombinatorialUtils = new CoreCombinatorialUtils( + const fillOrderCombinatorialUtils = new FillOrderCombinatorialUtils( orderFactory, ownerAddress, makerAddress, @@ -133,10 +133,10 @@ export async function coreCombinatorialUtilsFactoryAsync( exchangeWrapper, assetWrapper, ); - return coreCombinatorialUtils; + return fillOrderCombinatorialUtils; } -export class CoreCombinatorialUtils { +export class FillOrderCombinatorialUtils { public orderFactory: OrderFactoryFromScenario; public ownerAddress: string; public makerAddress: string; @@ -240,7 +240,7 @@ export class CoreCombinatorialUtils { // AllowanceAmountScenario.TooLow, // AllowanceAmountScenario.Unlimited, ]; - const fillScenarioArrays = CoreCombinatorialUtils._getAllCombinations([ + const fillScenarioArrays = FillOrderCombinatorialUtils._getAllCombinations([ takerScenarios, feeRecipientScenarios, makerAssetAmountScenario, @@ -309,7 +309,7 @@ export class CoreCombinatorialUtils { } else { const result = []; const restOfArrays = arrays.slice(1); - const allCombinationsOfRemaining = CoreCombinatorialUtils._getAllCombinations(restOfArrays); // recur with the rest of array + const allCombinationsOfRemaining = FillOrderCombinatorialUtils._getAllCombinations(restOfArrays); // recur with the rest of array // tslint:disable:prefer-for-of for (let i = 0; i < allCombinationsOfRemaining.length; i++) { for (let j = 0; j < arrays[0].length; j++) { diff --git a/packages/contracts/test/utils/test_with_reference.ts b/packages/contracts/test/utils/test_with_reference.ts new file mode 100644 index 000000000..599b1eed4 --- /dev/null +++ b/packages/contracts/test/utils/test_with_reference.ts @@ -0,0 +1,119 @@ +import * as chai from 'chai'; +import * as _ from 'lodash'; + +import { chaiSetup } from './chai_setup'; + +chaiSetup.configure(); +const expect = chai.expect; + +export async function testWithReferenceFuncAsync<P0, R>( + referenceFunc: (p0: P0) => Promise<R>, + testFunc: (p0: P0) => Promise<R>, + values: [P0], +): Promise<void>; +export async function testWithReferenceFuncAsync<P0, P1, R>( + referenceFunc: (p0: P0, p1: P1) => Promise<R>, + testFunc: (p0: P0, p1: P1) => Promise<R>, + values: [P0, P1], +): Promise<void>; +export async function testWithReferenceFuncAsync<P0, P1, P2, R>( + referenceFunc: (p0: P0, p1: P1, p2: P2) => Promise<R>, + testFunc: (p0: P0, p1: P1, p2: P2) => Promise<R>, + values: [P0, P1, P2], +): Promise<void>; +export async function testWithReferenceFuncAsync<P0, P1, P2, P3, R>( + referenceFunc: (p0: P0, p1: P1, p2: P2, p3: P3) => Promise<R>, + testFunc: (p0: P0, p1: P1, p2: P2, p3: P3) => Promise<R>, + values: [P0, P1, P2, P3], +): Promise<void>; +export async function testWithReferenceFuncAsync<P0, P1, P2, P3, P4, R>( + referenceFunc: (p0: P0, p1: P1, p2: P2, p3: P3, p4: P4) => Promise<R>, + testFunc: (p0: P0, p1: P1, p2: P2, p3: P3, p4: P4) => Promise<R>, + values: [P0, P1, P2, P3, P4], +): Promise<void>; + +/** + * Tests the behavior of a test function by comparing it to the expected + * behavior (defined by a reference function). + * + * First the reference function will be called to obtain an "expected result", + * or if the reference function throws/rejects, an "expected error". Next, the + * test function will be called to obtain an "actual result", or if the test + * function throws/rejects, an "actual error". The test passes if at least one + * of the following conditions is met: + * + * 1) Neither the reference function or the test function throw and the + * "expected result" equals the "actual result". + * + * 2) Both the reference function and the test function throw and the "actual + * error" message *contains* the "expected error" message. + * + * @param referenceFuncAsync a reference function implemented in pure + * JavaScript/TypeScript which accepts N arguments and returns the "expected + * result" or throws/rejects with the "expected error". + * @param testFuncAsync a test function which, e.g., makes a call or sends a + * transaction to a contract. It accepts the same N arguments returns the + * "actual result" or throws/rejects with the "actual error". + * @param values an array of N values, where each value corresponds in-order to + * an argument to both the test function and the reference function. + * @return A Promise that resolves if the test passes and rejects if the test + * fails, according to the rules described above. + */ +export async function testWithReferenceFuncAsync( + referenceFuncAsync: (...args: any[]) => Promise<any>, + testFuncAsync: (...args: any[]) => Promise<any>, + values: any[], +): Promise<void> { + let expectedResult: any; + let expectedErr: string | undefined; + try { + expectedResult = await referenceFuncAsync(...values); + } catch (e) { + expectedErr = e.message; + } + let actualResult: any | undefined; + try { + actualResult = await testFuncAsync(...values); + if (!_.isUndefined(expectedErr)) { + throw new Error( + `Expected error containing ${expectedErr} but got no error\n\tTest case: ${_getTestCaseString( + referenceFuncAsync, + values, + )}`, + ); + } + } catch (e) { + if (_.isUndefined(expectedErr)) { + throw new Error(`${e.message}\n\tTest case: ${_getTestCaseString(referenceFuncAsync, values)}`); + } else { + expect(e.message).to.contain( + expectedErr, + `${e.message}\n\tTest case: ${_getTestCaseString(referenceFuncAsync, values)}`, + ); + } + } + if (!_.isUndefined(actualResult) && !_.isUndefined(expectedResult)) { + expect(actualResult).to.deep.equal( + expectedResult, + `Test case: ${_getTestCaseString(referenceFuncAsync, values)}`, + ); + } +} + +function _getTestCaseString(referenceFuncAsync: (...args: any[]) => Promise<any>, values: any[]): string { + const paramNames = _getParameterNames(referenceFuncAsync); + return JSON.stringify(_.zipObject(paramNames, values)); +} + +// Source: https://stackoverflow.com/questions/1007981/how-to-get-function-parameter-names-values-dynamically +function _getParameterNames(func: (...args: any[]) => any): string[] { + return _.toString(func) + .replace(/[/][/].*$/gm, '') // strip single-line comments + .replace(/\s+/g, '') // strip white space + .replace(/[/][*][^/*]*[*][/]/g, '') // strip multi-line comments + .split('){', 1)[0] + .replace(/^[^(]*[(]/, '') // extract the parameters + .replace(/=[^,]+/g, '') // strip any ES6 defaults + .split(',') + .filter(Boolean); // split & filter [""] +} |