diff --git a/contracts/exchange-forwarder/package.json b/contracts/exchange-forwarder/package.json index 7a517399ea..ac635cdeac 100644 --- a/contracts/exchange-forwarder/package.json +++ b/contracts/exchange-forwarder/package.json @@ -14,12 +14,11 @@ "build:ts": "tsc -b", "build:ci": "yarn build", "pre_build": "run-s compile contracts:gen generate_contract_wrappers", - "test": "yarn run_mocha", + "test": "mocha --require source-map-support/register --require make-promises-safe '../integrations/lib/test/forwarder/**/*.js' --timeout 100000 --bail --exit", "rebuild_and_test": "run-s build test", "test:coverage": "SOLIDITY_COVERAGE=true run-s build run_mocha coverage:report:text coverage:report:lcov", "test:profiler": "SOLIDITY_PROFILER=true run-s build run_mocha profiler:report:html", "test:trace": "SOLIDITY_REVERT_TRACE=true run-s build run_mocha", - "run_mocha": "mocha --require source-map-support/register --require make-promises-safe 'lib/test/**/*.js' --timeout 100000 --bail --exit", "compile": "sol-compiler", "watch": "sol-compiler -w", "clean": "shx rm -rf lib generated-artifacts generated-wrappers", diff --git a/contracts/exchange-forwarder/src/index.ts b/contracts/exchange-forwarder/src/index.ts index ba813e7caf..d55f08ea2d 100644 --- a/contracts/exchange-forwarder/src/index.ts +++ b/contracts/exchange-forwarder/src/index.ts @@ -1,3 +1,2 @@ export * from './artifacts'; export * from './wrappers'; -export * from '../test/utils'; diff --git a/contracts/exchange-forwarder/test/forwarder.ts b/contracts/exchange-forwarder/test/forwarder.ts deleted file mode 100644 index 1e791ba39d..0000000000 --- a/contracts/exchange-forwarder/test/forwarder.ts +++ /dev/null @@ -1,715 +0,0 @@ -import { ERC20Wrapper, ERC721Wrapper } from '@0x/contracts-asset-proxy'; -import { artifacts as erc20Artifacts, DummyERC20TokenContract, WETH9Contract } from '@0x/contracts-erc20'; -import { DummyERC721TokenContract } from '@0x/contracts-erc721'; -import { - artifacts as exchangeArtifacts, - ExchangeContract, - ExchangeWrapper, - TestProtocolFeeCollectorContract, -} from '@0x/contracts-exchange'; -import { - blockchainTests, - constants, - ContractName, - expect, - getLatestBlockTimestampAsync, - OrderFactory, - sendTransactionResult, -} from '@0x/contracts-test-utils'; -import { assetDataUtils, ForwarderRevertErrors } from '@0x/order-utils'; -import { BigNumber } from '@0x/utils'; -import { Web3Wrapper } from '@0x/web3-wrapper'; - -import { artifacts, ForwarderContract, ForwarderTestFactory, ForwarderWrapper } from '../src'; - -const DECIMALS_DEFAULT = 18; - -blockchainTests(ContractName.Forwarder, env => { - let owner: string; - let makerAddress: string; - let takerAddress: string; - let orderFeeRecipientAddress: string; - let forwarderFeeRecipientAddress: string; - let defaultMakerAssetAddress: string; - - let weth: DummyERC20TokenContract; - let erc20Token: DummyERC20TokenContract; - let secondErc20Token: DummyERC20TokenContract; - let erc721Token: DummyERC721TokenContract; - let forwarderContract: ForwarderContract; - let wethContract: WETH9Contract; - let exchangeContract: ExchangeContract; - let protocolFeeCollector: TestProtocolFeeCollectorContract; - - let forwarderWrapper: ForwarderWrapper; - let exchangeWrapper: ExchangeWrapper; - let erc20Wrapper: ERC20Wrapper; - - let orderFactory: OrderFactory; - let forwarderTestFactory: ForwarderTestFactory; - - let chainId: number; - let wethAssetData: string; - let erc721MakerAssetIds: BigNumber[]; - - const GAS_PRICE = new BigNumber(env.txDefaults.gasPrice || constants.DEFAULT_GAS_PRICE); - const PROTOCOL_FEE_MULTIPLIER = new BigNumber(150); - const PROTOCOL_FEE = GAS_PRICE.times(PROTOCOL_FEE_MULTIPLIER); - - before(async () => { - // Set up addresses - const accounts = await env.getAccountAddressesAsync(); - const usedAddresses = ([ - owner, - makerAddress, - takerAddress, - orderFeeRecipientAddress, - forwarderFeeRecipientAddress, - ] = accounts); - - // Set up Exchange - chainId = await env.getChainIdAsync(); - exchangeContract = await ExchangeContract.deployFrom0xArtifactAsync( - exchangeArtifacts.Exchange, - env.provider, - env.txDefaults, - {}, - new BigNumber(chainId), - ); - exchangeWrapper = new ExchangeWrapper(exchangeContract); - - // Set up ERC20 - erc20Wrapper = new ERC20Wrapper(env.provider, usedAddresses, owner); - [erc20Token, secondErc20Token] = await erc20Wrapper.deployDummyTokensAsync(2, constants.DUMMY_TOKEN_DECIMALS); - const erc20Proxy = await erc20Wrapper.deployProxyAsync(); - await exchangeWrapper.registerAssetProxyAsync(erc20Proxy.address, owner); - await erc20Proxy.addAuthorizedAddress.sendTransactionAsync(exchangeContract.address, { - from: owner, - }); - - // Set up WETH - wethContract = await WETH9Contract.deployFrom0xArtifactAsync( - erc20Artifacts.WETH9, - env.provider, - env.txDefaults, - {}, - ); - weth = new DummyERC20TokenContract(wethContract.address, env.provider); - wethAssetData = assetDataUtils.encodeERC20AssetData(wethContract.address); - erc20Wrapper.addDummyTokenContract(weth); - await erc20Wrapper.setBalancesAndAllowancesAsync(); - - // Set up ERC721 - const erc721Wrapper = new ERC721Wrapper(env.provider, usedAddresses, owner); - [erc721Token] = await erc721Wrapper.deployDummyTokensAsync(); - const erc721Proxy = await erc721Wrapper.deployProxyAsync(); - await erc721Wrapper.setBalancesAndAllowancesAsync(); - const erc721Balances = await erc721Wrapper.getBalancesAsync(); - erc721MakerAssetIds = erc721Balances[makerAddress][erc721Token.address]; - await exchangeWrapper.registerAssetProxyAsync(erc721Proxy.address, owner); - await erc721Proxy.addAuthorizedAddress.sendTransactionAsync(exchangeContract.address, { - from: owner, - }); - - // Set up Protocol Fee Collector - protocolFeeCollector = await TestProtocolFeeCollectorContract.deployFrom0xArtifactAsync( - exchangeArtifacts.TestProtocolFeeCollector, - env.provider, - env.txDefaults, - {}, - wethContract.address, - ); - await exchangeContract.setProtocolFeeMultiplier.awaitTransactionSuccessAsync(PROTOCOL_FEE_MULTIPLIER); - await exchangeContract.setProtocolFeeCollectorAddress.awaitTransactionSuccessAsync( - protocolFeeCollector.address, - ); - erc20Wrapper.addTokenOwnerAddress(protocolFeeCollector.address); - - // Set defaults - defaultMakerAssetAddress = erc20Token.address; - const defaultTakerAssetAddress = wethContract.address; - const defaultOrderParams = { - makerAddress, - feeRecipientAddress: orderFeeRecipientAddress, - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(200, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(10, DECIMALS_DEFAULT), - makerFee: Web3Wrapper.toBaseUnitAmount(0, DECIMALS_DEFAULT), - takerFee: Web3Wrapper.toBaseUnitAmount(0, DECIMALS_DEFAULT), - makerAssetData: assetDataUtils.encodeERC20AssetData(defaultMakerAssetAddress), - takerAssetData: assetDataUtils.encodeERC20AssetData(defaultTakerAssetAddress), - makerFeeAssetData: assetDataUtils.encodeERC20AssetData(defaultMakerAssetAddress), - takerFeeAssetData: assetDataUtils.encodeERC20AssetData(defaultMakerAssetAddress), - exchangeAddress: exchangeContract.address, - chainId, - }; - - // Set up Forwarder - forwarderContract = await ForwarderContract.deployFrom0xArtifactAsync( - artifacts.Forwarder, - env.provider, - env.txDefaults, - {}, - exchangeContract.address, - wethAssetData, - ); - forwarderWrapper = new ForwarderWrapper(forwarderContract, env.provider); - await forwarderWrapper.approveMakerAssetProxyAsync(defaultOrderParams.makerAssetData, { - from: takerAddress, - }); - erc20Wrapper.addTokenOwnerAddress(forwarderContract.address); - - // Set up factories - const privateKey = constants.TESTRPC_PRIVATE_KEYS[accounts.indexOf(makerAddress)]; - orderFactory = new OrderFactory(privateKey, defaultOrderParams); - forwarderTestFactory = new ForwarderTestFactory( - exchangeWrapper, - forwarderWrapper, - erc20Wrapper, - forwarderContract.address, - makerAddress, - takerAddress, - protocolFeeCollector.address, - orderFeeRecipientAddress, - forwarderFeeRecipientAddress, - weth.address, - GAS_PRICE, - PROTOCOL_FEE_MULTIPLIER, - ); - }); - - blockchainTests.resets('constructor', () => { - it('should revert if assetProxy is unregistered', async () => { - const exchange = await ExchangeContract.deployFrom0xArtifactAsync( - exchangeArtifacts.Exchange, - env.provider, - env.txDefaults, - {}, - new BigNumber(chainId), - ); - - const deployForwarder = (ForwarderContract.deployFrom0xArtifactAsync( - artifacts.Forwarder, - env.provider, - env.txDefaults, - {}, - exchange.address, - wethAssetData, - ) as any) as sendTransactionResult; - - await expect(deployForwarder).to.revertWith(new ForwarderRevertErrors.UnregisteredAssetProxyError()); - }); - }); - blockchainTests.resets('marketSellOrdersWithEth without extra fees', () => { - it('should fill a single order without a taker fee', async () => { - const orderWithoutFee = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketSellTestAsync([orderWithoutFee], 0.78, [erc20Token]); - }); - it('should fill multiple orders without taker fees', async () => { - const firstOrder = await orderFactory.newSignedOrderAsync(); - const secondOrder = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(285, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(21, DECIMALS_DEFAULT), - }); - const orders = [firstOrder, secondOrder]; - await forwarderTestFactory.marketSellTestAsync(orders, 1.51, [erc20Token]); - }); - it('should fill a single order with a percentage fee', async () => { - const orderWithPercentageFee = await orderFactory.newSignedOrderAsync({ - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - takerFeeAssetData: assetDataUtils.encodeERC20AssetData(defaultMakerAssetAddress), - }); - await forwarderTestFactory.marketSellTestAsync([orderWithPercentageFee], 0.58, [erc20Token]); - }); - it('should fill multiple orders with percentage fees', async () => { - const firstOrder = await orderFactory.newSignedOrderAsync({ - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - takerFeeAssetData: assetDataUtils.encodeERC20AssetData(defaultMakerAssetAddress), - }); - const secondOrder = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(190, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(31, DECIMALS_DEFAULT), - takerFee: Web3Wrapper.toBaseUnitAmount(2, DECIMALS_DEFAULT), - takerFeeAssetData: assetDataUtils.encodeERC20AssetData(defaultMakerAssetAddress), - }); - const orders = [firstOrder, secondOrder]; - await forwarderTestFactory.marketSellTestAsync(orders, 1.34, [erc20Token]); - }); - it('should fail to fill an order with a percentage fee if the asset proxy is not yet approved', async () => { - const unapprovedAsset = assetDataUtils.encodeERC20AssetData(secondErc20Token.address); - const order = await orderFactory.newSignedOrderAsync({ - makerAssetData: unapprovedAsset, - takerFee: Web3Wrapper.toBaseUnitAmount(2, DECIMALS_DEFAULT), - takerFeeAssetData: unapprovedAsset, - }); - - const ethValue = order.takerAssetAmount; - const erc20Balances = await erc20Wrapper.getBalancesAsync(); - const takerEthBalanceBefore = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - - // Execute test case - const tx = await forwarderWrapper.marketSellOrdersWithEthAsync([order], { - value: ethValue, - from: takerAddress, - }); - - const takerEthBalanceAfter = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - const forwarderEthBalance = await env.web3Wrapper.getBalanceInWeiAsync(forwarderContract.address); - const newBalances = await erc20Wrapper.getBalancesAsync(); - const totalEthSpent = GAS_PRICE.times(tx.gasUsed); - - // Validate test case - expect(takerEthBalanceAfter).to.be.bignumber.equal(takerEthBalanceBefore.minus(totalEthSpent)); - expect(newBalances[makerAddress][defaultMakerAssetAddress]).to.be.bignumber.equal( - erc20Balances[makerAddress][defaultMakerAssetAddress], - ); - expect(newBalances[takerAddress][defaultMakerAssetAddress]).to.be.bignumber.equal( - erc20Balances[takerAddress][defaultMakerAssetAddress], - ); - expect(newBalances[makerAddress][weth.address]).to.be.bignumber.equal( - erc20Balances[makerAddress][weth.address], - ); - expect(newBalances[forwarderContract.address][weth.address]).to.be.bignumber.equal(constants.ZERO_AMOUNT); - expect(newBalances[forwarderContract.address][defaultMakerAssetAddress]).to.be.bignumber.equal( - constants.ZERO_AMOUNT, - ); - expect(forwarderEthBalance).to.be.bignumber.equal(constants.ZERO_AMOUNT); - }); - it('should fill a single order with a WETH fee', async () => { - const orderWithWethFee = await orderFactory.newSignedOrderAsync({ - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - takerFeeAssetData: wethAssetData, - }); - await forwarderTestFactory.marketSellTestAsync([orderWithWethFee], 0.13, [erc20Token]); - }); - it('should fill multiple orders with WETH fees', async () => { - const firstOrder = await orderFactory.newSignedOrderAsync({ - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - takerFeeAssetData: wethAssetData, - }); - const secondOrderWithWethFee = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(97, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(33, DECIMALS_DEFAULT), - takerFee: Web3Wrapper.toBaseUnitAmount(2, DECIMALS_DEFAULT), - takerFeeAssetData: wethAssetData, - }); - const orders = [firstOrder, secondOrderWithWethFee]; - await forwarderTestFactory.marketSellTestAsync(orders, 1.25, [erc20Token]); - }); - it('should refund remaining ETH if amount is greater than takerAssetAmount', async () => { - const order = await orderFactory.newSignedOrderAsync(); - const ethValue = order.takerAssetAmount.plus(PROTOCOL_FEE).plus(2); - const takerEthBalanceBefore = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - - const tx = await forwarderWrapper.marketSellOrdersWithEthAsync([order], { - value: ethValue, - from: takerAddress, - }); - const takerEthBalanceAfter = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - const totalEthSpent = order.takerAssetAmount.plus(PROTOCOL_FEE).plus(GAS_PRICE.times(tx.gasUsed)); - - expect(takerEthBalanceAfter).to.be.bignumber.equal(takerEthBalanceBefore.minus(totalEthSpent)); - }); - it('should fill orders with different makerAssetData', async () => { - const firstOrderMakerAssetData = assetDataUtils.encodeERC20AssetData(erc20Token.address); - const firstOrder = await orderFactory.newSignedOrderAsync({ - makerAssetData: firstOrderMakerAssetData, - }); - - const secondOrderMakerAssetData = assetDataUtils.encodeERC20AssetData(secondErc20Token.address); - const secondOrder = await orderFactory.newSignedOrderAsync({ - makerAssetData: secondOrderMakerAssetData, - }); - await forwarderWrapper.approveMakerAssetProxyAsync(secondOrderMakerAssetData, { from: takerAddress }); - - const orders = [firstOrder, secondOrder]; - await forwarderTestFactory.marketSellTestAsync(orders, 1.5, [erc20Token, secondErc20Token]); - }); - it('should fail to fill an order with a fee denominated in an asset other than makerAsset or WETH', async () => { - const makerAssetData = assetDataUtils.encodeERC20AssetData(erc20Token.address); - const takerFeeAssetData = assetDataUtils.encodeERC20AssetData(secondErc20Token.address); - - const order = await orderFactory.newSignedOrderAsync({ - makerAssetData, - takerFeeAssetData, - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - }); - - const revertError = new ForwarderRevertErrors.UnsupportedFeeError(takerFeeAssetData); - await forwarderTestFactory.marketSellTestAsync([order], 0.5, [erc20Token], { - revertError, - }); - }); - it('should fill a partially-filled order without a taker fee', async () => { - const order = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketSellTestAsync([order], 0.3, [erc20Token]); - await forwarderTestFactory.marketSellTestAsync([order], 0.8, [erc20Token]); - }); - it('should skip over an order with an invalid maker asset amount', async () => { - const unfillableOrder = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: constants.ZERO_AMOUNT, - }); - const fillableOrder = await orderFactory.newSignedOrderAsync(); - - await forwarderTestFactory.marketSellTestAsync([unfillableOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over an order with an invalid taker asset amount', async () => { - const unfillableOrder = await orderFactory.newSignedOrderAsync({ - takerAssetAmount: constants.ZERO_AMOUNT, - }); - const fillableOrder = await orderFactory.newSignedOrderAsync(); - - await forwarderTestFactory.marketSellTestAsync([unfillableOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over an expired order', async () => { - const currentTimestamp = await getLatestBlockTimestampAsync(); - const expiredOrder = await orderFactory.newSignedOrderAsync({ - expirationTimeSeconds: new BigNumber(currentTimestamp).minus(10), - }); - const fillableOrder = await orderFactory.newSignedOrderAsync(); - - await forwarderTestFactory.marketSellTestAsync([expiredOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over a fully filled order', async () => { - const fullyFilledOrder = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketSellTestAsync([fullyFilledOrder], 1, [erc20Token]); - - const fillableOrder = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketSellTestAsync([fullyFilledOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over a cancelled order', async () => { - const cancelledOrder = await orderFactory.newSignedOrderAsync(); - await exchangeWrapper.cancelOrderAsync(cancelledOrder, makerAddress); - - const fillableOrder = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketSellTestAsync([cancelledOrder, fillableOrder], 1.5, [erc20Token]); - }); - }); - blockchainTests.resets('marketSellOrdersWithEth with extra fees', () => { - it('should fill the order and send fee to feeRecipient', async () => { - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(157, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(36, DECIMALS_DEFAULT), - }); - await forwarderTestFactory.marketSellTestAsync([order], 0.67, [erc20Token], { - forwarderFeePercentage: new BigNumber(2), - }); - }); - it('should fail if the fee is set too high', async () => { - const order = await orderFactory.newSignedOrderAsync(); - const forwarderFeePercentage = new BigNumber(6); - const revertError = new ForwarderRevertErrors.FeePercentageTooLargeError( - ForwarderTestFactory.getPercentageOfValue(constants.PERCENTAGE_DENOMINATOR, forwarderFeePercentage), - ); - - await forwarderTestFactory.marketSellTestAsync([order], 0.5, [erc20Token], { - forwarderFeePercentage, - revertError, - }); - }); - }); - blockchainTests.resets('marketBuyOrdersWithEth without extra fees', () => { - it('should buy the exact amount of makerAsset in a single order', async () => { - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(131, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(20, DECIMALS_DEFAULT), - }); - await forwarderTestFactory.marketBuyTestAsync([order], 0.62, [erc20Token]); - }); - it('should buy the exact amount of makerAsset in multiple orders', async () => { - const firstOrder = await orderFactory.newSignedOrderAsync(); - const secondOrder = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(77, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(11, DECIMALS_DEFAULT), - }); - const orders = [firstOrder, secondOrder]; - await forwarderTestFactory.marketBuyTestAsync(orders, 1.96, [erc20Token]); - }); - it('should buy exactly makerAssetBuyAmount in orders with different makerAssetData', async () => { - const firstOrderMakerAssetData = assetDataUtils.encodeERC20AssetData(erc20Token.address); - const firstOrder = await orderFactory.newSignedOrderAsync({ - makerAssetData: firstOrderMakerAssetData, - }); - - const secondOrderMakerAssetData = assetDataUtils.encodeERC20AssetData(secondErc20Token.address); - const secondOrder = await orderFactory.newSignedOrderAsync({ - makerAssetData: secondOrderMakerAssetData, - }); - await forwarderWrapper.approveMakerAssetProxyAsync(secondOrderMakerAssetData, { from: takerAddress }); - - const orders = [firstOrder, secondOrder]; - await forwarderTestFactory.marketBuyTestAsync(orders, 1.5, [erc20Token, secondErc20Token]); - }); - it('should buy the exact amount of makerAsset and return excess ETH', async () => { - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(80, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(17, DECIMALS_DEFAULT), - }); - await forwarderTestFactory.marketBuyTestAsync([order], 0.57, [erc20Token], { - ethValueAdjustment: 2, - }); - }); - it('should buy the exact amount of makerAsset from a single order with a WETH fee', async () => { - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(79, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(16, DECIMALS_DEFAULT), - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - takerFeeAssetData: wethAssetData, - }); - await forwarderTestFactory.marketBuyTestAsync([order], 0.38, [erc20Token]); - }); - it('should buy the exact amount of makerAsset from a single order with a percentage fee', async () => { - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(80, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(17, DECIMALS_DEFAULT), - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - takerFeeAssetData: assetDataUtils.encodeERC20AssetData(defaultMakerAssetAddress), - }); - await forwarderTestFactory.marketBuyTestAsync([order], 0.52, [erc20Token]); - }); - it('should revert if the amount of ETH sent is too low to fill the makerAssetAmount', async () => { - const order = await orderFactory.newSignedOrderAsync(); - const revertError = new ForwarderRevertErrors.CompleteBuyFailedError( - order.makerAssetAmount.times(0.5), - constants.ZERO_AMOUNT, - ); - - await forwarderTestFactory.marketBuyTestAsync([order], 0.5, [erc20Token], { - ethValueAdjustment: -2, - revertError, - }); - }); - it('should buy an ERC721 asset from a single order', async () => { - const makerAssetId = erc721MakerAssetIds[0]; - const erc721Order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: new BigNumber(1), - makerAssetData: assetDataUtils.encodeERC721AssetData(erc721Token.address, makerAssetId), - takerFeeAssetData: wethAssetData, - }); - await forwarderTestFactory.marketBuyTestAsync([erc721Order], 1, [erc721Token], { - makerAssetId, - }); - }); - it('should buy an ERC721 asset and pay a WETH fee', async () => { - const makerAssetId = erc721MakerAssetIds[0]; - const erc721orderWithWethFee = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: new BigNumber(1), - makerAssetData: assetDataUtils.encodeERC721AssetData(erc721Token.address, makerAssetId), - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - takerFeeAssetData: wethAssetData, - }); - await forwarderTestFactory.marketBuyTestAsync([erc721orderWithWethFee], 1, [erc721Token], { - makerAssetId, - }); - }); - it('should fail to fill an order with a fee denominated in an asset other than makerAsset or WETH', async () => { - const makerAssetData = assetDataUtils.encodeERC20AssetData(erc20Token.address); - const takerFeeAssetData = assetDataUtils.encodeERC20AssetData(secondErc20Token.address); - - const order = await orderFactory.newSignedOrderAsync({ - makerAssetData, - takerFeeAssetData, - takerFee: Web3Wrapper.toBaseUnitAmount(1, DECIMALS_DEFAULT), - }); - - const revertError = new ForwarderRevertErrors.UnsupportedFeeError(takerFeeAssetData); - await forwarderTestFactory.marketBuyTestAsync([order], 0.5, [erc20Token], { - revertError, - }); - }); - it('should fill a partially-filled order without a taker fee', async () => { - const order = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketBuyTestAsync([order], 0.3, [erc20Token]); - await forwarderTestFactory.marketBuyTestAsync([order], 0.8, [erc20Token]); - }); - it('should skip over an order with an invalid maker asset amount', async () => { - const unfillableOrder = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: constants.ZERO_AMOUNT, - }); - const fillableOrder = await orderFactory.newSignedOrderAsync(); - - await forwarderTestFactory.marketBuyTestAsync([unfillableOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over an order with an invalid taker asset amount', async () => { - const unfillableOrder = await orderFactory.newSignedOrderAsync({ - takerAssetAmount: constants.ZERO_AMOUNT, - }); - const fillableOrder = await orderFactory.newSignedOrderAsync(); - - await forwarderTestFactory.marketBuyTestAsync([unfillableOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over an expired order', async () => { - const currentTimestamp = await getLatestBlockTimestampAsync(); - const expiredOrder = await orderFactory.newSignedOrderAsync({ - expirationTimeSeconds: new BigNumber(currentTimestamp).minus(10), - }); - const fillableOrder = await orderFactory.newSignedOrderAsync(); - - await forwarderTestFactory.marketBuyTestAsync([expiredOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over a fully filled order', async () => { - const fullyFilledOrder = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketBuyTestAsync([fullyFilledOrder], 1, [erc20Token]); - - const fillableOrder = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketBuyTestAsync([fullyFilledOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('should skip over a cancelled order', async () => { - const cancelledOrder = await orderFactory.newSignedOrderAsync(); - await exchangeWrapper.cancelOrderAsync(cancelledOrder, makerAddress); - - const fillableOrder = await orderFactory.newSignedOrderAsync(); - await forwarderTestFactory.marketBuyTestAsync([cancelledOrder, fillableOrder], 1.5, [erc20Token]); - }); - it('Should buy slightly greater makerAsset when exchange rate is rounded', async () => { - // The 0x Protocol contracts round the exchange rate in favor of the Maker. - // In this case, the taker must round up how much they're going to spend, which - // in turn increases the amount of MakerAsset being purchased. - // Example: - // The taker wants to buy 5 units of the MakerAsset at a rate of 3M/2T. - // For every 2 units of TakerAsset, the taker will receive 3 units of MakerAsset. - // To purchase 5 units, the taker must spend 10/3 = 3.33 units of TakerAssset. - // However, the Taker can only spend whole units. - // Spending floor(10/3) = 3 units will yield a profit of Floor(3*3/2) = Floor(4.5) = 4 units of MakerAsset. - // Spending ceil(10/3) = 4 units will yield a profit of Floor(4*3/2) = 6 units of MakerAsset. - // - // The forwarding contract will opt for the second option, which overbuys, to ensure the taker - // receives at least the amount of MakerAsset they requested. - // - // Construct test case using values from example above - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: new BigNumber('30'), - takerAssetAmount: new BigNumber('20'), - makerAssetData: assetDataUtils.encodeERC20AssetData(erc20Token.address), - takerAssetData: assetDataUtils.encodeERC20AssetData(weth.address), - makerFee: new BigNumber(0), - takerFee: new BigNumber(0), - }); - const desiredMakerAssetFillAmount = new BigNumber('5'); - const makerAssetFillAmount = new BigNumber('6'); - const primaryTakerAssetFillAmount = new BigNumber('4'); - const ethValue = primaryTakerAssetFillAmount.plus(PROTOCOL_FEE); - - const erc20Balances = await erc20Wrapper.getBalancesAsync(); - const takerEthBalanceBefore = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - - // Execute test case - const tx = await forwarderWrapper.marketBuyOrdersWithEthAsync([order], desiredMakerAssetFillAmount, { - value: ethValue, - from: takerAddress, - }); - // Fetch end balances and construct expected outputs - const takerEthBalanceAfter = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - const forwarderEthBalance = await env.web3Wrapper.getBalanceInWeiAsync(forwarderContract.address); - const newBalances = await erc20Wrapper.getBalancesAsync(); - const totalEthSpent = ethValue.plus(GAS_PRICE.times(tx.gasUsed)); - // Validate test case - expect(makerAssetFillAmount).to.be.bignumber.greaterThan(desiredMakerAssetFillAmount); - expect(takerEthBalanceAfter).to.be.bignumber.equal(takerEthBalanceBefore.minus(totalEthSpent)); - expect(newBalances[makerAddress][defaultMakerAssetAddress]).to.be.bignumber.equal( - erc20Balances[makerAddress][defaultMakerAssetAddress].minus(makerAssetFillAmount), - ); - expect(newBalances[takerAddress][defaultMakerAssetAddress]).to.be.bignumber.equal( - erc20Balances[takerAddress][defaultMakerAssetAddress].plus(makerAssetFillAmount), - ); - expect(newBalances[makerAddress][weth.address]).to.be.bignumber.equal( - erc20Balances[makerAddress][weth.address].plus(primaryTakerAssetFillAmount), - ); - expect(newBalances[forwarderContract.address][weth.address]).to.be.bignumber.equal(constants.ZERO_AMOUNT); - expect(newBalances[forwarderContract.address][defaultMakerAssetAddress]).to.be.bignumber.equal( - constants.ZERO_AMOUNT, - ); - expect(forwarderEthBalance).to.be.bignumber.equal(constants.ZERO_AMOUNT); - }); - it('Should buy slightly greater MakerAsset when exchange rate is rounded (Regression Test)', async () => { - // Disable protocol fees for regression test - await exchangeContract.setProtocolFeeCollectorAddress.awaitTransactionSuccessAsync(constants.NULL_ADDRESS); - // Order taken from a transaction on mainnet that failed due to a rounding error. - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: new BigNumber('268166666666666666666'), - takerAssetAmount: new BigNumber('219090625878836371'), - makerAssetData: assetDataUtils.encodeERC20AssetData(erc20Token.address), - takerAssetData: assetDataUtils.encodeERC20AssetData(weth.address), - makerFee: new BigNumber(0), - takerFee: new BigNumber(0), - }); - - const erc20Balances = await erc20Wrapper.getBalancesAsync(); - const takerEthBalanceBefore = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - - // The taker will receive more than the desired amount of makerAsset due to rounding - const desiredMakerAssetFillAmount = new BigNumber('5000000000000000000'); - const ethValue = new BigNumber('4084971271824171'); - const makerAssetFillAmount = ethValue - .times(order.makerAssetAmount) - .dividedToIntegerBy(order.takerAssetAmount); - // Execute test case - const tx = await forwarderWrapper.marketBuyOrdersWithEthAsync([order], desiredMakerAssetFillAmount, { - value: ethValue, - from: takerAddress, - }); - // Fetch end balances and construct expected outputs - const takerEthBalanceAfter = await env.web3Wrapper.getBalanceInWeiAsync(takerAddress); - const forwarderEthBalance = await env.web3Wrapper.getBalanceInWeiAsync(forwarderContract.address); - const newBalances = await erc20Wrapper.getBalancesAsync(); - const primaryTakerAssetFillAmount = ethValue; - const totalEthSpent = primaryTakerAssetFillAmount.plus(GAS_PRICE.times(tx.gasUsed)); - // Validate test case - expect(makerAssetFillAmount).to.be.bignumber.greaterThan(desiredMakerAssetFillAmount); - expect(takerEthBalanceAfter).to.be.bignumber.equal(takerEthBalanceBefore.minus(totalEthSpent)); - expect(newBalances[makerAddress][defaultMakerAssetAddress]).to.be.bignumber.equal( - erc20Balances[makerAddress][defaultMakerAssetAddress].minus(makerAssetFillAmount), - ); - expect(newBalances[takerAddress][defaultMakerAssetAddress]).to.be.bignumber.equal( - erc20Balances[takerAddress][defaultMakerAssetAddress].plus(makerAssetFillAmount), - ); - expect(newBalances[makerAddress][weth.address]).to.be.bignumber.equal( - erc20Balances[makerAddress][weth.address].plus(primaryTakerAssetFillAmount), - ); - expect(newBalances[forwarderContract.address][weth.address]).to.be.bignumber.equal(constants.ZERO_AMOUNT); - expect(newBalances[forwarderContract.address][defaultMakerAssetAddress]).to.be.bignumber.equal( - constants.ZERO_AMOUNT, - ); - expect(forwarderEthBalance).to.be.bignumber.equal(constants.ZERO_AMOUNT); - }); - }); - blockchainTests.resets('marketBuyOrdersWithEth with extra fees', () => { - it('should buy the asset and send fee to feeRecipient', async () => { - const order = await orderFactory.newSignedOrderAsync({ - makerAssetAmount: Web3Wrapper.toBaseUnitAmount(125, DECIMALS_DEFAULT), - takerAssetAmount: Web3Wrapper.toBaseUnitAmount(11, DECIMALS_DEFAULT), - }); - await forwarderTestFactory.marketBuyTestAsync([order], 0.33, [erc20Token], { - forwarderFeePercentage: new BigNumber(2), - }); - }); - it('should fail if the fee is set too high', async () => { - const order = await orderFactory.newSignedOrderAsync(); - const revertError = new ForwarderRevertErrors.FeePercentageTooLargeError( - ForwarderTestFactory.getPercentageOfValue(constants.PERCENTAGE_DENOMINATOR, new BigNumber(6)), - ); - await forwarderTestFactory.marketBuyTestAsync([order], 0.5, [erc20Token], { - forwarderFeePercentage: new BigNumber(6), - revertError, - }); - }); - it('should fail if there is not enough ETH remaining to pay the fee', async () => { - const order = await orderFactory.newSignedOrderAsync(); - const forwarderFeePercentage = new BigNumber(2); - const ethFee = ForwarderTestFactory.getPercentageOfValue( - order.takerAssetAmount.times(0.5).plus(PROTOCOL_FEE), - forwarderFeePercentage, - ); - - const revertError = new ForwarderRevertErrors.InsufficientEthForFeeError(ethFee, ethFee.minus(1)); - - // -2 to compensate for the extra 1 wei added in ForwarderTestFactory to account for rounding - await forwarderTestFactory.marketBuyTestAsync([order], 0.5, [erc20Token], { - ethValueAdjustment: -2, - forwarderFeePercentage, - revertError, - }); - }); - }); -}); -// tslint:disable:max-file-line-count -// tslint:enable:no-unnecessary-type-assertion diff --git a/contracts/exchange-forwarder/test/global_hooks.ts b/contracts/exchange-forwarder/test/global_hooks.ts deleted file mode 100644 index 2ca47d433b..0000000000 --- a/contracts/exchange-forwarder/test/global_hooks.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { env, EnvVars } from '@0x/dev-utils'; - -import { coverage, profiler, provider } from '@0x/contracts-test-utils'; -import { providerUtils } from '@0x/utils'; - -before('start web3 provider', () => { - providerUtils.startProviderEngine(provider); -}); -after('generate coverage report', async () => { - if (env.parseBoolean(EnvVars.SolidityCoverage)) { - const coverageSubprovider = coverage.getCoverageSubproviderSingleton(); - await coverageSubprovider.writeCoverageAsync(); - } - if (env.parseBoolean(EnvVars.SolidityProfiler)) { - const profilerSubprovider = profiler.getProfilerSubproviderSingleton(); - await profilerSubprovider.writeProfilerOutputAsync(); - } - provider.stop(); -}); diff --git a/contracts/exchange-forwarder/test/utils/forwarder_test_factory.ts b/contracts/exchange-forwarder/test/utils/forwarder_test_factory.ts deleted file mode 100644 index 5c64df817c..0000000000 --- a/contracts/exchange-forwarder/test/utils/forwarder_test_factory.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { ERC20Wrapper } from '@0x/contracts-asset-proxy'; -import { DummyERC20TokenContract } from '@0x/contracts-erc20'; -import { DummyERC721TokenContract } from '@0x/contracts-erc721'; -import { ExchangeWrapper } from '@0x/contracts-exchange'; -import { constants, ERC20BalancesByOwner, expect, OrderStatus, web3Wrapper } from '@0x/contracts-test-utils'; -import { assetDataUtils } from '@0x/order-utils'; -import { OrderInfo, SignedOrder } from '@0x/types'; -import { BigNumber, RevertError } from '@0x/utils'; -import * as _ from 'lodash'; - -import { ForwarderWrapper } from './forwarder_wrapper'; - -// Necessary bookkeeping to validate Forwarder results -interface ForwarderFillState { - takerAssetFillAmount: BigNumber; - makerAssetFillAmount: { - [makerAssetData: string]: BigNumber; - }; - protocolFees: BigNumber; - wethFees: BigNumber; - percentageFees: { - [makerAssetData: string]: BigNumber; - }; - maxOversoldWeth: BigNumber; - maxOverboughtMakerAsset: BigNumber; -} - -// Since bignumber is not compatible with chai's within -function expectBalanceWithin(balance: BigNumber, low: BigNumber, high: BigNumber, message?: string): void { - expect(balance, message).to.be.bignumber.gte(low); - expect(balance, message).to.be.bignumber.lte(high); -} - -export class ForwarderTestFactory { - public static getPercentageOfValue(value: BigNumber, percentage: BigNumber): BigNumber { - const numerator = constants.PERCENTAGE_DENOMINATOR.times(percentage).dividedToIntegerBy(100); - const newValue = value.times(numerator).dividedToIntegerBy(constants.PERCENTAGE_DENOMINATOR); - return newValue; - } - - constructor( - private readonly _exchangeWrapper: ExchangeWrapper, - private readonly _forwarderWrapper: ForwarderWrapper, - private readonly _erc20Wrapper: ERC20Wrapper, - private readonly _forwarderAddress: string, - private readonly _makerAddress: string, - private readonly _takerAddress: string, - private readonly _protocolFeeCollectorAddress: string, - private readonly _orderFeeRecipientAddress: string, - private readonly _forwarderFeeRecipientAddress: string, - private readonly _wethAddress: string, - private readonly _gasPrice: BigNumber, - private readonly _protocolFeeMultiplier: BigNumber, - ) {} - - public async marketBuyTestAsync( - orders: SignedOrder[], - fractionalNumberOfOrdersToFill: number, - makerAssetContracts: Array, - options: { - ethValueAdjustment?: number; // Used to provided insufficient/excess ETH - forwarderFeePercentage?: BigNumber; - makerAssetId?: BigNumber; - revertError?: RevertError; - } = {}, - ): Promise { - const ethValueAdjustment = options.ethValueAdjustment || 0; - const forwarderFeePercentage = options.forwarderFeePercentage || constants.ZERO_AMOUNT; - - const erc20Balances = await this._erc20Wrapper.getBalancesAsync(); - const takerEthBalanceBefore = await web3Wrapper.getBalanceInWeiAsync(this._takerAddress); - const forwarderFeeRecipientEthBalanceBefore = await web3Wrapper.getBalanceInWeiAsync( - this._forwarderFeeRecipientAddress, - ); - - const ordersInfoBefore = await Promise.all(orders.map(order => this._exchangeWrapper.getOrderInfoAsync(order))); - const orderStatusesBefore = ordersInfoBefore.map(orderInfo => orderInfo.orderStatus); - - const expectedResults = this._computeExpectedResults(orders, ordersInfoBefore, fractionalNumberOfOrdersToFill); - const wethSpent = expectedResults.takerAssetFillAmount - .plus(expectedResults.protocolFees) - .plus(expectedResults.wethFees) - .plus(expectedResults.maxOversoldWeth); - const ethSpentOnForwarderFee = ForwarderTestFactory.getPercentageOfValue(wethSpent, forwarderFeePercentage); - const ethValue = wethSpent.plus(ethSpentOnForwarderFee).plus(ethValueAdjustment); - - const feePercentage = ForwarderTestFactory.getPercentageOfValue( - constants.PERCENTAGE_DENOMINATOR, - forwarderFeePercentage, - ); - - const totalMakerAssetFillAmount = Object.values(expectedResults.makerAssetFillAmount).reduce((prev, current) => - prev.plus(current), - ); - const totalPercentageFees = Object.values(expectedResults.percentageFees).reduce((prev, current) => - prev.plus(current), - ); - const tx = this._forwarderWrapper.marketBuyOrdersWithEthAsync( - orders, - totalMakerAssetFillAmount.minus(totalPercentageFees), - { - value: ethValue, - from: this._takerAddress, - }, - { feePercentage, feeRecipient: this._forwarderFeeRecipientAddress }, - ); - - if (options.revertError !== undefined) { - await expect(tx).to.revertWith(options.revertError); - } else { - const gasUsed = (await tx).gasUsed; - const ordersInfoAfter = await Promise.all( - orders.map(order => this._exchangeWrapper.getOrderInfoAsync(order)), - ); - const orderStatusesAfter = ordersInfoAfter.map(orderInfo => orderInfo.orderStatus); - - await this._checkResultsAsync( - fractionalNumberOfOrdersToFill, - orderStatusesBefore, - orderStatusesAfter, - gasUsed, - expectedResults, - takerEthBalanceBefore, - erc20Balances, - makerAssetContracts, - { - forwarderFeePercentage, - forwarderFeeRecipientEthBalanceBefore, - makerAssetId: options.makerAssetId, - }, - ); - } - } - - public async marketSellTestAsync( - orders: SignedOrder[], - fractionalNumberOfOrdersToFill: number, - makerAssetContracts: DummyERC20TokenContract[], - options: { - forwarderFeePercentage?: BigNumber; - revertError?: RevertError; - } = {}, - ): Promise { - const forwarderFeePercentage = options.forwarderFeePercentage || constants.ZERO_AMOUNT; - - const erc20Balances = await this._erc20Wrapper.getBalancesAsync(); - const takerEthBalanceBefore = await web3Wrapper.getBalanceInWeiAsync(this._takerAddress); - const forwarderFeeRecipientEthBalanceBefore = await web3Wrapper.getBalanceInWeiAsync( - this._forwarderFeeRecipientAddress, - ); - - const ordersInfoBefore = await Promise.all(orders.map(order => this._exchangeWrapper.getOrderInfoAsync(order))); - const orderStatusesBefore = ordersInfoBefore.map(orderInfo => orderInfo.orderStatus); - - const expectedResults = this._computeExpectedResults(orders, ordersInfoBefore, fractionalNumberOfOrdersToFill); - const wethSpent = expectedResults.takerAssetFillAmount - .plus(expectedResults.protocolFees) - .plus(expectedResults.wethFees) - .plus(expectedResults.maxOversoldWeth); - - const ethSpentOnForwarderFee = ForwarderTestFactory.getPercentageOfValue(wethSpent, forwarderFeePercentage); - const ethValue = wethSpent.plus(ethSpentOnForwarderFee); - - const feePercentage = ForwarderTestFactory.getPercentageOfValue( - constants.PERCENTAGE_DENOMINATOR, - forwarderFeePercentage, - ); - - const tx = this._forwarderWrapper.marketSellOrdersWithEthAsync( - orders, - { - value: ethValue, - from: this._takerAddress, - }, - { feePercentage, feeRecipient: this._forwarderFeeRecipientAddress }, - ); - - if (options.revertError !== undefined) { - await expect(tx).to.revertWith(options.revertError); - } else { - const gasUsed = (await tx).gasUsed; - const orderStatusesAfter = await Promise.all( - orders.map(async order => (await this._exchangeWrapper.getOrderInfoAsync(order)).orderStatus), - ); - - await this._checkResultsAsync( - fractionalNumberOfOrdersToFill, - orderStatusesBefore, - orderStatusesAfter, - gasUsed, - expectedResults, - takerEthBalanceBefore, - erc20Balances, - makerAssetContracts, - { - forwarderFeePercentage, - forwarderFeeRecipientEthBalanceBefore, - }, - ); - } - } - - private _checkErc20Balances( - oldBalances: ERC20BalancesByOwner, - newBalances: ERC20BalancesByOwner, - expectedResults: ForwarderFillState, - makerAssetContract: DummyERC20TokenContract, - ): void { - const makerAssetAddress = makerAssetContract.address; - const makerAssetData = assetDataUtils.encodeERC20AssetData(makerAssetAddress); - - const { - maxOverboughtMakerAsset, - makerAssetFillAmount: { [makerAssetData]: makerAssetFillAmount }, - percentageFees: { [makerAssetData]: percentageFees }, - } = expectedResults; - - expectBalanceWithin( - newBalances[this._makerAddress][makerAssetAddress], - oldBalances[this._makerAddress][makerAssetAddress] - .minus(makerAssetFillAmount) - .minus(maxOverboughtMakerAsset), - oldBalances[this._makerAddress][makerAssetAddress].minus(makerAssetFillAmount), - 'Maker makerAsset balance', - ); - expectBalanceWithin( - newBalances[this._takerAddress][makerAssetAddress], - oldBalances[this._takerAddress][makerAssetAddress].plus(makerAssetFillAmount).minus(percentageFees), - oldBalances[this._takerAddress][makerAssetAddress] - .plus(makerAssetFillAmount) - .minus(percentageFees) - .plus(maxOverboughtMakerAsset), - 'Taker makerAsset balance', - ); - expect( - newBalances[this._orderFeeRecipientAddress][makerAssetAddress], - 'Order fee recipient makerAsset balance', - ).to.be.bignumber.equal(oldBalances[this._orderFeeRecipientAddress][makerAssetAddress].plus(percentageFees)); - expect( - newBalances[this._forwarderAddress][makerAssetAddress], - 'Forwarder contract makerAsset balance', - ).to.be.bignumber.equal(constants.ZERO_AMOUNT); - } - - private async _checkResultsAsync( - fractionalNumberOfOrdersToFill: number, - orderStatusesBefore: OrderStatus[], - orderStatusesAfter: OrderStatus[], - gasUsed: number, - expectedResults: ForwarderFillState, - takerEthBalanceBefore: BigNumber, - erc20Balances: ERC20BalancesByOwner, - makerAssetContracts: Array, - options: { - forwarderFeePercentage?: BigNumber; - forwarderFeeRecipientEthBalanceBefore?: BigNumber; - makerAssetId?: BigNumber; - } = {}, - ): Promise { - for (const [i, orderStatus] of orderStatusesAfter.entries()) { - let expectedOrderStatus = orderStatusesBefore[i]; - if (fractionalNumberOfOrdersToFill >= i + 1 && orderStatusesBefore[i] === OrderStatus.Fillable) { - expectedOrderStatus = OrderStatus.FullyFilled; - } - expect(orderStatus, ` Order ${i} status`).to.equal(expectedOrderStatus); - } - - const wethSpent = expectedResults.takerAssetFillAmount - .plus(expectedResults.protocolFees) - .plus(expectedResults.wethFees); - const ethSpentOnForwarderFee = ForwarderTestFactory.getPercentageOfValue( - wethSpent, - options.forwarderFeePercentage || constants.ZERO_AMOUNT, - ); - const totalEthSpent = wethSpent.plus(ethSpentOnForwarderFee).plus(this._gasPrice.times(gasUsed)); - - const takerEthBalanceAfter = await web3Wrapper.getBalanceInWeiAsync(this._takerAddress); - const forwarderEthBalance = await web3Wrapper.getBalanceInWeiAsync(this._forwarderAddress); - const newBalances = await this._erc20Wrapper.getBalancesAsync(); - - expectBalanceWithin( - takerEthBalanceAfter, - takerEthBalanceBefore.minus(totalEthSpent).minus(expectedResults.maxOversoldWeth), - takerEthBalanceBefore.minus(totalEthSpent), - 'Taker ETH balance', - ); - if (options.forwarderFeeRecipientEthBalanceBefore !== undefined) { - const fowarderFeeRecipientEthBalanceAfter = await web3Wrapper.getBalanceInWeiAsync( - this._forwarderFeeRecipientAddress, - ); - expect(fowarderFeeRecipientEthBalanceAfter, 'Forwarder fee recipient ETH balance').to.be.bignumber.equal( - options.forwarderFeeRecipientEthBalanceBefore.plus(ethSpentOnForwarderFee), - ); - } - - for (const makerAssetContract of makerAssetContracts) { - if (makerAssetContract instanceof DummyERC20TokenContract) { - this._checkErc20Balances(erc20Balances, newBalances, expectedResults, makerAssetContract); - } else if (options.makerAssetId !== undefined) { - const newOwner = await makerAssetContract.ownerOf.callAsync(options.makerAssetId); - expect(newOwner, 'New ERC721 owner').to.be.bignumber.equal(this._takerAddress); - } - } - - expectBalanceWithin( - newBalances[this._makerAddress][this._wethAddress], - erc20Balances[this._makerAddress][this._wethAddress].plus(expectedResults.takerAssetFillAmount), - erc20Balances[this._makerAddress][this._wethAddress] - .plus(expectedResults.takerAssetFillAmount) - .plus(expectedResults.maxOversoldWeth), - 'Maker WETH balance', - ); - expect( - newBalances[this._orderFeeRecipientAddress][this._wethAddress], - 'Order fee recipient WETH balance', - ).to.be.bignumber.equal( - erc20Balances[this._orderFeeRecipientAddress][this._wethAddress].plus(expectedResults.wethFees), - ); - expect( - newBalances[this._forwarderAddress][this._wethAddress], - 'Forwarder contract WETH balance', - ).to.be.bignumber.equal(constants.ZERO_AMOUNT); - expect(forwarderEthBalance).to.be.bignumber.equal(constants.ZERO_AMOUNT); - } - - // Simulates filling some orders via the Forwarder contract. For example, if - // orders = [A, B, C, D] and fractionalNumberOfOrdersToFill = 2.3, then - // we simulate A and B being completely filled, and 0.3 * C being filled. - private _computeExpectedResults( - orders: SignedOrder[], - ordersInfoBefore: OrderInfo[], - fractionalNumberOfOrdersToFill: number, - ): ForwarderFillState { - const currentState: ForwarderFillState = { - takerAssetFillAmount: constants.ZERO_AMOUNT, - makerAssetFillAmount: {}, - protocolFees: constants.ZERO_AMOUNT, - wethFees: constants.ZERO_AMOUNT, - percentageFees: {}, - maxOversoldWeth: constants.ZERO_AMOUNT, - maxOverboughtMakerAsset: constants.ZERO_AMOUNT, - }; - let remainingOrdersToFill = fractionalNumberOfOrdersToFill; - - for (const [i, order] of orders.entries()) { - if (currentState.makerAssetFillAmount[order.makerAssetData] === undefined) { - currentState.makerAssetFillAmount[order.makerAssetData] = new BigNumber(0); - } - if (currentState.percentageFees[order.makerAssetData] === undefined) { - currentState.percentageFees[order.makerAssetData] = new BigNumber(0); - } - - if (remainingOrdersToFill === 0) { - break; - } - - if (ordersInfoBefore[i].orderStatus !== OrderStatus.Fillable) { - // If the order is not fillable, skip over it but still count it towards fractionalNumberOfOrdersToFill - remainingOrdersToFill = Math.max(remainingOrdersToFill - 1, 0); - continue; - } - - let makerAssetAmount; - let takerAssetAmount; - let takerFee; - if (remainingOrdersToFill < 1) { - makerAssetAmount = order.makerAssetAmount.times(remainingOrdersToFill).integerValue(); - takerAssetAmount = order.takerAssetAmount.times(remainingOrdersToFill).integerValue(); - takerFee = order.takerFee.times(remainingOrdersToFill).integerValue(); - - // Up to 1 wei worth of WETH will be oversold on the last order due to rounding - currentState.maxOversoldWeth = new BigNumber(1); - // Equivalently, up to 1 wei worth of maker asset will be overbought - currentState.maxOverboughtMakerAsset = currentState.maxOversoldWeth - .times(order.makerAssetAmount) - .dividedToIntegerBy(order.takerAssetAmount); - } else { - makerAssetAmount = order.makerAssetAmount; - takerAssetAmount = order.takerAssetAmount; - takerFee = order.takerFee; - } - - // Accounting for partially filled orders - // As with unfillable orders, these still count as 1 towards fractionalNumberOfOrdersToFill - const takerAssetFilled = ordersInfoBefore[i].orderTakerAssetFilledAmount; - const makerAssetFilled = takerAssetFilled - .times(order.makerAssetAmount) - .dividedToIntegerBy(order.takerAssetAmount); - takerAssetAmount = BigNumber.max(takerAssetAmount.minus(takerAssetFilled), constants.ZERO_AMOUNT); - makerAssetAmount = BigNumber.max(makerAssetAmount.minus(makerAssetFilled), constants.ZERO_AMOUNT); - - currentState.takerAssetFillAmount = currentState.takerAssetFillAmount.plus(takerAssetAmount); - currentState.makerAssetFillAmount[order.makerAssetData] = currentState.makerAssetFillAmount[ - order.makerAssetData - ].plus(makerAssetAmount); - - if (this._protocolFeeCollectorAddress !== constants.NULL_ADDRESS) { - currentState.protocolFees = currentState.protocolFees.plus( - this._gasPrice.times(this._protocolFeeMultiplier), - ); - } - if (order.takerFeeAssetData === order.makerAssetData) { - currentState.percentageFees[order.makerAssetData] = currentState.percentageFees[ - order.makerAssetData - ].plus(takerFee); - } else if (order.takerFeeAssetData === order.takerAssetData) { - currentState.wethFees = currentState.wethFees.plus(takerFee); - } - - remainingOrdersToFill = Math.max(remainingOrdersToFill - 1, 0); - } - - return currentState; - } -} diff --git a/contracts/exchange-forwarder/test/utils/forwarder_wrapper.ts b/contracts/exchange-forwarder/test/utils/forwarder_wrapper.ts deleted file mode 100644 index 8c87e4a78c..0000000000 --- a/contracts/exchange-forwarder/test/utils/forwarder_wrapper.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { artifacts as erc20Artifacts } from '@0x/contracts-erc20'; -import { artifacts as erc721Artifacts } from '@0x/contracts-erc721'; -import { artifacts as exchangeArtifacts } from '@0x/contracts-exchange'; -import { constants, LogDecoder, Web3ProviderEngine } from '@0x/contracts-test-utils'; -import { SignedOrder } from '@0x/types'; -import { BigNumber } from '@0x/utils'; -import { Web3Wrapper } from '@0x/web3-wrapper'; -import { TransactionReceiptWithDecodedLogs, TxDataPayable } from 'ethereum-types'; -import * as _ from 'lodash'; - -import { ForwarderContract } from '../../generated-wrappers/forwarder'; -import { artifacts } from '../../src/artifacts'; - -export class ForwarderWrapper { - private readonly _web3Wrapper: Web3Wrapper; - private readonly _forwarderContract: ForwarderContract; - private readonly _logDecoder: LogDecoder; - constructor(contractInstance: ForwarderContract, provider: Web3ProviderEngine) { - this._forwarderContract = contractInstance; - this._web3Wrapper = new Web3Wrapper(provider); - this._logDecoder = new LogDecoder(this._web3Wrapper, { - ...artifacts, - ...exchangeArtifacts, - ...erc20Artifacts, - ...erc721Artifacts, - }); - } - public async marketSellOrdersWithEthAsync( - orders: SignedOrder[], - txData: TxDataPayable, - opts: { feePercentage?: BigNumber; feeRecipient?: string } = {}, - ): Promise { - const feePercentage = opts.feePercentage === undefined ? constants.ZERO_AMOUNT : opts.feePercentage; - const feeRecipient = opts.feeRecipient === undefined ? constants.NULL_ADDRESS : opts.feeRecipient; - const txHash = await this._forwarderContract.marketSellOrdersWithEth.sendTransactionAsync( - orders, - orders.map(signedOrder => signedOrder.signature), - feePercentage, - feeRecipient, - txData, - ); - const tx = await this._logDecoder.getTxWithDecodedLogsAsync(txHash); - return tx; - } - public async marketBuyOrdersWithEthAsync( - orders: SignedOrder[], - makerAssetFillAmount: BigNumber, - txData: TxDataPayable, - opts: { feePercentage?: BigNumber; feeRecipient?: string } = {}, - ): Promise { - const feePercentage = opts.feePercentage === undefined ? constants.ZERO_AMOUNT : opts.feePercentage; - const feeRecipient = opts.feeRecipient === undefined ? constants.NULL_ADDRESS : opts.feeRecipient; - const txHash = await this._forwarderContract.marketBuyOrdersWithEth.sendTransactionAsync( - orders, - makerAssetFillAmount, - orders.map(signedOrder => signedOrder.signature), - feePercentage, - feeRecipient, - txData, - ); - const tx = await this._logDecoder.getTxWithDecodedLogsAsync(txHash); - return tx; - } - public async withdrawAssetAsync( - assetData: string, - amount: BigNumber, - txData: TxDataPayable, - ): Promise { - const txHash = await this._forwarderContract.withdrawAsset.sendTransactionAsync(assetData, amount, txData); - const tx = await this._logDecoder.getTxWithDecodedLogsAsync(txHash); - return tx; - } - public async approveMakerAssetProxyAsync( - assetData: string, - txData: TxDataPayable, - ): Promise { - const txHash = await this._forwarderContract.approveMakerAssetProxy.sendTransactionAsync(assetData, txData); - const tx = await this._logDecoder.getTxWithDecodedLogsAsync(txHash); - return tx; - } -} diff --git a/contracts/exchange-forwarder/test/utils/index.ts b/contracts/exchange-forwarder/test/utils/index.ts deleted file mode 100644 index 3b6b4db9bc..0000000000 --- a/contracts/exchange-forwarder/test/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './forwarder_wrapper'; -export * from './forwarder_test_factory'; diff --git a/contracts/exchange/test/balance_stores/local_balance_store.ts b/contracts/exchange/test/balance_stores/local_balance_store.ts index 39f7692000..7a28571fc9 100644 --- a/contracts/exchange/test/balance_stores/local_balance_store.ts +++ b/contracts/exchange/test/balance_stores/local_balance_store.ts @@ -41,6 +41,18 @@ export class LocalBalanceStore extends BalanceStore { this._balances.eth[senderAddress] = this._balances.eth[senderAddress].minus(amount); } + /** + * Converts some amount of the ETH balance of an address to WETH balance to simulate wrapping ETH. + * @param senderAddress Address whose ETH to wrap. + * @param amount Amount to wrap. + */ + public wrapEth(senderAddress: string, wethAddress: string, amount: Numberish): void { + this._balances.eth[senderAddress] = this._balances.eth[senderAddress].minus(amount); + _.update(this._balances.erc20, [senderAddress, wethAddress], balance => + (balance || constants.ZERO_AMOUNT).plus(amount), + ); + } + /** * Sends ETH from `fromAddress` to `toAddress`. * @param fromAddress Sender of ETH. diff --git a/contracts/integrations/test/actors/base.ts b/contracts/integrations/test/actors/base.ts index 3c3abb953c..63d6ede13b 100644 --- a/contracts/integrations/test/actors/base.ts +++ b/contracts/integrations/test/actors/base.ts @@ -1,7 +1,9 @@ import { DummyERC20TokenContract, WETH9Contract } from '@0x/contracts-erc20'; -import { constants, TransactionFactory } from '@0x/contracts-test-utils'; +import { DummyERC721TokenContract } from '@0x/contracts-erc721'; +import { constants, getRandomInteger, TransactionFactory } from '@0x/contracts-test-utils'; import { SignatureType, SignedZeroExTransaction, ZeroExTransaction } from '@0x/types'; import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; import { DeploymentManager } from '../utils/deployment_manager'; @@ -62,6 +64,34 @@ export class Actor { ); } + /** + * Mints some number of ERC721 NFTs and approves a spender (defaults to the ERC721 asset proxy) + * to transfer the token. + */ + public async configureERC721TokenAsync( + token: DummyERC721TokenContract, + spender?: string, + numToMint?: number, + ): Promise { + const tokenIds: BigNumber[] = []; + _.times(numToMint || 1, async () => { + const tokenId = getRandomInteger(constants.ZERO_AMOUNT, constants.MAX_UINT256); + await token.mint.awaitTransactionSuccessAsync(this.address, tokenId, { + from: this.address, + }); + tokenIds.push(tokenId); + }); + + await token.setApprovalForAll.awaitTransactionSuccessAsync( + spender || this.deployment.assetProxies.erc721Proxy.address, + true, + { + from: this.address, + }, + ); + return tokenIds; + } + /** * Signs a transaction. */ diff --git a/contracts/integrations/test/actors/maker.ts b/contracts/integrations/test/actors/maker.ts index fc92f4a5fa..3cbefe4039 100644 --- a/contracts/integrations/test/actors/maker.ts +++ b/contracts/integrations/test/actors/maker.ts @@ -1,4 +1,4 @@ -import { constants, OrderFactory } from '@0x/contracts-test-utils'; +import { constants, OrderFactory, orderUtils } from '@0x/contracts-test-utils'; import { Order, SignedOrder } from '@0x/types'; import { TransactionReceiptWithDecodedLogs } from 'ethereum-types'; @@ -41,6 +41,16 @@ export function MakerMixin(Base: TBase) { return this.orderFactory.newSignedOrderAsync(customOrderParams); } + /** + * Cancels one of the maker's orders. + */ + public async cancelOrderAsync(order: SignedOrder): Promise { + const params = orderUtils.createCancel(order); + return this.actor.deployment.exchange.cancelOrder.awaitTransactionSuccessAsync(params.order, { + from: this.actor.address, + }); + } + /** * Joins the staking pool specified by the given ID. */ diff --git a/contracts/integrations/test/coordinator/coordinator.ts b/contracts/integrations/test/coordinator/coordinator_test.ts similarity index 99% rename from contracts/integrations/test/coordinator/coordinator.ts rename to contracts/integrations/test/coordinator/coordinator_test.ts index 161fd2fe30..1c48774b0e 100644 --- a/contracts/integrations/test/coordinator/coordinator.ts +++ b/contracts/integrations/test/coordinator/coordinator_test.ts @@ -21,7 +21,7 @@ import { deployCoordinatorAsync } from './deploy_coordinator'; import { DeploymentManager } from '../utils/deployment_manager'; // tslint:disable:no-unnecessary-type-assertion -blockchainTests.resets('Coordinator tests', env => { +blockchainTests.resets('Coordinator integration tests', env => { let deployment: DeploymentManager; let coordinator: CoordinatorContract; let balanceStore: BlockchainBalanceStore; diff --git a/contracts/integrations/test/forwarder/deploy_forwarder.ts b/contracts/integrations/test/forwarder/deploy_forwarder.ts new file mode 100644 index 0000000000..43b651f5c8 --- /dev/null +++ b/contracts/integrations/test/forwarder/deploy_forwarder.ts @@ -0,0 +1,20 @@ +import { artifacts as exchangeArtifacts } from '@0x/contracts-exchange'; +import { artifacts, ForwarderContract } from '@0x/contracts-exchange-forwarder'; +import { BlockchainTestsEnvironment } from '@0x/contracts-test-utils'; +import { assetDataUtils } from '@0x/order-utils'; + +import { DeploymentManager } from '../utils/deployment_manager'; + +export async function deployForwarderAsync( + deployment: DeploymentManager, + environment: BlockchainTestsEnvironment, +): Promise { + return await ForwarderContract.deployFrom0xArtifactAsync( + artifacts.Forwarder, + environment.provider, + deployment.txDefaults, + { ...exchangeArtifacts, ...artifacts }, + deployment.exchange.address, + assetDataUtils.encodeERC20AssetData(deployment.tokens.weth.address), + ); +} diff --git a/contracts/integrations/test/forwarder/forwarder_test.ts b/contracts/integrations/test/forwarder/forwarder_test.ts new file mode 100644 index 0000000000..c7246fe72a --- /dev/null +++ b/contracts/integrations/test/forwarder/forwarder_test.ts @@ -0,0 +1,597 @@ +import { DummyERC20TokenContract } from '@0x/contracts-erc20'; +import { DummyERC721TokenContract } from '@0x/contracts-erc721'; +import { + artifacts as exchangeArtifacts, + BlockchainBalanceStore, + ExchangeContract, + LocalBalanceStore, +} from '@0x/contracts-exchange'; +import { artifacts, ForwarderContract } from '@0x/contracts-exchange-forwarder'; +import { + blockchainTests, + constants, + expect, + getLatestBlockTimestampAsync, + getPercentageOfValue, + toBaseUnitAmount, +} from '@0x/contracts-test-utils'; +import { assetDataUtils, ForwarderRevertErrors } from '@0x/order-utils'; +import { BigNumber } from '@0x/utils'; + +import { Actor, actorAddressesByName, FeeRecipient, Maker } from '../actors'; +import { deployForwarderAsync } from './deploy_forwarder'; +import { ForwarderTestFactory } from './forwarder_test_factory'; +import { DeploymentManager } from '../utils/deployment_manager'; + +blockchainTests('Forwarder integration tests', env => { + let deployment: DeploymentManager; + let forwarder: ForwarderContract; + let balanceStore: BlockchainBalanceStore; + let testFactory: ForwarderTestFactory; + + let makerToken: DummyERC20TokenContract; + let makerFeeToken: DummyERC20TokenContract; + let anotherErc20Token: DummyERC20TokenContract; + let erc721Token: DummyERC721TokenContract; + let nftId: BigNumber; + let wethAssetData: string; + let makerAssetData: string; + + let maker: Maker; + let taker: Actor; + let orderFeeRecipient: FeeRecipient; + let forwarderFeeRecipient: FeeRecipient; + + before(async () => { + deployment = await DeploymentManager.deployAsync(env, { + numErc20TokensToDeploy: 3, + numErc721TokensToDeploy: 1, + numErc1155TokensToDeploy: 0, + }); + forwarder = await deployForwarderAsync(deployment, env); + + [makerToken, makerFeeToken, anotherErc20Token] = deployment.tokens.erc20; + [erc721Token] = deployment.tokens.erc721; + wethAssetData = assetDataUtils.encodeERC20AssetData(deployment.tokens.weth.address); + makerAssetData = assetDataUtils.encodeERC20AssetData(makerToken.address); + + taker = new Actor({ name: 'Taker', deployment }); + orderFeeRecipient = new FeeRecipient({ + name: 'Order fee recipient', + deployment, + }); + forwarderFeeRecipient = new FeeRecipient({ + name: 'Forwarder fee recipient', + deployment, + }); + maker = new Maker({ + name: 'Maker', + deployment, + orderConfig: { + feeRecipientAddress: orderFeeRecipient.address, + makerAssetAmount: toBaseUnitAmount(2), + takerAssetAmount: toBaseUnitAmount(1), + makerAssetData: makerAssetData, + takerAssetData: wethAssetData, + takerFee: constants.ZERO_AMOUNT, + makerFeeAssetData: assetDataUtils.encodeERC20AssetData(makerFeeToken.address), + takerFeeAssetData: wethAssetData, + }, + }); + + maker.configureERC20TokenAsync(makerToken); + maker.configureERC20TokenAsync(makerFeeToken); + maker.configureERC20TokenAsync(anotherErc20Token); + await forwarder.approveMakerAssetProxy.awaitTransactionSuccessAsync(makerAssetData); + [nftId] = await maker.configureERC721TokenAsync(erc721Token); + + const tokenOwners = { + ...actorAddressesByName([maker, taker, orderFeeRecipient, forwarderFeeRecipient]), + Forwarder: forwarder.address, + StakingProxy: deployment.staking.stakingProxy.address, + }; + const tokenContracts = { + erc20: { makerToken, makerFeeToken, anotherErc20Token, wETH: deployment.tokens.weth }, + erc721: { erc721Token }, + }; + const tokenIds = { erc721: { [erc721Token.address]: [nftId] } }; + balanceStore = new BlockchainBalanceStore(tokenOwners, tokenContracts, tokenIds); + + testFactory = new ForwarderTestFactory( + forwarder, + deployment, + balanceStore, + maker, + taker, + orderFeeRecipient, + forwarderFeeRecipient, + ); + }); + + blockchainTests.resets('constructor', () => { + it('should revert if assetProxy is unregistered', async () => { + const chainId = await env.getChainIdAsync(); + const wethAssetData = assetDataUtils.encodeERC20AssetData(deployment.tokens.weth.address); + const exchange = await ExchangeContract.deployFrom0xArtifactAsync( + exchangeArtifacts.Exchange, + env.provider, + env.txDefaults, + {}, + new BigNumber(chainId), + ); + const deployForwarder = ForwarderContract.deployFrom0xArtifactAsync( + artifacts.Forwarder, + env.provider, + env.txDefaults, + {}, + exchange.address, + wethAssetData, + ); + await expect(deployForwarder).to.revertWith(new ForwarderRevertErrors.UnregisteredAssetProxyError()); + }); + }); + blockchainTests.resets('marketSellOrdersWithEth without extra fees', () => { + it('should fill a single order without a taker fee', async () => { + const orderWithoutFee = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([orderWithoutFee], 0.78); + }); + it('should fill multiple orders without taker fees', async () => { + const firstOrder = await maker.signOrderAsync(); + const secondOrder = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(285), + takerAssetAmount: toBaseUnitAmount(21), + }); + const orders = [firstOrder, secondOrder]; + await testFactory.marketSellTestAsync(orders, 1.51); + }); + it('should fill a single order with a percentage fee', async () => { + const orderWithPercentageFee = await maker.signOrderAsync({ + takerFee: toBaseUnitAmount(1), + takerFeeAssetData: makerAssetData, + }); + await testFactory.marketSellTestAsync([orderWithPercentageFee], 0.58); + }); + it('should fill multiple orders with percentage fees', async () => { + const firstOrder = await maker.signOrderAsync({ + takerFee: toBaseUnitAmount(1), + takerFeeAssetData: makerAssetData, + }); + const secondOrder = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(190), + takerAssetAmount: toBaseUnitAmount(31), + takerFee: toBaseUnitAmount(2), + takerFeeAssetData: makerAssetData, + }); + const orders = [firstOrder, secondOrder]; + await testFactory.marketSellTestAsync(orders, 1.34); + }); + it('should fail to fill an order with a percentage fee if the asset proxy is not yet approved', async () => { + const unapprovedAsset = assetDataUtils.encodeERC20AssetData(anotherErc20Token.address); + const order = await maker.signOrderAsync({ + makerAssetData: unapprovedAsset, + takerFee: toBaseUnitAmount(2), + takerFeeAssetData: unapprovedAsset, + }); + + await balanceStore.updateBalancesAsync(); + // Execute test case + const tx = await forwarder.marketSellOrdersWithEth.awaitTransactionSuccessAsync( + [order], + [order.signature], + constants.ZERO_AMOUNT, + forwarderFeeRecipient.address, + { + value: order.takerAssetAmount.plus(DeploymentManager.protocolFee), + from: taker.address, + }, + ); + + const expectedBalances = LocalBalanceStore.create(balanceStore); + expectedBalances.burnGas(tx.from, DeploymentManager.gasPrice.times(tx.gasUsed)); + + // Verify balances + await balanceStore.updateBalancesAsync(); + balanceStore.assertEquals(expectedBalances); + }); + it('should fill a single order with a WETH fee', async () => { + const orderWithWethFee = await maker.signOrderAsync({ + takerFee: toBaseUnitAmount(1), + takerFeeAssetData: wethAssetData, + }); + await testFactory.marketSellTestAsync([orderWithWethFee], 0.13); + }); + it('should fill multiple orders with WETH fees', async () => { + const firstOrder = await maker.signOrderAsync({ + takerFee: toBaseUnitAmount(1), + takerFeeAssetData: wethAssetData, + }); + const secondOrderWithWethFee = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(97), + takerAssetAmount: toBaseUnitAmount(33), + takerFee: toBaseUnitAmount(2), + takerFeeAssetData: wethAssetData, + }); + const orders = [firstOrder, secondOrderWithWethFee]; + await testFactory.marketSellTestAsync(orders, 1.25); + }); + it('should refund remaining ETH if amount is greater than takerAssetAmount', async () => { + const order = await maker.signOrderAsync(); + const ethValue = order.takerAssetAmount.plus(DeploymentManager.protocolFee).plus(2); + const takerEthBalanceBefore = await env.web3Wrapper.getBalanceInWeiAsync(taker.address); + const tx = await forwarder.marketSellOrdersWithEth.awaitTransactionSuccessAsync( + [order], + [order.signature], + constants.ZERO_AMOUNT, + forwarderFeeRecipient.address, + { + value: ethValue, + from: taker.address, + }, + ); + const takerEthBalanceAfter = await env.web3Wrapper.getBalanceInWeiAsync(taker.address); + const totalEthSpent = order.takerAssetAmount + .plus(DeploymentManager.protocolFee) + .plus(DeploymentManager.gasPrice.times(tx.gasUsed)); + expect(takerEthBalanceAfter).to.be.bignumber.equal(takerEthBalanceBefore.minus(totalEthSpent)); + }); + it('should fill orders with different makerAssetData', async () => { + const firstOrder = await maker.signOrderAsync(); + const secondOrderMakerAssetData = assetDataUtils.encodeERC20AssetData(anotherErc20Token.address); + const secondOrder = await maker.signOrderAsync({ + makerAssetData: secondOrderMakerAssetData, + }); + await forwarder.approveMakerAssetProxy.awaitTransactionSuccessAsync(secondOrderMakerAssetData); + const orders = [firstOrder, secondOrder]; + await testFactory.marketSellTestAsync(orders, 1.5); + }); + it('should fail to fill an order with a fee denominated in an asset other than makerAsset or WETH', async () => { + const takerFeeAssetData = assetDataUtils.encodeERC20AssetData(anotherErc20Token.address); + const order = await maker.signOrderAsync({ + takerFeeAssetData, + takerFee: toBaseUnitAmount(1), + }); + const revertError = new ForwarderRevertErrors.UnsupportedFeeError(takerFeeAssetData); + await testFactory.marketSellTestAsync([order], 0.5, { + revertError, + }); + }); + it('should fill a partially-filled order without a taker fee', async () => { + const order = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([order], 0.3); + await testFactory.marketSellTestAsync([order], 0.8); + }); + it('should skip over an order with an invalid maker asset amount', async () => { + const unfillableOrder = await maker.signOrderAsync({ + makerAssetAmount: constants.ZERO_AMOUNT, + }); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([unfillableOrder, fillableOrder], 1.5); + }); + it('should skip over an order with an invalid taker asset amount', async () => { + const unfillableOrder = await maker.signOrderAsync({ + takerAssetAmount: constants.ZERO_AMOUNT, + }); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([unfillableOrder, fillableOrder], 1.5); + }); + it('should skip over an expired order', async () => { + const currentTimestamp = await getLatestBlockTimestampAsync(); + const expiredOrder = await maker.signOrderAsync({ + expirationTimeSeconds: new BigNumber(currentTimestamp).minus(10), + }); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([expiredOrder, fillableOrder], 1.5); + }); + it('should skip over a fully filled order', async () => { + const fullyFilledOrder = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([fullyFilledOrder], 1); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([fullyFilledOrder, fillableOrder], 1.5); + }); + it('should skip over a cancelled order', async () => { + const cancelledOrder = await maker.signOrderAsync(); + await maker.cancelOrderAsync(cancelledOrder); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketSellTestAsync([cancelledOrder, fillableOrder], 1.5); + }); + }); + blockchainTests.resets('marketSellOrdersWithEth with extra fees', () => { + it('should fill the order and send fee to feeRecipient', async () => { + const order = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(157), + takerAssetAmount: toBaseUnitAmount(36), + }); + await testFactory.marketSellTestAsync([order], 0.67, { + forwarderFeePercentage: new BigNumber(2), + }); + }); + it('should fail if the fee is set too high', async () => { + const order = await maker.signOrderAsync(); + const forwarderFeePercentage = new BigNumber(6); + const revertError = new ForwarderRevertErrors.FeePercentageTooLargeError( + getPercentageOfValue(constants.PERCENTAGE_DENOMINATOR, forwarderFeePercentage), + ); + await testFactory.marketSellTestAsync([order], 0.5, { + forwarderFeePercentage, + revertError, + }); + }); + }); + blockchainTests.resets('marketBuyOrdersWithEth without extra fees', () => { + it('should buy the exact amount of makerAsset in a single order', async () => { + const order = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(131), + takerAssetAmount: toBaseUnitAmount(20), + }); + await testFactory.marketBuyTestAsync([order], 0.62); + }); + it('should buy the exact amount of makerAsset in multiple orders', async () => { + const firstOrder = await maker.signOrderAsync(); + const secondOrder = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(77), + takerAssetAmount: toBaseUnitAmount(11), + }); + const orders = [firstOrder, secondOrder]; + await testFactory.marketBuyTestAsync(orders, 1.96); + }); + it('should buy exactly makerAssetBuyAmount in orders with different makerAssetData', async () => { + const firstOrder = await maker.signOrderAsync(); + const secondOrderMakerAssetData = assetDataUtils.encodeERC20AssetData(anotherErc20Token.address); + const secondOrder = await maker.signOrderAsync({ + makerAssetData: secondOrderMakerAssetData, + }); + await forwarder.approveMakerAssetProxy.awaitTransactionSuccessAsync(secondOrderMakerAssetData); + const orders = [firstOrder, secondOrder]; + await testFactory.marketBuyTestAsync(orders, 1.5); + }); + it('should buy the exact amount of makerAsset and return excess ETH', async () => { + const order = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(80), + takerAssetAmount: toBaseUnitAmount(17), + }); + await testFactory.marketBuyTestAsync([order], 0.57, { + ethValueAdjustment: 2, + }); + }); + it('should buy the exact amount of makerAsset from a single order with a WETH fee', async () => { + const order = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(79), + takerAssetAmount: toBaseUnitAmount(16), + takerFee: toBaseUnitAmount(1), + takerFeeAssetData: wethAssetData, + }); + await testFactory.marketBuyTestAsync([order], 0.38); + }); + it('should buy the exact amount of makerAsset from a single order with a percentage fee', async () => { + const order = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(80), + takerAssetAmount: toBaseUnitAmount(17), + takerFee: toBaseUnitAmount(1), + takerFeeAssetData: makerAssetData, + }); + await testFactory.marketBuyTestAsync([order], 0.52); + }); + it('should revert if the amount of ETH sent is too low to fill the makerAssetAmount', async () => { + const order = await maker.signOrderAsync(); + const revertError = new ForwarderRevertErrors.CompleteBuyFailedError( + order.makerAssetAmount.times(0.5), + constants.ZERO_AMOUNT, + ); + await testFactory.marketBuyTestAsync([order], 0.5, { + ethValueAdjustment: -2, + revertError, + }); + }); + it('should buy an ERC721 asset from a single order', async () => { + const erc721Order = await maker.signOrderAsync({ + makerAssetAmount: new BigNumber(1), + makerAssetData: assetDataUtils.encodeERC721AssetData(erc721Token.address, nftId), + takerFeeAssetData: wethAssetData, + }); + await testFactory.marketBuyTestAsync([erc721Order], 1); + }); + it('should buy an ERC721 asset and pay a WETH fee', async () => { + const erc721orderWithWethFee = await maker.signOrderAsync({ + makerAssetAmount: new BigNumber(1), + makerAssetData: assetDataUtils.encodeERC721AssetData(erc721Token.address, nftId), + takerFee: toBaseUnitAmount(1), + takerFeeAssetData: wethAssetData, + }); + await testFactory.marketBuyTestAsync([erc721orderWithWethFee], 1); + }); + it('should fail to fill an order with a fee denominated in an asset other than makerAsset or WETH', async () => { + const takerFeeAssetData = assetDataUtils.encodeERC20AssetData(anotherErc20Token.address); + const order = await maker.signOrderAsync({ + takerFeeAssetData, + takerFee: toBaseUnitAmount(1), + }); + const revertError = new ForwarderRevertErrors.UnsupportedFeeError(takerFeeAssetData); + await testFactory.marketBuyTestAsync([order], 0.5, { + revertError, + }); + }); + it('should fill a partially-filled order without a taker fee', async () => { + const order = await maker.signOrderAsync(); + await testFactory.marketBuyTestAsync([order], 0.3); + await testFactory.marketBuyTestAsync([order], 0.8); + }); + it('should skip over an order with an invalid maker asset amount', async () => { + const unfillableOrder = await maker.signOrderAsync({ + makerAssetAmount: constants.ZERO_AMOUNT, + }); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketBuyTestAsync([unfillableOrder, fillableOrder], 1.5); + }); + it('should skip over an order with an invalid taker asset amount', async () => { + const unfillableOrder = await maker.signOrderAsync({ + takerAssetAmount: constants.ZERO_AMOUNT, + }); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketBuyTestAsync([unfillableOrder, fillableOrder], 1.5); + }); + it('should skip over an expired order', async () => { + const currentTimestamp = await getLatestBlockTimestampAsync(); + const expiredOrder = await maker.signOrderAsync({ + expirationTimeSeconds: new BigNumber(currentTimestamp).minus(10), + }); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketBuyTestAsync([expiredOrder, fillableOrder], 1.5); + }); + it('should skip over a fully filled order', async () => { + const fullyFilledOrder = await maker.signOrderAsync(); + await testFactory.marketBuyTestAsync([fullyFilledOrder], 1); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketBuyTestAsync([fullyFilledOrder, fillableOrder], 1.5); + }); + it('should skip over a cancelled order', async () => { + const cancelledOrder = await maker.signOrderAsync(); + await maker.cancelOrderAsync(cancelledOrder); + const fillableOrder = await maker.signOrderAsync(); + await testFactory.marketBuyTestAsync([cancelledOrder, fillableOrder], 1.5); + }); + it('Should buy slightly greater makerAsset when exchange rate is rounded', async () => { + // The 0x Protocol contracts round the exchange rate in favor of the Maker. + // In this case, the taker must round up how much they're going to spend, which + // in turn increases the amount of MakerAsset being purchased. + // Example: + // The taker wants to buy 5 units of the MakerAsset at a rate of 3M/2T. + // For every 2 units of TakerAsset, the taker will receive 3 units of MakerAsset. + // To purchase 5 units, the taker must spend 10/3 = 3.33 units of TakerAssset. + // However, the Taker can only spend whole units. + // Spending floor(10/3) = 3 units will yield a profit of Floor(3*3/2) = Floor(4.5) = 4 units of MakerAsset. + // Spending ceil(10/3) = 4 units will yield a profit of Floor(4*3/2) = 6 units of MakerAsset. + // + // The forwarding contract will opt for the second option, which overbuys, to ensure the taker + // receives at least the amount of MakerAsset they requested. + // + // Construct test case using values from example above + const order = await maker.signOrderAsync({ + makerAssetAmount: new BigNumber('30'), + takerAssetAmount: new BigNumber('20'), + makerFee: new BigNumber(0), + takerFee: new BigNumber(0), + }); + const desiredMakerAssetFillAmount = new BigNumber('5'); + const makerAssetFillAmount = new BigNumber('6'); + const primaryTakerAssetFillAmount = new BigNumber('4'); + const ethValue = primaryTakerAssetFillAmount.plus(DeploymentManager.protocolFee); + + await balanceStore.updateBalancesAsync(); + // Execute test case + const tx = await forwarder.marketBuyOrdersWithEth.awaitTransactionSuccessAsync( + [order], + desiredMakerAssetFillAmount, + [order.signature], + constants.ZERO_AMOUNT, + forwarderFeeRecipient.address, + { + value: ethValue, + from: taker.address, + }, + ); + + // Compute expected balances + const expectedBalances = LocalBalanceStore.create(balanceStore); + expectedBalances.transferAsset(maker.address, taker.address, makerAssetFillAmount, makerAssetData); + expectedBalances.wrapEth(taker.address, deployment.tokens.weth.address, ethValue); + expectedBalances.transferAsset(taker.address, maker.address, primaryTakerAssetFillAmount, wethAssetData); + expectedBalances.transferAsset( + taker.address, + deployment.staking.stakingProxy.address, + DeploymentManager.protocolFee, + wethAssetData, + ); + expectedBalances.burnGas(tx.from, DeploymentManager.gasPrice.times(tx.gasUsed)); + + // Verify balances + await balanceStore.updateBalancesAsync(); + balanceStore.assertEquals(expectedBalances); + }); + it('Should buy slightly greater MakerAsset when exchange rate is rounded (Regression Test)', async () => { + // Order taken from a transaction on mainnet that failed due to a rounding error. + const order = await maker.signOrderAsync({ + makerAssetAmount: new BigNumber('268166666666666666666'), + takerAssetAmount: new BigNumber('219090625878836371'), + makerFee: new BigNumber(0), + takerFee: new BigNumber(0), + }); + // The taker will receive more than the desired amount of makerAsset due to rounding + const desiredMakerAssetFillAmount = new BigNumber('5000000000000000000'); + const takerAssetFillAmount = new BigNumber('4084971271824171'); + const makerAssetFillAmount = takerAssetFillAmount + .times(order.makerAssetAmount) + .dividedToIntegerBy(order.takerAssetAmount); + + await balanceStore.updateBalancesAsync(); + // Execute test case + const tx = await forwarder.marketBuyOrdersWithEth.awaitTransactionSuccessAsync( + [order], + desiredMakerAssetFillAmount, + [order.signature], + constants.ZERO_AMOUNT, + forwarderFeeRecipient.address, + { + value: takerAssetFillAmount.plus(DeploymentManager.protocolFee), + from: taker.address, + }, + ); + + // Compute expected balances + const expectedBalances = LocalBalanceStore.create(balanceStore); + expectedBalances.transferAsset(maker.address, taker.address, makerAssetFillAmount, makerAssetData); + expectedBalances.wrapEth( + taker.address, + deployment.tokens.weth.address, + takerAssetFillAmount.plus(DeploymentManager.protocolFee), + ); + expectedBalances.transferAsset(taker.address, maker.address, takerAssetFillAmount, wethAssetData); + expectedBalances.transferAsset( + taker.address, + deployment.staking.stakingProxy.address, + DeploymentManager.protocolFee, + wethAssetData, + ); + expectedBalances.burnGas(tx.from, DeploymentManager.gasPrice.times(tx.gasUsed)); + + // Verify balances + await balanceStore.updateBalancesAsync(); + balanceStore.assertEquals(expectedBalances); + }); + }); + blockchainTests.resets('marketBuyOrdersWithEth with extra fees', () => { + it('should buy the asset and send fee to feeRecipient', async () => { + const order = await maker.signOrderAsync({ + makerAssetAmount: toBaseUnitAmount(125), + takerAssetAmount: toBaseUnitAmount(11), + }); + await testFactory.marketBuyTestAsync([order], 0.33, { + forwarderFeePercentage: new BigNumber(2), + }); + }); + it('should fail if the fee is set too high', async () => { + const order = await maker.signOrderAsync(); + const revertError = new ForwarderRevertErrors.FeePercentageTooLargeError( + getPercentageOfValue(constants.PERCENTAGE_DENOMINATOR, new BigNumber(6)), + ); + await testFactory.marketBuyTestAsync([order], 0.5, { + forwarderFeePercentage: new BigNumber(6), + revertError, + }); + }); + it('should fail if there is not enough ETH remaining to pay the fee', async () => { + const order = await maker.signOrderAsync(); + const forwarderFeePercentage = new BigNumber(2); + const ethFee = getPercentageOfValue( + order.takerAssetAmount.times(0.5).plus(DeploymentManager.protocolFee), + forwarderFeePercentage, + ); + const revertError = new ForwarderRevertErrors.InsufficientEthForFeeError(ethFee, ethFee.minus(1)); + await testFactory.marketBuyTestAsync([order], 0.5, { + ethValueAdjustment: -1, + forwarderFeePercentage, + revertError, + }); + }); + }); +}); +// tslint:disable:max-file-line-count diff --git a/contracts/integrations/test/forwarder/forwarder_test_factory.ts b/contracts/integrations/test/forwarder/forwarder_test_factory.ts new file mode 100644 index 0000000000..2d799eed45 --- /dev/null +++ b/contracts/integrations/test/forwarder/forwarder_test_factory.ts @@ -0,0 +1,269 @@ +import { BlockchainBalanceStore, LocalBalanceStore } from '@0x/contracts-exchange'; +import { ForwarderContract } from '@0x/contracts-exchange-forwarder'; +import { constants, expect, getPercentageOfValue, OrderStatus } from '@0x/contracts-test-utils'; +import { OrderInfo, SignedOrder } from '@0x/types'; +import { BigNumber, RevertError } from '@0x/utils'; +import * as _ from 'lodash'; +import { TransactionReceiptWithDecodedLogs } from 'ethereum-types'; + +import { Actor, FeeRecipient, Maker } from '../actors'; +import { DeploymentManager } from '../utils/deployment_manager'; + +// Necessary bookkeeping to validate Forwarder results +interface ForwarderFillState { + balances: LocalBalanceStore; + wethSpentAmount: BigNumber; + makerAssetAcquiredAmount: BigNumber; +} + +interface MarketSellOptions { + forwarderFeePercentage: BigNumber; + revertError: RevertError; +} + +interface MarketBuyOptions extends MarketSellOptions { + ethValueAdjustment: number; // Used to provided insufficient/excess ETH +} + +export class ForwarderTestFactory { + constructor( + private readonly _forwarder: ForwarderContract, + private readonly _deployment: DeploymentManager, + private readonly _balanceStore: BlockchainBalanceStore, + private readonly _maker: Maker, + private readonly _taker: Actor, + private readonly _orderFeeRecipient: FeeRecipient, + private readonly _forwarderFeeRecipient: FeeRecipient, + ) {} + + public async marketBuyTestAsync( + orders: SignedOrder[], + fractionalNumberOfOrdersToFill: number, + options: Partial = {}, + ): Promise { + const ethValueAdjustment = options.ethValueAdjustment || 0; + const forwarderFeePercentage = options.forwarderFeePercentage || 0; + + const orderInfoBefore = await Promise.all( + orders.map(order => this._deployment.exchange.getOrderInfo.callAsync(order)), + ); + const expectedOrderStatuses = orderInfoBefore.map((orderInfo, i) => + fractionalNumberOfOrdersToFill >= i + 1 && orderInfo.orderStatus === OrderStatus.Fillable + ? OrderStatus.FullyFilled + : orderInfo.orderStatus, + ); + + const { + balances: expectedBalances, + wethSpentAmount, + makerAssetAcquiredAmount, + } = await this._simulateForwarderFillAsync(orders, orderInfoBefore, fractionalNumberOfOrdersToFill, options); + + const ethSpentOnForwarderFee = getPercentageOfValue(wethSpentAmount, forwarderFeePercentage); + const feePercentage = getPercentageOfValue(constants.PERCENTAGE_DENOMINATOR, forwarderFeePercentage); + + const tx = this._forwarder.marketBuyOrdersWithEth.awaitTransactionSuccessAsync( + orders, + makerAssetAcquiredAmount, + orders.map(signedOrder => signedOrder.signature), + feePercentage, + this._forwarderFeeRecipient.address, + { + value: wethSpentAmount.plus(ethSpentOnForwarderFee).plus(ethValueAdjustment), + from: this._taker.address, + }, + ); + + if (options.revertError !== undefined) { + await expect(tx).to.revertWith(options.revertError); + } else { + const txReceipt = await tx; + await this._checkResultsAsync(txReceipt, orders, expectedOrderStatuses, expectedBalances); + } + } + + public async marketSellTestAsync( + orders: SignedOrder[], + fractionalNumberOfOrdersToFill: number, + options: Partial = {}, + ): Promise { + const orderInfoBefore = await Promise.all( + orders.map(order => this._deployment.exchange.getOrderInfo.callAsync(order)), + ); + const expectedOrderStatuses = orderInfoBefore.map((orderInfo, i) => + fractionalNumberOfOrdersToFill >= i + 1 && orderInfo.orderStatus === OrderStatus.Fillable + ? OrderStatus.FullyFilled + : orderInfo.orderStatus, + ); + + const { balances: expectedBalances, wethSpentAmount } = await this._simulateForwarderFillAsync( + orders, + orderInfoBefore, + fractionalNumberOfOrdersToFill, + options, + ); + + const forwarderFeePercentage = options.forwarderFeePercentage || 0; + const ethSpentOnForwarderFee = getPercentageOfValue(wethSpentAmount, forwarderFeePercentage); + const feePercentage = getPercentageOfValue(constants.PERCENTAGE_DENOMINATOR, forwarderFeePercentage); + + const tx = this._forwarder.marketSellOrdersWithEth.awaitTransactionSuccessAsync( + orders, + orders.map(signedOrder => signedOrder.signature), + feePercentage, + this._forwarderFeeRecipient.address, + { + value: wethSpentAmount.plus(ethSpentOnForwarderFee), + from: this._taker.address, + }, + ); + + if (options.revertError !== undefined) { + await expect(tx).to.revertWith(options.revertError); + } else { + const txReceipt = await tx; + await this._checkResultsAsync(txReceipt, orders, expectedOrderStatuses, expectedBalances); + } + } + + private async _checkResultsAsync( + txReceipt: TransactionReceiptWithDecodedLogs, + orders: SignedOrder[], + expectedOrderStatuses: OrderStatus[], + expectedBalances: LocalBalanceStore, + ): Promise { + // Transaction gas cost + expectedBalances.burnGas(txReceipt.from, DeploymentManager.gasPrice.times(txReceipt.gasUsed)); + await this._balanceStore.updateBalancesAsync(); + // Check balances + this._balanceStore.assertEquals(expectedBalances); + + // Get updated order info + const orderInfoAfter = await Promise.all( + orders.map(order => this._deployment.exchange.getOrderInfo.callAsync(order)), + ); + // Check order statuses + for (const [i, orderInfo] of orderInfoAfter.entries()) { + expect(orderInfo.orderStatus, ` Order ${i} status`).to.equal(expectedOrderStatuses[i]); + } + } + + // Simulates filling some orders via the Forwarder contract. For example, if + // orders = [A, B, C, D] and fractionalNumberOfOrdersToFill = 2.3, then + // we simulate A and B being completely filled, and 0.3 * C being filled. + private async _simulateForwarderFillAsync( + orders: SignedOrder[], + ordersInfoBefore: OrderInfo[], + fractionalNumberOfOrdersToFill: number, + options: Partial, + ): Promise { + await this._balanceStore.updateBalancesAsync(); + const balances = LocalBalanceStore.create(this._balanceStore); + const currentTotal = { + wethSpentAmount: constants.ZERO_AMOUNT, + makerAssetAcquiredAmount: constants.ZERO_AMOUNT, + }; + + let remainingOrdersToFill = fractionalNumberOfOrdersToFill; + for (const [i, order] of orders.entries()) { + if (remainingOrdersToFill === 0) { + break; + } else if (ordersInfoBefore[i].orderStatus !== OrderStatus.Fillable) { + // If the order is not fillable, skip over it but still count it towards fractionalNumberOfOrdersToFill + remainingOrdersToFill = Math.max(remainingOrdersToFill - 1, 0); + continue; + } + + const { wethSpentAmount, makerAssetAcquiredAmount } = this._simulateSingleFill( + balances, + order, + ordersInfoBefore[i].orderTakerAssetFilledAmount, + Math.min(remainingOrdersToFill, 1), + ); + remainingOrdersToFill = Math.max(remainingOrdersToFill - 1, 0); + + currentTotal.wethSpentAmount = currentTotal.wethSpentAmount.plus(wethSpentAmount); + currentTotal.makerAssetAcquiredAmount = currentTotal.makerAssetAcquiredAmount.plus( + makerAssetAcquiredAmount, + ); + } + + const ethSpentOnForwarderFee = getPercentageOfValue( + currentTotal.wethSpentAmount, + options.forwarderFeePercentage || 0, + ); + // In reality the Forwarder is a middleman in this transaction and the ETH gets wrapped and unwrapped. + balances.sendEth(this._taker.address, this._forwarderFeeRecipient.address, ethSpentOnForwarderFee); + + return { ...currentTotal, balances }; + } + + private _simulateSingleFill( + balances: LocalBalanceStore, + order: SignedOrder, + takerAssetFilled: BigNumber, + fillFraction: number, + ): ForwarderFillState { + let { makerAssetAmount, takerAssetAmount, makerFee, takerFee } = order; + makerAssetAmount = makerAssetAmount.times(fillFraction).integerValue(BigNumber.ROUND_CEIL); + takerAssetAmount = takerAssetAmount.times(fillFraction).integerValue(BigNumber.ROUND_CEIL); + makerFee = makerFee.times(fillFraction).integerValue(BigNumber.ROUND_CEIL); + takerFee = takerFee.times(fillFraction).integerValue(BigNumber.ROUND_CEIL); + + // Accounting for partially filled orders + // As with unfillable orders, these still count as 1 towards fractionalNumberOfOrdersToFill + takerAssetAmount = BigNumber.max(takerAssetAmount.minus(takerAssetFilled), 0); + const makerAssetFilled = takerAssetFilled + .times(order.makerAssetAmount) + .dividedToIntegerBy(order.takerAssetAmount); + makerAssetAmount = BigNumber.max(makerAssetAmount.minus(makerAssetFilled), 0); + const makerFeeFilled = takerAssetFilled.times(order.makerFee).dividedToIntegerBy(order.takerAssetAmount); + makerFee = BigNumber.max(makerFee.minus(makerFeeFilled), 0); + const takerFeeFilled = takerAssetFilled.times(order.takerFee).dividedToIntegerBy(order.takerAssetAmount); + takerFee = BigNumber.max(takerFee.minus(takerFeeFilled), 0); + + let wethSpentAmount = takerAssetAmount.plus(DeploymentManager.protocolFee); + let makerAssetAcquiredAmount = makerAssetAmount; + if (order.takerFeeAssetData === order.makerAssetData) { + makerAssetAcquiredAmount = makerAssetAcquiredAmount.minus(takerFee); + } else if (order.takerFeeAssetData === order.takerAssetData) { + wethSpentAmount = wethSpentAmount.plus(takerFee); + } + + // Taker sends ETH to Forwarder + balances.sendEth(this._taker.address, this._forwarder.address, wethSpentAmount); + // Forwarder wraps the ETH + balances.wrapEth(this._forwarder.address, this._deployment.tokens.weth.address, wethSpentAmount); + // (In reality this is done all at once, but we simulate it order by order) + + // Maker -> Forwarder + balances.transferAsset(this._maker.address, this._forwarder.address, makerAssetAmount, order.makerAssetData); + // Maker -> Order fee recipient + balances.transferAsset(this._maker.address, this._orderFeeRecipient.address, makerFee, order.makerFeeAssetData); + // Forwarder -> Maker + balances.transferAsset(this._forwarder.address, this._maker.address, takerAssetAmount, order.takerAssetData); + // Forwarder -> Order fee recipient + balances.transferAsset( + this._forwarder.address, + this._orderFeeRecipient.address, + takerFee, + order.takerFeeAssetData, + ); + // Forwarder pays the protocol fee in WETH + balances.transferAsset( + this._forwarder.address, + this._deployment.staking.stakingProxy.address, + DeploymentManager.protocolFee, + order.takerAssetData, + ); + // Forwarder gives acquired maker asset to taker + balances.transferAsset( + this._forwarder.address, + this._taker.address, + makerAssetAcquiredAmount, + order.makerAssetData, + ); + + return { wethSpentAmount, makerAssetAcquiredAmount, balances }; + } +} diff --git a/contracts/test-utils/src/index.ts b/contracts/test-utils/src/index.ts index 75aa8cec38..063859363d 100644 --- a/contracts/test-utils/src/index.ts +++ b/contracts/test-utils/src/index.ts @@ -69,5 +69,6 @@ export { getRandomInteger, getRandomPortion, getNumericalDivergence, + getPercentageOfValue, toBaseUnitAmount, } from './number_utils'; diff --git a/contracts/test-utils/src/number_utils.ts b/contracts/test-utils/src/number_utils.ts index 829aaef8b4..ead259022d 100644 --- a/contracts/test-utils/src/number_utils.ts +++ b/contracts/test-utils/src/number_utils.ts @@ -3,6 +3,7 @@ import { Web3Wrapper } from '@0x/web3-wrapper'; import * as crypto from 'crypto'; import { expect } from './chai_setup'; +import { constants } from './constants'; import { Numberish } from './types'; /** @@ -86,3 +87,12 @@ export function toBaseUnitAmount(amount: Numberish): BigNumber { const baseUnitAmount = Web3Wrapper.toBaseUnitAmount(amountAsBigNumber, decimals); return baseUnitAmount; } + +/** + * Computes a percentage of `value`, first converting `percentage` to be expressed in 18 digits. + */ +export function getPercentageOfValue(value: Numberish, percentage: Numberish): BigNumber { + const numerator = constants.PERCENTAGE_DENOMINATOR.times(percentage).dividedToIntegerBy(100); + const newValue = numerator.times(value).dividedToIntegerBy(constants.PERCENTAGE_DENOMINATOR); + return newValue; +}