diff --git a/contracts/zero-ex/CHANGELOG.json b/contracts/zero-ex/CHANGELOG.json index 65018dc553..7a2ccc6a7c 100644 --- a/contracts/zero-ex/CHANGELOG.json +++ b/contracts/zero-ex/CHANGELOG.json @@ -49,6 +49,14 @@ { "note": "Add updated Kyber and Mooniswap rollup to FQT", "pr": 2692 + }, + { + "note": "Add `UniswapFeature`", + "pr": 2703 + }, + { + "note": "Fix versioning (`_encodeVersion()`) bug", + "pr": 2703 } ] }, diff --git a/contracts/zero-ex/contracts/src/IZeroEx.sol b/contracts/zero-ex/contracts/src/IZeroEx.sol index 62a8f832f8..b31371a02f 100644 --- a/contracts/zero-ex/contracts/src/IZeroEx.sol +++ b/contracts/zero-ex/contracts/src/IZeroEx.sol @@ -25,6 +25,7 @@ import "./features/ITokenSpenderFeature.sol"; import "./features/ISignatureValidatorFeature.sol"; import "./features/ITransformERC20Feature.sol"; import "./features/IMetaTransactionsFeature.sol"; +import "./features/IUniswapFeature.sol"; /// @dev Interface for a fully featured Exchange Proxy. @@ -34,7 +35,8 @@ interface IZeroEx is ITokenSpenderFeature, ISignatureValidatorFeature, ITransformERC20Feature, - IMetaTransactionsFeature + IMetaTransactionsFeature, + IUniswapFeature { // solhint-disable state-visibility diff --git a/contracts/zero-ex/contracts/src/features/IUniswapFeature.sol b/contracts/zero-ex/contracts/src/features/IUniswapFeature.sol new file mode 100644 index 0000000000..c889380859 --- /dev/null +++ b/contracts/zero-ex/contracts/src/features/IUniswapFeature.sol @@ -0,0 +1,43 @@ +/* + + Copyright 2020 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity ^0.6.5; +pragma experimental ABIEncoderV2; + +import "@0x/contracts-erc20/contracts/src/v06/IERC20TokenV06.sol"; + + +/// @dev VIP uniswap fill functions. +interface IUniswapFeature { + + /// @dev Efficiently sell directly to uniswap/sushiswap. + /// @param tokens Sell path. + /// @param sellAmount of `tokens[0]` Amount to sell. + /// @param minBuyAmount Minimum amount of `tokens[-1]` to buy. + /// @param isSushi Use sushiswap if true. + /// @return buyAmount Amount of `tokens[-1]` bought. + function sellToUniswap( + IERC20TokenV06[] calldata tokens, + uint256 sellAmount, + uint256 minBuyAmount, + bool isSushi + ) + external + payable + returns (uint256 buyAmount); +} diff --git a/contracts/zero-ex/contracts/src/features/UniswapFeature.sol b/contracts/zero-ex/contracts/src/features/UniswapFeature.sol new file mode 100644 index 0000000000..9aec0c018d --- /dev/null +++ b/contracts/zero-ex/contracts/src/features/UniswapFeature.sol @@ -0,0 +1,366 @@ +/* + + Copyright 2020 ZeroEx Intl. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +pragma solidity ^0.6.5; +pragma experimental ABIEncoderV2; + +import "@0x/contracts-erc20/contracts/src/v06/IERC20TokenV06.sol"; +import "@0x/contracts-erc20/contracts/src/v06/IEtherTokenV06.sol"; +import "../migrations/LibMigrate.sol"; +import "../external/IAllowanceTarget.sol"; +import "../fixins/FixinCommon.sol"; +import "./IFeature.sol"; +import "./IUniswapFeature.sol"; + + +/// @dev VIP uniswap fill functions. +contract UniswapFeature is + IFeature, + IUniswapFeature, + FixinCommon +{ + /// @dev Name of this feature. + string public constant override FEATURE_NAME = "UniswapFeature"; + /// @dev Version of this feature. + uint256 public immutable override FEATURE_VERSION = _encodeVersion(1, 0, 0); + /// @dev WETH contract. + IEtherTokenV06 private immutable WETH; + /// @dev AllowanceTarget instance. + IAllowanceTarget private immutable ALLOWANCE_TARGET; + + // 0xFF + address of the UniswapV2Factory contract. + uint256 constant private FF_UNISWAP_FACTORY = 0xFF5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f0000000000000000000000; + // 0xFF + address of the (Sushiswap) UniswapV2Factory contract. + uint256 constant private FF_SUSHISWAP_FACTORY = 0xFFC0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac0000000000000000000000; + // Init code hash of the UniswapV2Pair contract. + uint256 constant private UNISWAP_PAIR_INIT_CODE_HASH = 0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f; + // Init code hash of the (Sushiswap) UniswapV2Pair contract. + uint256 constant private SUSHISWAP_PAIR_INIT_CODE_HASH = 0xe18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303; + // Mask of the lower 20 bytes of a bytes32. + uint256 constant private ADDRESS_MASK = 0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff; + // ETH pseudo-token address. + uint256 constant private ETH_TOKEN_ADDRESS_32 = 0x000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee; + // Maximum token quantity that can be swapped against the UniswapV2Pair contract. + uint256 constant private MAX_SWAP_AMOUNT = 2**112; + + // bytes4(keccak256("executeCall(address,bytes)")) + uint256 constant private ALLOWANCE_TARGET_EXECUTE_CALL_SELECTOR_32 = 0xbca8c7b500000000000000000000000000000000000000000000000000000000; + // bytes4(keccak256("getReserves()")) + uint256 constant private UNISWAP_PAIR_RESERVES_CALL_SELECTOR_32 = 0x0902f1ac00000000000000000000000000000000000000000000000000000000; + // bytes4(keccak256("swap(uint256,uint256,address,bytes)")) + uint256 constant private UNISWAP_PAIR_SWAP_CALL_SELECTOR_32 = 0x022c0d9f00000000000000000000000000000000000000000000000000000000; + // bytes4(keccak256("transferFrom(address,address,uint256)")) + uint256 constant private TRANSFER_FROM_CALL_SELECTOR_32 = 0x23b872dd00000000000000000000000000000000000000000000000000000000; + // bytes4(keccak256("withdraw(uint256)")) + uint256 constant private WETH_WITHDRAW_CALL_SELECTOR_32 = 0x2e1a7d4d00000000000000000000000000000000000000000000000000000000; + // bytes4(keccak256("deposit()")) + uint256 constant private WETH_DEPOSIT_CALL_SELECTOR_32 = 0xd0e30db000000000000000000000000000000000000000000000000000000000; + // bytes4(keccak256("transfer(address,uint256)")) + uint256 constant private ERC20_TRANSFER_CALL_SELECTOR_32 = 0xa9059cbb00000000000000000000000000000000000000000000000000000000; + + /// @dev Construct this contract. + /// @param weth The WETH contract. + /// @param allowanceTarget The AllowanceTarget contract. + constructor(IEtherTokenV06 weth, IAllowanceTarget allowanceTarget) public { + WETH = weth; + ALLOWANCE_TARGET = allowanceTarget; + } + + /// @dev Initialize and register this feature. + /// Should be delegatecalled by `Migrate.migrate()`. + /// @return success `LibMigrate.SUCCESS` on success. + function migrate() + external + returns (bytes4 success) + { + _registerFeatureFunction(this.sellToUniswap.selector); + return LibMigrate.MIGRATE_SUCCESS; + } + + /// @dev Efficiently sell directly to uniswap/sushiswap. + /// @param tokens Sell path. + /// @param sellAmount of `tokens[0]` Amount to sell. + /// @param minBuyAmount Minimum amount of `tokens[-1]` to buy. + /// @param isSushi Use sushiswap if true. + /// @return buyAmount Amount of `tokens[-1]` bought. + function sellToUniswap( + IERC20TokenV06[] calldata tokens, + uint256 sellAmount, + uint256 minBuyAmount, + bool isSushi + ) + external + payable + override + returns (uint256 buyAmount) + { + require(tokens.length > 1, "UniswapFeature/InvalidTokensLength"); + { + // Load immutables onto the stack. + IEtherTokenV06 weth = WETH; + IAllowanceTarget allowanceTarget = ALLOWANCE_TARGET; + + // Store some vars in memory to get around stack limits. + assembly { + // calldataload(mload(0xA00)) == first element of `tokens` array + mstore(0xA00, add(calldataload(0x04), 0x24)) + // mload(0xA20) == isSushi + mstore(0xA20, isSushi) + // mload(0xA40) == WETH + mstore(0xA40, weth) + // mload(0xA60) == ALLOWANCE_TARGET + mstore(0xA60, allowanceTarget) + } + } + + assembly { + // numPairs == tokens.length - 1 + let numPairs := sub(calldataload(add(calldataload(0x04), 0x4)), 1) + // We use the previous buy amount as the sell amount for the next + // pair in a path. So for the first swap we want to set it to `sellAmount`. + buyAmount := sellAmount + let buyToken + let nextPair := 0 + + for {let i := 0} lt(i, numPairs) {i := add(i, 1)} { + // sellToken = tokens[i] + let sellToken := loadTokenAddress(i) + // buyToken = tokens[i+1] + buyToken := loadTokenAddress(add(i, 1)) + // The canonical ordering of this token pair. + let pairOrder := lt(normalizeToken(sellToken), normalizeToken(buyToken)) + + // Compute the pair address if it hasn't already been computed + // from the last iteration. + let pair := nextPair + if iszero(pair) { + pair := computePairAddress(sellToken, buyToken) + nextPair := 0 + } + + if iszero(i) { + switch eq(sellToken, ETH_TOKEN_ADDRESS_32) + case 0 { + // For the first pair we need to transfer sellTokens into the + // pair contract using `AllowanceTarget.executeCall()` + mstore(0xB00, ALLOWANCE_TARGET_EXECUTE_CALL_SELECTOR_32) + mstore(0xB04, sellToken) + mstore(0xB24, 0x40) + mstore(0xB44, 0x64) + mstore(0xB64, TRANSFER_FROM_CALL_SELECTOR_32) + mstore(0xB68, caller()) + mstore(0xB88, pair) + mstore(0xBA8, sellAmount) + if iszero(call(gas(), mload(0xA60), 0, 0xB00, 0xC8, 0x00, 0x0)) { + bubbleRevert() + } + } + default { + // If selling ETH, we need to wrap it to WETH and transfer to the + // pair contract. + if iszero(eq(callvalue(), sellAmount)) { + revert(0, 0) + } + sellToken := mload(0xA40)// Re-assign to WETH + // Call `WETH.deposit{value: sellAmount}()` + mstore(0xB00, WETH_DEPOSIT_CALL_SELECTOR_32) + if iszero(call(gas(), sellToken, sellAmount, 0xB00, 0x4, 0x00, 0x0)) { + bubbleRevert() + } + // Call `WETH.transfer(pair, sellAmount)` + mstore(0xB00, ERC20_TRANSFER_CALL_SELECTOR_32) + mstore(0xB04, pair) + mstore(0xB24, sellAmount) + if iszero(call(gas(), sellToken, 0, 0xB00, 0x44, 0x00, 0x0)) { + bubbleRevert() + } + } + // No need to check results, if deposit/transfers failed the UniswapV2Pair will + // reject our trade (or it may succeed if somehow the reserve was out of sync) + // this is fine for the taker. + } + + // Call pair.getReserves(), store the results at `0xC00` + mstore(0xB00, UNISWAP_PAIR_RESERVES_CALL_SELECTOR_32) + if iszero(staticcall(gas(), pair, 0xB00, 0x4, 0xC00, 0x40)) { + bubbleRevert() + } + + // Sell amount for this hop is the previous buy amount. + let pairSellAmount := buyAmount + // Compute the buy amount based on the pair reserves. + { + let sellReserve + let buyReserve + switch iszero(pairOrder) + case 0 { + // Transpose if pair order is different. + sellReserve := mload(0xC00) + buyReserve := mload(0xC20) + } + default { + sellReserve := mload(0xC20) + buyReserve := mload(0xC00) + } + // Ensure that the sellAmount is < 2¹¹². + if gt(pairSellAmount, MAX_SWAP_AMOUNT) { + revert(0, 0) + } + // Pairs are in the range (0, 2¹¹²) so this shouldn't overflow. + // buyAmount = (pairSellAmount * 997 * buyReserve) / + // (pairSellAmount * 997 + sellReserve * 1000); + let sellAmountWithFee := mul(pairSellAmount, 997) + buyAmount := div( + mul(sellAmountWithFee, buyReserve), + add(sellAmountWithFee, mul(sellReserve, 1000)) + ) + } + + let receiver + // Is this the last pair contract? + switch eq(add(i, 1), numPairs) + case 0 { + // Not the last pair contract, so forward bought tokens to + // the next pair contract. + nextPair := computePairAddress( + buyToken, + loadTokenAddress(add(i, 2)) + ) + receiver := nextPair + } + default { + // The last pair contract. + // Forward directly to taker UNLESS they want ETH back. + switch eq(buyToken, ETH_TOKEN_ADDRESS_32) + case 0 { + receiver := caller() + } + default { + receiver := address() + } + } + + // Call pair.swap() + mstore(0xB00, UNISWAP_PAIR_SWAP_CALL_SELECTOR_32) + switch pairOrder + case 0 { + mstore(0xB04, buyAmount) + mstore(0xB24, 0) + } + default { + mstore(0xB04, 0) + mstore(0xB24, buyAmount) + } + mstore(0xB44, receiver) + mstore(0xB64, 0x80) + mstore(0xB84, 0) + if iszero(call(gas(), pair, 0, 0xB00, 0xA4, 0, 0)) { + bubbleRevert() + } + } // End for-loop. + + // If buying ETH, unwrap the WETH first + if eq(buyToken, ETH_TOKEN_ADDRESS_32) { + // Call `WETH.withdraw(buyAmount)` + mstore(0xB00, WETH_WITHDRAW_CALL_SELECTOR_32) + mstore(0xB04, buyAmount) + if iszero(call(gas(), mload(0xA40), 0, 0xB00, 0x24, 0x00, 0x0)) { + bubbleRevert() + } + // Transfer ETH to the caller. + if iszero(call(gas(), caller(), buyAmount, 0xB00, 0x0, 0x00, 0x0)) { + bubbleRevert() + } + } + + // Functions /////////////////////////////////////////////////////// + + // Load a token address from the `tokens` calldata argument. + function loadTokenAddress(idx) -> addr { + addr := and(ADDRESS_MASK, calldataload(add(mload(0xA00), mul(idx, 0x20)))) + } + + // Convert ETH pseudo-token addresses to WETH. + function normalizeToken(token) -> normalized { + normalized := token + // Translate ETH pseudo-tokens to WETH. + if eq(token, ETH_TOKEN_ADDRESS_32) { + normalized := mload(0xA40) + } + } + + // Compute the address of the UniswapV2Pair contract given two + // tokens. + function computePairAddress(tokenA, tokenB) -> pair { + // Convert ETH pseudo-token addresses to WETH. + tokenA := normalizeToken(tokenA) + tokenB := normalizeToken(tokenB) + // There is one contract for every combination of tokens, + // which is deployed using CREATE2. + // The derivation of this address is given by: + // address(keccak256(abi.encodePacked( + // bytes(0xFF), + // address(UNISWAP_FACTORY_ADDRESS), + // keccak256(abi.encodePacked( + // tokenA < tokenB ? tokenA : tokenB, + // tokenA < tokenB ? tokenB : tokenA, + // )), + // bytes32(UNISWAP_PAIR_INIT_CODE_HASH), + // ))); + + // Compute the salt (the hash of the sorted tokens). + // Tokens are written in reverse memory order to packed encode + // them as two 20-byte values in a 40-byte chunk of memory + // starting at 0xB0C. + switch lt(tokenA, tokenB) + case 0 { + mstore(0xB14, tokenA) + mstore(0xB00, tokenB) + } + default { + mstore(0xB14, tokenB) + mstore(0xB00, tokenA) + } + let salt := keccak256(0xB0C, 0x28) + // Compute the pair address by hashing all the components together. + switch mload(0xA20) // isSushi + case 0 { + mstore(0xB00, FF_UNISWAP_FACTORY) + mstore(0xB15, salt) + mstore(0xB35, UNISWAP_PAIR_INIT_CODE_HASH) + } + default { + mstore(0xB00, FF_SUSHISWAP_FACTORY) + mstore(0xB15, salt) + mstore(0xB35, SUSHISWAP_PAIR_INIT_CODE_HASH) + } + pair := and(ADDRESS_MASK, keccak256(0xB00, 0x55)) + } + + // Revert with the return data from the most recent call. + function bubbleRevert() { + returndatacopy(0, 0, returndatasize()) + revert(0, returndatasize()) + } + } + + // Revert if we bought too little. + // TODO: replace with rich revert? + require(buyAmount >= minBuyAmount, "UniswapFeature/UnderBought"); + } +} diff --git a/contracts/zero-ex/contracts/src/fixins/FixinCommon.sol b/contracts/zero-ex/contracts/src/fixins/FixinCommon.sol index 37ef41d652..841bdf5044 100644 --- a/contracts/zero-ex/contracts/src/fixins/FixinCommon.sol +++ b/contracts/zero-ex/contracts/src/fixins/FixinCommon.sol @@ -81,6 +81,6 @@ abstract contract FixinCommon { pure returns (uint256 encodedVersion) { - return (major << 64) | (minor << 32) | revision; + return (uint256(major) << 64) | (uint256(minor) << 32) | uint256(revision); } } diff --git a/contracts/zero-ex/contracts/src/transformers/LibERC20Transformer.sol b/contracts/zero-ex/contracts/src/transformers/LibERC20Transformer.sol index 258a91098c..695f7f9f8d 100644 --- a/contracts/zero-ex/contracts/src/transformers/LibERC20Transformer.sol +++ b/contracts/zero-ex/contracts/src/transformers/LibERC20Transformer.sol @@ -29,6 +29,8 @@ library LibERC20Transformer { /// @dev ETH pseudo-token address. address constant internal ETH_TOKEN_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + /// @dev ETH pseudo-token. + IERC20TokenV06 constant internal ETH_TOKEN = IERC20TokenV06(ETH_TOKEN_ADDRESS); /// @dev Return value indicating success in `IERC20Transformer.transform()`. /// This is just `keccak256('TRANSFORMER_SUCCESS')`. bytes4 constant internal TRANSFORMER_SUCCESS = 0x13c9929e; diff --git a/contracts/zero-ex/package.json b/contracts/zero-ex/package.json index 528789cde1..89d547f032 100644 --- a/contracts/zero-ex/package.json +++ b/contracts/zero-ex/package.json @@ -41,7 +41,7 @@ "config": { "publicInterfaceContracts": "IZeroEx,ZeroEx,FullMigration,InitialMigration,IFlashWallet,IAllowanceTarget,IERC20Transformer,IOwnableFeature,ISimpleFunctionRegistryFeature,ITokenSpenderFeature,ITransformERC20Feature,FillQuoteTransformer,PayTakerTransformer,WethTransformer,OwnableFeature,SimpleFunctionRegistryFeature,TransformERC20Feature,TokenSpenderFeature,AffiliateFeeTransformer,SignatureValidatorFeature,MetaTransactionsFeature,LogMetadataTransformer,BridgeAdapter", "abis:comment": "This list is auto-generated by contracts-gen. Don't edit manually.", - "abis": "./test/generated-artifacts/@(AffiliateFeeTransformer|AllowanceTarget|BootstrapFeature|BridgeAdapter|FillQuoteTransformer|FixinCommon|FixinEIP712|FixinReentrancyGuard|FlashWallet|FullMigration|IAllowanceTarget|IBootstrapFeature|IBridgeAdapter|IERC20Bridge|IERC20Transformer|IExchange|IFeature|IFlashWallet|IGasToken|IMetaTransactionsFeature|IOwnableFeature|ISignatureValidatorFeature|ISimpleFunctionRegistryFeature|ITestSimpleFunctionRegistryFeature|ITokenSpenderFeature|ITransformERC20Feature|IZeroEx|InitialMigration|LibBootstrap|LibCommonRichErrors|LibERC20Transformer|LibMetaTransactionsRichErrors|LibMetaTransactionsStorage|LibMigrate|LibOwnableRichErrors|LibOwnableStorage|LibProxyRichErrors|LibProxyStorage|LibReentrancyGuardStorage|LibSignatureRichErrors|LibSignedCallData|LibSimpleFunctionRegistryRichErrors|LibSimpleFunctionRegistryStorage|LibSpenderRichErrors|LibStorage|LibTokenSpenderStorage|LibTransformERC20RichErrors|LibTransformERC20Storage|LibWalletRichErrors|LogMetadataTransformer|MetaTransactionsFeature|MixinAdapterAddresses|MixinBalancer|MixinCurve|MixinKyber|MixinMStable|MixinMooniswap|MixinOasis|MixinUniswap|MixinUniswapV2|MixinZeroExBridge|OwnableFeature|PayTakerTransformer|SignatureValidatorFeature|SimpleFunctionRegistryFeature|TestCallTarget|TestDelegateCaller|TestFillQuoteTransformerBridge|TestFillQuoteTransformerExchange|TestFillQuoteTransformerHost|TestFullMigration|TestInitialMigration|TestMetaTransactionsTransformERC20Feature|TestMigrator|TestMintTokenERC20Transformer|TestMintableERC20Token|TestSimpleFunctionRegistryFeatureImpl1|TestSimpleFunctionRegistryFeatureImpl2|TestTokenSpender|TestTokenSpenderERC20Token|TestTransformERC20|TestTransformerBase|TestTransformerDeployerTransformer|TestTransformerHost|TestWeth|TestWethTransformerHost|TestZeroExFeature|TokenSpenderFeature|TransformERC20Feature|Transformer|TransformerDeployer|WethTransformer|ZeroEx).json" + "abis": "./test/generated-artifacts/@(AffiliateFeeTransformer|AllowanceTarget|BootstrapFeature|BridgeAdapter|FillQuoteTransformer|FixinCommon|FixinEIP712|FixinReentrancyGuard|FlashWallet|FullMigration|IAllowanceTarget|IBootstrapFeature|IBridgeAdapter|IERC20Bridge|IERC20Transformer|IExchange|IFeature|IFlashWallet|IGasToken|IMetaTransactionsFeature|IOwnableFeature|ISignatureValidatorFeature|ISimpleFunctionRegistryFeature|ITestSimpleFunctionRegistryFeature|ITokenSpenderFeature|ITransformERC20Feature|IUniswapFeature|IZeroEx|InitialMigration|LibBootstrap|LibCommonRichErrors|LibERC20Transformer|LibMetaTransactionsRichErrors|LibMetaTransactionsStorage|LibMigrate|LibOwnableRichErrors|LibOwnableStorage|LibProxyRichErrors|LibProxyStorage|LibReentrancyGuardStorage|LibSignatureRichErrors|LibSignedCallData|LibSimpleFunctionRegistryRichErrors|LibSimpleFunctionRegistryStorage|LibSpenderRichErrors|LibStorage|LibTokenSpenderStorage|LibTransformERC20RichErrors|LibTransformERC20Storage|LibWalletRichErrors|LogMetadataTransformer|MetaTransactionsFeature|MixinAdapterAddresses|MixinBalancer|MixinCurve|MixinKyber|MixinMStable|MixinMooniswap|MixinOasis|MixinUniswap|MixinUniswapV2|MixinZeroExBridge|OwnableFeature|PayTakerTransformer|SignatureValidatorFeature|SimpleFunctionRegistryFeature|TestCallTarget|TestDelegateCaller|TestFillQuoteTransformerBridge|TestFillQuoteTransformerExchange|TestFillQuoteTransformerHost|TestFullMigration|TestInitialMigration|TestMetaTransactionsTransformERC20Feature|TestMigrator|TestMintTokenERC20Transformer|TestMintableERC20Token|TestSimpleFunctionRegistryFeatureImpl1|TestSimpleFunctionRegistryFeatureImpl2|TestTokenSpender|TestTokenSpenderERC20Token|TestTransformERC20|TestTransformerBase|TestTransformerDeployerTransformer|TestTransformerHost|TestWeth|TestWethTransformerHost|TestZeroExFeature|TokenSpenderFeature|TransformERC20Feature|Transformer|TransformerDeployer|UniswapFeature|WethTransformer|ZeroEx).json" }, "repository": { "type": "git", diff --git a/contracts/zero-ex/test/artifacts.ts b/contracts/zero-ex/test/artifacts.ts index b0e690901d..d97231955e 100644 --- a/contracts/zero-ex/test/artifacts.ts +++ b/contracts/zero-ex/test/artifacts.ts @@ -32,6 +32,7 @@ import * as ISimpleFunctionRegistryFeature from '../test/generated-artifacts/ISi import * as ITestSimpleFunctionRegistryFeature from '../test/generated-artifacts/ITestSimpleFunctionRegistryFeature.json'; import * as ITokenSpenderFeature from '../test/generated-artifacts/ITokenSpenderFeature.json'; import * as ITransformERC20Feature from '../test/generated-artifacts/ITransformERC20Feature.json'; +import * as IUniswapFeature from '../test/generated-artifacts/IUniswapFeature.json'; import * as IZeroEx from '../test/generated-artifacts/IZeroEx.json'; import * as LibBootstrap from '../test/generated-artifacts/LibBootstrap.json'; import * as LibCommonRichErrors from '../test/generated-artifacts/LibCommonRichErrors.json'; @@ -96,6 +97,7 @@ import * as TokenSpenderFeature from '../test/generated-artifacts/TokenSpenderFe import * as Transformer from '../test/generated-artifacts/Transformer.json'; import * as TransformERC20Feature from '../test/generated-artifacts/TransformERC20Feature.json'; import * as TransformerDeployer from '../test/generated-artifacts/TransformerDeployer.json'; +import * as UniswapFeature from '../test/generated-artifacts/UniswapFeature.json'; import * as WethTransformer from '../test/generated-artifacts/WethTransformer.json'; import * as ZeroEx from '../test/generated-artifacts/ZeroEx.json'; export const artifacts = { @@ -124,12 +126,14 @@ export const artifacts = { ISimpleFunctionRegistryFeature: ISimpleFunctionRegistryFeature as ContractArtifact, ITokenSpenderFeature: ITokenSpenderFeature as ContractArtifact, ITransformERC20Feature: ITransformERC20Feature as ContractArtifact, + IUniswapFeature: IUniswapFeature as ContractArtifact, MetaTransactionsFeature: MetaTransactionsFeature as ContractArtifact, OwnableFeature: OwnableFeature as ContractArtifact, SignatureValidatorFeature: SignatureValidatorFeature as ContractArtifact, SimpleFunctionRegistryFeature: SimpleFunctionRegistryFeature as ContractArtifact, TokenSpenderFeature: TokenSpenderFeature as ContractArtifact, TransformERC20Feature: TransformERC20Feature as ContractArtifact, + UniswapFeature: UniswapFeature as ContractArtifact, LibSignedCallData: LibSignedCallData as ContractArtifact, FixinCommon: FixinCommon as ContractArtifact, FixinEIP712: FixinEIP712 as ContractArtifact, diff --git a/contracts/zero-ex/test/wrappers.ts b/contracts/zero-ex/test/wrappers.ts index 7b775aa0f6..904e4db89a 100644 --- a/contracts/zero-ex/test/wrappers.ts +++ b/contracts/zero-ex/test/wrappers.ts @@ -29,6 +29,7 @@ export * from '../test/generated-wrappers/i_simple_function_registry_feature'; export * from '../test/generated-wrappers/i_test_simple_function_registry_feature'; export * from '../test/generated-wrappers/i_token_spender_feature'; export * from '../test/generated-wrappers/i_transform_erc20_feature'; +export * from '../test/generated-wrappers/i_uniswap_feature'; export * from '../test/generated-wrappers/i_zero_ex'; export * from '../test/generated-wrappers/initial_migration'; export * from '../test/generated-wrappers/lib_bootstrap'; @@ -94,5 +95,6 @@ export * from '../test/generated-wrappers/token_spender_feature'; export * from '../test/generated-wrappers/transform_erc20_feature'; export * from '../test/generated-wrappers/transformer'; export * from '../test/generated-wrappers/transformer_deployer'; +export * from '../test/generated-wrappers/uniswap_feature'; export * from '../test/generated-wrappers/weth_transformer'; export * from '../test/generated-wrappers/zero_ex'; diff --git a/contracts/zero-ex/tsconfig.json b/contracts/zero-ex/tsconfig.json index 9d53f0d9c0..c74120dd9d 100644 --- a/contracts/zero-ex/tsconfig.json +++ b/contracts/zero-ex/tsconfig.json @@ -52,6 +52,7 @@ "test/generated-artifacts/ITestSimpleFunctionRegistryFeature.json", "test/generated-artifacts/ITokenSpenderFeature.json", "test/generated-artifacts/ITransformERC20Feature.json", + "test/generated-artifacts/IUniswapFeature.json", "test/generated-artifacts/IZeroEx.json", "test/generated-artifacts/InitialMigration.json", "test/generated-artifacts/LibBootstrap.json", @@ -117,6 +118,7 @@ "test/generated-artifacts/TransformERC20Feature.json", "test/generated-artifacts/Transformer.json", "test/generated-artifacts/TransformerDeployer.json", + "test/generated-artifacts/UniswapFeature.json", "test/generated-artifacts/WethTransformer.json", "test/generated-artifacts/ZeroEx.json" ], diff --git a/packages/asset-swapper/CHANGELOG.json b/packages/asset-swapper/CHANGELOG.json index df4b66f6ab..dfae646ca3 100644 --- a/packages/asset-swapper/CHANGELOG.json +++ b/packages/asset-swapper/CHANGELOG.json @@ -109,6 +109,14 @@ { "note": "Added `SushiSwap`", "pr": 2698 + }, + { + "note": "Add uniswap VIP support", + "pr": 2703 + }, + { + "note": "Add `includedSources` support", + "pr": 2703 } ] }, diff --git a/packages/asset-swapper/src/constants.ts b/packages/asset-swapper/src/constants.ts index 049f890701..f54b81470d 100644 --- a/packages/asset-swapper/src/constants.ts +++ b/packages/asset-swapper/src/constants.ts @@ -71,6 +71,7 @@ const DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS: ExchangeProxyContractOpts sellTokenFeeAmount: ZERO_AMOUNT, }, refundReceiver: NULL_ADDRESS, + isMetaTransaction: false, }; const DEFAULT_FORWARDER_SWAP_QUOTE_EXECUTE_OPTS: SwapQuoteExecutionOpts = DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS; diff --git a/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts b/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts index a7ce975feb..3ff074ac04 100644 --- a/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts +++ b/packages/asset-swapper/src/quote_consumers/exchange_proxy_swap_quote_consumer.ts @@ -27,11 +27,12 @@ import { SwapQuoteGetOutputOpts, } from '../types'; import { assert } from '../utils/assert'; +import { ERC20BridgeSource, UniswapV2FillData } from '../utils/market_operation_utils/types'; import { getTokenFromAssetData } from '../utils/utils'; // tslint:disable-next-line:custom-no-magic-numbers const MAX_UINT256 = new BigNumber(2).pow(256).minus(1); -const { NULL_ADDRESS } = constants; +const { NULL_ADDRESS, ZERO_AMOUNT } = constants; export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { public readonly provider: ZeroExProvider; @@ -82,16 +83,44 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { opts: Partial = {}, ): Promise { assert.isValidSwapQuote('quote', quote); - // tslint:disable-next-line:no-object-literal-type-assertion - const { refundReceiver, affiliateFee, isFromETH, isToETH } = { + const optsWithDefaults: ExchangeProxyContractOpts = { ...constants.DEFAULT_EXCHANGE_PROXY_EXTENSION_CONTRACT_OPTS, ...opts.extensionContractOpts, - } as ExchangeProxyContractOpts; + }; + // tslint:disable-next-line:no-object-literal-type-assertion + const { refundReceiver, affiliateFee, isFromETH, isToETH } = optsWithDefaults; const sellToken = getTokenFromAssetData(quote.takerAssetData); const buyToken = getTokenFromAssetData(quote.makerAssetData); const sellAmount = quote.worstCaseQuoteInfo.totalTakerAssetAmount; + // VIP routes. + if (isDirectUniswapCompatible(quote, optsWithDefaults)) { + const source = quote.orders[0].fills[0].source; + const fillData = quote.orders[0].fills[0].fillData as UniswapV2FillData; + return { + calldataHexString: this._exchangeProxy + .sellToUniswap( + fillData.tokenAddressPath.map((a, i) => { + if (i === 0 && isFromETH) { + return ETH_TOKEN_ADDRESS; + } + if (i === fillData.tokenAddressPath.length - 1 && isToETH) { + return ETH_TOKEN_ADDRESS; + } + return a; + }), + sellAmount, + quote.worstCaseQuoteInfo.makerAssetAmount, + source === ERC20BridgeSource.SushiSwap, + ) + .getABIEncodedTransactionData(), + ethAmount: isFromETH ? sellAmount : ZERO_AMOUNT, + toAddress: this._exchangeProxy.address, + allowanceTarget: this.contractAddresses.exchangeProxyAllowanceTarget, + }; + } + // Build up the transforms. const transforms = []; if (isFromETH) { @@ -232,3 +261,29 @@ export class ExchangeProxySwapQuoteConsumer implements SwapQuoteConsumerBase { function isBuyQuote(quote: SwapQuote): quote is MarketBuySwapQuote { return quote.type === MarketOperation.Buy; } + +function isDirectUniswapCompatible(quote: SwapQuote, opts: ExchangeProxyContractOpts): boolean { + // Must not be a mtx. + if (opts.isMetaTransaction) { + return false; + } + // Must not have an affiliate fee. + if (!opts.affiliateFee.buyTokenFeeAmount.eq(0) || !opts.affiliateFee.sellTokenFeeAmount.eq(0)) { + return false; + } + // Must be a single order. + if (quote.orders.length !== 1) { + return false; + } + const order = quote.orders[0]; + // With a single underlying fill/source. + if (order.fills.length !== 1) { + return false; + } + const fill = order.fills[0]; + // And that fill must be uniswap v2 or sushiswap. + if (![ERC20BridgeSource.UniswapV2, ERC20BridgeSource.SushiSwap].includes(fill.source)) { + return false; + } + return true; +} diff --git a/packages/asset-swapper/src/swap_quoter.ts b/packages/asset-swapper/src/swap_quoter.ts index 939aaa5d01..7b99bc55b4 100644 --- a/packages/asset-swapper/src/swap_quoter.ts +++ b/packages/asset-swapper/src/swap_quoter.ts @@ -27,6 +27,7 @@ import { calculateLiquidity } from './utils/calculate_liquidity'; import { MarketOperationUtils } from './utils/market_operation_utils'; import { createDummyOrderForSampler } from './utils/market_operation_utils/orders'; import { DexOrderSampler } from './utils/market_operation_utils/sampler'; +import { SourceFilters } from './utils/market_operation_utils/source_filters'; import { ERC20BridgeSource, MarketDepth, @@ -421,13 +422,13 @@ export class SwapQuoter { assert.isString('takerTokenAddress', takerTokenAddress); const makerAssetData = assetDataUtils.encodeERC20AssetData(makerTokenAddress); const takerAssetData = assetDataUtils.encodeERC20AssetData(takerTokenAddress); - let [sellOrders, buyOrders] = - options.excludedSources && options.excludedSources.includes(ERC20BridgeSource.Native) - ? [[], []] - : await Promise.all([ - this.orderbook.getOrdersAsync(makerAssetData, takerAssetData), - this.orderbook.getOrdersAsync(takerAssetData, makerAssetData), - ]); + const sourceFilters = new SourceFilters([], options.excludedSources, options.includedSources); + let [sellOrders, buyOrders] = !sourceFilters.isAllowed(ERC20BridgeSource.Native) + ? [[], []] + : await Promise.all([ + this.orderbook.getOrdersAsync(makerAssetData, takerAssetData), + this.orderbook.getOrdersAsync(takerAssetData, makerAssetData), + ]); if (!sellOrders || sellOrders.length === 0) { sellOrders = [ { @@ -652,12 +653,14 @@ export class SwapQuoter { gasPrice = await this.getGasPriceEstimationOrThrowAsync(); } + const sourceFilters = new SourceFilters([], opts.excludedSources, opts.includedSources); + // If RFQT is enabled and `nativeExclusivelyRFQT` is set, then `ERC20BridgeSource.Native` should // never be excluded. if ( opts.rfqt && opts.rfqt.nativeExclusivelyRFQT === true && - opts.excludedSources.includes(ERC20BridgeSource.Native) + !sourceFilters.isAllowed(ERC20BridgeSource.Native) ) { throw new Error('Native liquidity cannot be excluded if "rfqt.nativeExclusivelyRFQT" is set'); } @@ -666,7 +669,7 @@ export class SwapQuoter { const orderBatchPromises: Array> = []; const skipOpenOrderbook = - opts.excludedSources.includes(ERC20BridgeSource.Native) || + !sourceFilters.isAllowed(ERC20BridgeSource.Native) || (opts.rfqt && opts.rfqt.nativeExclusivelyRFQT === true); if (!skipOpenOrderbook) { orderBatchPromises.push(this._getSignedOrdersAsync(makerAssetData, takerAssetData)); // order book @@ -685,7 +688,7 @@ export class SwapQuoter { opts.rfqt.intentOnFilling && // The requestor is asking for a firm quote opts.rfqt.apiKey && this._isApiKeyWhitelisted(opts.rfqt.apiKey) && // A valid API key was provided - !opts.excludedSources.includes(ERC20BridgeSource.Native) // Native liquidity is not excluded + sourceFilters.isAllowed(ERC20BridgeSource.Native) // Native liquidity is not excluded ) { if (!opts.rfqt.takerAddress || opts.rfqt.takerAddress === constants.NULL_ADDRESS) { throw new Error('RFQ-T requests must specify a taker address'); diff --git a/packages/asset-swapper/src/types.ts b/packages/asset-swapper/src/types.ts index e7d9c8bf6f..6ec4357215 100644 --- a/packages/asset-swapper/src/types.ts +++ b/packages/asset-swapper/src/types.ts @@ -160,6 +160,7 @@ export interface ExchangeProxyContractOpts { isToETH: boolean; affiliateFee: AffiliateFee; refundReceiver: string | ExchangeProxyRefundReceiver; + isMetaTransaction: boolean; } export interface GetExtensionContractTypeOpts { diff --git a/packages/asset-swapper/src/utils/market_operation_utils/balancer_utils.ts b/packages/asset-swapper/src/utils/market_operation_utils/balancer_utils.ts index f4d5d99635..936cd07e37 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/balancer_utils.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/balancer_utils.ts @@ -2,8 +2,6 @@ import { BigNumber } from '@0x/utils'; import { bmath, getPoolsWithTokens, parsePoolData } from '@balancer-labs/sor'; import { Decimal } from 'decimal.js'; -import { ERC20BridgeSource } from './types'; - // tslint:disable:boolean-naming export interface BalancerPool { @@ -67,10 +65,10 @@ export class BalancerPoolsCache { public howToSampleBalancer( takerToken: string, makerToken: string, - excludedSources: ERC20BridgeSource[], + isAllowedSource: boolean, ): { onChain: boolean; offChain: boolean } { // If Balancer is excluded as a source, do not sample. - if (excludedSources.includes(ERC20BridgeSource.Balancer)) { + if (!isAllowedSource) { return { onChain: false, offChain: false }; } const cachedBalancerPools = this.getCachedPoolAddressesForPair(takerToken, makerToken, ONE_DAY_MS); diff --git a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts index 2b758c1b44..2aece92a34 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts @@ -1,5 +1,6 @@ import { BigNumber } from '@0x/utils'; +import { SourceFilters } from './source_filters'; import { CurveFunctionSelectors, CurveInfo, ERC20BridgeSource, GetMarketOrdersOpts } from './types'; // tslint:disable: custom-no-magic-numbers @@ -7,7 +8,8 @@ import { CurveFunctionSelectors, CurveInfo, ERC20BridgeSource, GetMarketOrdersOp /** * Valid sources for market sell. */ -export const SELL_SOURCES = [ +export const SELL_SOURCE_FILTER = new SourceFilters([ + ERC20BridgeSource.Native, ERC20BridgeSource.Uniswap, ERC20BridgeSource.UniswapV2, ERC20BridgeSource.Eth2Dai, @@ -20,29 +22,36 @@ export const SELL_SOURCES = [ ERC20BridgeSource.Mooniswap, ERC20BridgeSource.Swerve, ERC20BridgeSource.SushiSwap, -]; + ERC20BridgeSource.MultiHop, +]); /** * Valid sources for market buy. */ -export const BUY_SOURCES = [ - ERC20BridgeSource.Uniswap, - ERC20BridgeSource.UniswapV2, - ERC20BridgeSource.Eth2Dai, - ERC20BridgeSource.Kyber, - ERC20BridgeSource.Curve, - ERC20BridgeSource.Balancer, - // ERC20BridgeSource.Bancor, // FIXME: Disabled until Bancor SDK supports buy quotes - ERC20BridgeSource.MStable, - ERC20BridgeSource.Mooniswap, - ERC20BridgeSource.Swerve, - ERC20BridgeSource.SushiSwap, -]; +export const BUY_SOURCE_FILTER = new SourceFilters( + [ + ERC20BridgeSource.Native, + ERC20BridgeSource.Uniswap, + ERC20BridgeSource.UniswapV2, + ERC20BridgeSource.Eth2Dai, + ERC20BridgeSource.Kyber, + ERC20BridgeSource.Curve, + ERC20BridgeSource.Balancer, + // ERC20BridgeSource.Bancor, // FIXME: Disabled until Bancor SDK supports buy quotes + ERC20BridgeSource.MStable, + ERC20BridgeSource.Mooniswap, + ERC20BridgeSource.Swerve, + ERC20BridgeSource.SushiSwap, + ERC20BridgeSource.MultiHop, + ], + [ERC20BridgeSource.MultiBridge], +); export const DEFAULT_GET_MARKET_ORDERS_OPTS: GetMarketOrdersOpts = { // tslint:disable-next-line: custom-no-magic-numbers runLimit: 2 ** 15, excludedSources: [], + includedSources: [], bridgeSlippage: 0.005, maxFallbackSlippage: 0.05, numSamples: 13, diff --git a/packages/asset-swapper/src/utils/market_operation_utils/index.ts b/packages/asset-swapper/src/utils/market_operation_utils/index.ts index 1dea80f1f9..672045106e 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/index.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/index.ts @@ -6,15 +6,14 @@ import * as _ from 'lodash'; import { MarketOperation } from '../../types'; import { QuoteRequestor } from '../quote_requestor'; -import { difference } from '../utils'; import { generateQuoteReport } from './../quote_report_generator'; import { - BUY_SOURCES, + BUY_SOURCE_FILTER, DEFAULT_GET_MARKET_ORDERS_OPTS, FEE_QUOTE_SOURCES, ONE_ETHER, - SELL_SOURCES, + SELL_SOURCE_FILTER, ZERO_AMOUNT, } from './constants'; import { createFillPaths, getPathAdjustedRate, getPathAdjustedSlippage } from './fills'; @@ -28,6 +27,7 @@ import { } from './orders'; import { findOptimalPathAsync } from './path_optimizer'; import { DexOrderSampler, getSampleAmounts } from './sampler'; +import { SourceFilters } from './source_filters'; import { AggregationError, DexSample, @@ -58,8 +58,7 @@ export async function getRfqtIndicativeQuotesAsync( assetFillAmount: BigNumber, opts: Partial, ): Promise { - const hasExcludedNativeLiquidity = opts.excludedSources && opts.excludedSources.includes(ERC20BridgeSource.Native); - if (!hasExcludedNativeLiquidity && opts.rfqt && opts.rfqt.isIndicative === true && opts.rfqt.quoteRequestor) { + if (opts.rfqt && opts.rfqt.isIndicative === true && opts.rfqt.quoteRequestor) { return opts.rfqt.quoteRequestor.requestRfqtIndicativeQuotesAsync( makerAssetData, takerAssetData, @@ -75,6 +74,9 @@ export async function getRfqtIndicativeQuotesAsync( export class MarketOperationUtils { private readonly _wethAddress: string; private readonly _multiBridge: string; + private readonly _sellSources: SourceFilters; + private readonly _buySources: SourceFilters; + private readonly _feeSources = new SourceFilters(FEE_QUOTE_SOURCES); constructor( private readonly _sampler: DexOrderSampler, @@ -85,6 +87,15 @@ export class MarketOperationUtils { ) { this._wethAddress = contractAddresses.etherToken.toLowerCase(); this._multiBridge = contractAddresses.multiBridge.toLowerCase(); + const optionalQuoteSources = []; + if (this._liquidityProviderRegistry !== NULL_ADDRESS) { + optionalQuoteSources.push(ERC20BridgeSource.LiquidityProvider); + } + if (this._multiBridge !== NULL_ADDRESS) { + optionalQuoteSources.push(ERC20BridgeSource.MultiBridge); + } + this._buySources = BUY_SOURCE_FILTER.validate(optionalQuoteSources); + this._sellSources = SELL_SOURCE_FILTER.validate(optionalQuoteSources); } /** @@ -105,11 +116,18 @@ export class MarketOperationUtils { const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; const [makerToken, takerToken] = getNativeOrderTokens(nativeOrders[0]); const sampleAmounts = getSampleAmounts(takerAmount, _opts.numSamples, _opts.sampleDistributionBase); + const requestFilters = new SourceFilters().exclude(_opts.excludedSources).include(_opts.includedSources); + const feeSourceFilters = this._feeSources.merge(requestFilters); + const quoteSourceFilters = this._sellSources.merge(requestFilters); const { onChain: sampleBalancerOnChain, offChain: sampleBalancerOffChain, - } = this._sampler.balancerPoolsCache.howToSampleBalancer(takerToken, makerToken, _opts.excludedSources); + } = this._sampler.balancerPoolsCache.howToSampleBalancer( + takerToken, + makerToken, + quoteSourceFilters.isAllowed(ERC20BridgeSource.Balancer), + ); // Call the sampler contract. const samplerPromise = this._sampler.executeAsync( @@ -117,7 +135,7 @@ export class MarketOperationUtils { this._sampler.getOrderFillableTakerAmounts(nativeOrders, this.contractAddresses.exchange), // Get ETH -> maker token price. this._sampler.getMedianSellRate( - difference(FEE_QUOTE_SOURCES, _opts.excludedSources), + feeSourceFilters.sources, makerToken, this._wethAddress, ONE_ETHER, @@ -127,7 +145,7 @@ export class MarketOperationUtils { ), // Get ETH -> taker token price. this._sampler.getMedianSellRate( - difference(FEE_QUOTE_SOURCES, _opts.excludedSources), + feeSourceFilters.sources, takerToken, this._wethAddress, ONE_ETHER, @@ -137,10 +155,7 @@ export class MarketOperationUtils { ), // Get sell quotes for taker -> maker. this._sampler.getSellQuotes( - difference( - SELL_SOURCES.concat(this._optionalSources()), - _opts.excludedSources.concat(sampleBalancerOnChain ? [] : ERC20BridgeSource.Balancer), - ), + quoteSourceFilters.exclude(sampleBalancerOnChain ? [] : ERC20BridgeSource.Balancer).sources, makerToken, takerToken, sampleAmounts, @@ -148,37 +163,34 @@ export class MarketOperationUtils { this._liquidityProviderRegistry, this._multiBridge, ), - _opts.excludedSources.includes(ERC20BridgeSource.MultiHop) - ? DexOrderSampler.constant([]) - : this._sampler.getTwoHopSellQuotes( - difference( - SELL_SOURCES.concat(this._optionalSources()), - _opts.excludedSources.concat(ERC20BridgeSource.MultiBridge), - ), - makerToken, - takerToken, - takerAmount, - this._tokenAdjacencyGraph, - this._wethAddress, - this._liquidityProviderRegistry, - ), + this._sampler.getTwoHopSellQuotes( + quoteSourceFilters.isAllowed(ERC20BridgeSource.MultiHop) ? quoteSourceFilters.sources : [], + makerToken, + takerToken, + takerAmount, + this._tokenAdjacencyGraph, + this._wethAddress, + this._liquidityProviderRegistry, + ), ); - const rfqtPromise = getRfqtIndicativeQuotesAsync( - nativeOrders[0].makerAssetData, - nativeOrders[0].takerAssetData, - MarketOperation.Sell, - takerAmount, - _opts, - ); + const rfqtPromise = quoteSourceFilters.isAllowed(ERC20BridgeSource.Native) + ? getRfqtIndicativeQuotesAsync( + nativeOrders[0].makerAssetData, + nativeOrders[0].takerAssetData, + MarketOperation.Sell, + takerAmount, + _opts, + ) + : Promise.resolve([]); const offChainBalancerPromise = sampleBalancerOffChain ? this._sampler.getBalancerSellQuotesOffChainAsync(makerToken, takerToken, sampleAmounts) : Promise.resolve([]); - const offChainBancorPromise = _opts.excludedSources.includes(ERC20BridgeSource.Bancor) - ? Promise.resolve([]) - : this._sampler.getBancorSellQuotesOffChainAsync(makerToken, takerToken, sampleAmounts); + const offChainBancorPromise = quoteSourceFilters.isAllowed(ERC20BridgeSource.Bancor) + ? this._sampler.getBancorSellQuotesOffChainAsync(makerToken, takerToken, [takerAmount]) + : Promise.resolve([]); const [ [orderFillableAmounts, ethToMakerAssetRate, ethToTakerAssetRate, dexQuotes, twoHopQuotes], @@ -220,11 +232,18 @@ export class MarketOperationUtils { const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; const [makerToken, takerToken] = getNativeOrderTokens(nativeOrders[0]); const sampleAmounts = getSampleAmounts(makerAmount, _opts.numSamples, _opts.sampleDistributionBase); + const requestFilters = new SourceFilters().exclude(_opts.excludedSources).include(_opts.includedSources); + const feeSourceFilters = this._feeSources.merge(requestFilters); + const quoteSourceFilters = this._buySources.merge(requestFilters); const { onChain: sampleBalancerOnChain, offChain: sampleBalancerOffChain, - } = this._sampler.balancerPoolsCache.howToSampleBalancer(takerToken, makerToken, _opts.excludedSources); + } = this._sampler.balancerPoolsCache.howToSampleBalancer( + takerToken, + makerToken, + quoteSourceFilters.isAllowed(ERC20BridgeSource.Balancer), + ); // Call the sampler contract. const samplerPromise = this._sampler.executeAsync( @@ -232,7 +251,7 @@ export class MarketOperationUtils { this._sampler.getOrderFillableMakerAmounts(nativeOrders, this.contractAddresses.exchange), // Get ETH -> makerToken token price. this._sampler.getMedianSellRate( - difference(FEE_QUOTE_SOURCES, _opts.excludedSources), + feeSourceFilters.sources, makerToken, this._wethAddress, ONE_ETHER, @@ -242,7 +261,7 @@ export class MarketOperationUtils { ), // Get ETH -> taker token price. this._sampler.getMedianSellRate( - difference(FEE_QUOTE_SOURCES, _opts.excludedSources), + feeSourceFilters.sources, takerToken, this._wethAddress, ONE_ETHER, @@ -252,49 +271,38 @@ export class MarketOperationUtils { ), // Get buy quotes for taker -> maker. this._sampler.getBuyQuotes( - difference( - BUY_SOURCES.concat( - this._liquidityProviderRegistry !== NULL_ADDRESS ? [ERC20BridgeSource.LiquidityProvider] : [], - ), - _opts.excludedSources.concat(sampleBalancerOnChain ? [] : ERC20BridgeSource.Balancer), - ), + quoteSourceFilters.exclude(sampleBalancerOnChain ? [] : ERC20BridgeSource.Balancer).sources, makerToken, takerToken, sampleAmounts, this._wethAddress, this._liquidityProviderRegistry, ), - _opts.excludedSources.includes(ERC20BridgeSource.MultiHop) - ? DexOrderSampler.constant([]) - : this._sampler.getTwoHopBuyQuotes( - difference( - BUY_SOURCES.concat( - this._liquidityProviderRegistry !== NULL_ADDRESS - ? [ERC20BridgeSource.LiquidityProvider] - : [], - ), - _opts.excludedSources, - ), - makerToken, - takerToken, - makerAmount, - this._tokenAdjacencyGraph, - this._wethAddress, - this._liquidityProviderRegistry, - ), + this._sampler.getTwoHopBuyQuotes( + quoteSourceFilters.isAllowed(ERC20BridgeSource.MultiHop) ? quoteSourceFilters.sources : [], + makerToken, + takerToken, + makerAmount, + this._tokenAdjacencyGraph, + this._wethAddress, + this._liquidityProviderRegistry, + ), ); + const rfqtPromise = quoteSourceFilters.isAllowed(ERC20BridgeSource.Native) + ? getRfqtIndicativeQuotesAsync( + nativeOrders[0].makerAssetData, + nativeOrders[0].takerAssetData, + MarketOperation.Buy, + makerAmount, + _opts, + ) + : Promise.resolve([]); + const offChainBalancerPromise = sampleBalancerOffChain ? this._sampler.getBalancerBuyQuotesOffChainAsync(makerToken, takerToken, sampleAmounts) : Promise.resolve([]); - const rfqtPromise = getRfqtIndicativeQuotesAsync( - nativeOrders[0].makerAssetData, - nativeOrders[0].takerAssetData, - MarketOperation.Buy, - makerAmount, - _opts, - ); const [ [orderFillableAmounts, ethToMakerAssetRate, ethToTakerAssetRate, dexQuotes, twoHopQuotes], rfqtIndicativeQuotes, @@ -394,14 +402,17 @@ export class MarketOperationUtils { } const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; - const sources = difference(BUY_SOURCES, _opts.excludedSources.concat(ERC20BridgeSource.Balancer)); + const requestFilters = new SourceFilters().exclude(_opts.excludedSources).include(_opts.includedSources); + const feeSourceFilters = this._feeSources.merge(requestFilters); + const quoteSourceFilters = this._buySources.merge(requestFilters); + const ops = [ ...batchNativeOrders.map(orders => this._sampler.getOrderFillableMakerAmounts(orders, this.contractAddresses.exchange), ), ...batchNativeOrders.map(orders => this._sampler.getMedianSellRate( - difference(FEE_QUOTE_SOURCES, _opts.excludedSources), + feeSourceFilters.sources, getNativeOrderTokens(orders[0])[1], this._wethAddress, ONE_ETHER, @@ -410,7 +421,7 @@ export class MarketOperationUtils { ), ...batchNativeOrders.map((orders, i) => this._sampler.getBuyQuotes( - sources, + quoteSourceFilters.sources, getNativeOrderTokens(orders[0])[0], getNativeOrderTokens(orders[0])[1], [makerAmounts[i]], @@ -593,12 +604,6 @@ export class MarketOperationUtils { : undefined; return { optimizedOrders, quoteReport, isTwoHop: false }; } - - private _optionalSources(): ERC20BridgeSource[] { - return (this._liquidityProviderRegistry !== NULL_ADDRESS ? [ERC20BridgeSource.LiquidityProvider] : []).concat( - this._multiBridge !== NULL_ADDRESS ? [ERC20BridgeSource.MultiBridge] : [], - ); - } } // tslint:disable: max-file-line-count diff --git a/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts b/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts index 71df073f26..62bcaeba2b 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts @@ -13,6 +13,7 @@ import { getKyberReserveIdsForPair } from './kyber_utils'; import { getMultiBridgeIntermediateToken } from './multibridge_utils'; import { getIntermediateTokens } from './multihop_utils'; import { SamplerContractOperation } from './sampler_contract_operation'; +import { SourceFilters } from './source_filters'; import { BalancerFillData, BancorFillData, @@ -35,6 +36,19 @@ import { UniswapV2FillData, } from './types'; +/** + * Source filters for `getTwoHopBuyQuotes()` and `getTwoHopSellQuotes()`. + */ +export const TWO_HOP_SOURCE_FILTERS = SourceFilters.all().exclude([ + ERC20BridgeSource.MultiHop, + ERC20BridgeSource.MultiBridge, + ERC20BridgeSource.Native, +]); +/** + * Source filters for `getSellQuotes()` and `getBuyQuotes()`. + */ +export const BATCH_SOURCE_FILTERS = SourceFilters.all().exclude([ERC20BridgeSource.MultiHop, ERC20BridgeSource.Native]); + // tslint:disable:no-inferred-empty-object-type no-unbound-method /** @@ -553,10 +567,14 @@ export class SamplerOperations { wethAddress: string, liquidityProviderRegistryAddress?: string, ): BatchedOperation>> { + const _sources = TWO_HOP_SOURCE_FILTERS.getAllowed(sources); + if (_sources.length === 0) { + return SamplerOperations.constant([]); + } const intermediateTokens = getIntermediateTokens(makerToken, takerToken, tokenAdjacencyGraph, wethAddress); const subOps = intermediateTokens.map(intermediateToken => { const firstHopOps = this._getSellQuoteOperations( - sources, + _sources, intermediateToken, takerToken, [ZERO_AMOUNT], @@ -564,7 +582,7 @@ export class SamplerOperations { liquidityProviderRegistryAddress, ); const secondHopOps = this._getSellQuoteOperations( - sources, + _sources, makerToken, intermediateToken, [ZERO_AMOUNT], @@ -624,10 +642,14 @@ export class SamplerOperations { wethAddress: string, liquidityProviderRegistryAddress?: string, ): BatchedOperation>> { + const _sources = TWO_HOP_SOURCE_FILTERS.getAllowed(sources); + if (_sources.length === 0) { + return SamplerOperations.constant([]); + } const intermediateTokens = getIntermediateTokens(makerToken, takerToken, tokenAdjacencyGraph, wethAddress); const subOps = intermediateTokens.map(intermediateToken => { const firstHopOps = this._getBuyQuoteOperations( - sources, + _sources, intermediateToken, takerToken, [new BigNumber(0)], @@ -635,7 +657,7 @@ export class SamplerOperations { liquidityProviderRegistryAddress, ); const secondHopOps = this._getBuyQuoteOperations( - sources, + _sources, makerToken, intermediateToken, [new BigNumber(0)], @@ -776,8 +798,9 @@ export class SamplerOperations { liquidityProviderRegistryAddress?: string, multiBridgeAddress?: string, ): BatchedOperation { + const _sources = BATCH_SOURCE_FILTERS.getAllowed(sources); const subOps = this._getSellQuoteOperations( - sources, + _sources, makerToken, takerToken, takerFillAmounts, @@ -816,8 +839,9 @@ export class SamplerOperations { wethAddress: string, liquidityProviderRegistryAddress?: string, ): BatchedOperation { + const _sources = BATCH_SOURCE_FILTERS.getAllowed(sources); const subOps = this._getBuyQuoteOperations( - sources, + _sources, makerToken, takerToken, makerFillAmounts, diff --git a/packages/asset-swapper/src/utils/market_operation_utils/source_filters.ts b/packages/asset-swapper/src/utils/market_operation_utils/source_filters.ts new file mode 100644 index 0000000000..407673c02a --- /dev/null +++ b/packages/asset-swapper/src/utils/market_operation_utils/source_filters.ts @@ -0,0 +1,95 @@ +import * as _ from 'lodash'; + +import { ERC20BridgeSource } from './types'; + +export class SourceFilters { + // All valid sources. + private readonly _validSources: ERC20BridgeSource[]; + // Sources in `_validSources` that are not allowed. + private readonly _excludedSources: ERC20BridgeSource[]; + // Sources in `_validSources` that are only allowed. + private readonly _includedSources: ERC20BridgeSource[]; + + public static all(): SourceFilters { + return new SourceFilters(Object.values(ERC20BridgeSource)); + } + + constructor( + validSources: ERC20BridgeSource[] = [], + excludedSources: ERC20BridgeSource[] = [], + includedSources: ERC20BridgeSource[] = [], + ) { + this._validSources = _.uniq(validSources); + this._excludedSources = _.uniq(excludedSources); + this._includedSources = _.uniq(includedSources); + } + + public isAllowed(source: ERC20BridgeSource): boolean { + // Must be in list of valid sources. + if (this._validSources.length > 0 && !this._validSources.includes(source)) { + return false; + } + // Must not be excluded. + if (this._excludedSources.includes(source)) { + return false; + } + // If we have an inclusion list, it must be in that list. + if (this._includedSources.length > 0 && !this._includedSources.includes(source)) { + return false; + } + return true; + } + + public areAnyAllowed(sources: ERC20BridgeSource[]): boolean { + return sources.some(s => this.isAllowed(s)); + } + + public areAllAllowed(sources: ERC20BridgeSource[]): boolean { + return sources.every(s => this.isAllowed(s)); + } + + public getAllowed(sources: ERC20BridgeSource[] = []): ERC20BridgeSource[] { + return sources.filter(s => this.isAllowed(s)); + } + + public get sources(): ERC20BridgeSource[] { + return this._validSources.filter(s => this.isAllowed(s)); + } + + public exclude(sources: ERC20BridgeSource | ERC20BridgeSource[]): SourceFilters { + return new SourceFilters( + this._validSources, + [...this._excludedSources, ...(Array.isArray(sources) ? sources : [sources])], + this._includedSources, + ); + } + + public validate(sources: ERC20BridgeSource | ERC20BridgeSource[]): SourceFilters { + return new SourceFilters( + [...this._validSources, ...(Array.isArray(sources) ? sources : [sources])], + this._excludedSources, + this._includedSources, + ); + } + + public include(sources: ERC20BridgeSource | ERC20BridgeSource[]): SourceFilters { + return new SourceFilters(this._validSources, this._excludedSources, [ + ...this._includedSources, + ...(Array.isArray(sources) ? sources : [sources]), + ]); + } + + public merge(other: SourceFilters): SourceFilters { + let validSources = this._validSources; + if (validSources.length === 0) { + validSources = other._validSources; + } else if (other._validSources.length !== 0) { + validSources = validSources.filter(s => other._validSources.includes(s)); + } + return new SourceFilters( + validSources, + [...this._excludedSources, ...other._excludedSources], + [...this._includedSources, ...other._includedSources], + ); + } +} diff --git a/packages/asset-swapper/src/utils/market_operation_utils/types.ts b/packages/asset-swapper/src/utils/market_operation_utils/types.ts index 72ed930048..2bbee4dc81 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/types.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/types.ts @@ -243,6 +243,11 @@ export interface GetMarketOrdersOpts { * Liquidity sources to exclude. Default is none. */ excludedSources: ERC20BridgeSource[]; + /** + * Liquidity sources to include. Default is none, which allows all supported + * sources that aren't excluded by `excludedSources`. + */ + includedSources: ERC20BridgeSource[]; /** * Complexity limit on the search algorithm, i.e., maximum number of * nodes to visit. Default is 1024. diff --git a/packages/asset-swapper/src/utils/utils.ts b/packages/asset-swapper/src/utils/utils.ts index d5d8a9e5e1..bea2e48a40 100644 --- a/packages/asset-swapper/src/utils/utils.ts +++ b/packages/asset-swapper/src/utils/utils.ts @@ -107,13 +107,6 @@ export function isERC20EquivalentAssetData(assetData: AssetData): assetData is E return assetDataUtils.isERC20TokenAssetData(assetData) || assetDataUtils.isERC20BridgeAssetData(assetData); } -/** - * Gets the difference between two sets. - */ -export function difference(a: T[], b: T[]): T[] { - return a.filter(x => b.indexOf(x) === -1); -} - export function getTokenFromAssetData(assetData: string): string { const data = assetDataUtils.decodeAssetDataOrThrow(assetData); if (data.assetProxyId !== AssetProxyId.ERC20 && data.assetProxyId !== AssetProxyId.ERC20Bridge) { diff --git a/packages/asset-swapper/test/market_operation_utils_test.ts b/packages/asset-swapper/test/market_operation_utils_test.ts index aeea772c05..77261f3b6b 100644 --- a/packages/asset-swapper/test/market_operation_utils_test.ts +++ b/packages/asset-swapper/test/market_operation_utils_test.ts @@ -18,10 +18,22 @@ import * as TypeMoq from 'typemoq'; import { MarketOperation, QuoteRequestor, RfqtRequestOpts, SignedOrderWithFillableAmounts } from '../src'; import { getRfqtIndicativeQuotesAsync, MarketOperationUtils } from '../src/utils/market_operation_utils/'; import { BalancerPoolsCache } from '../src/utils/market_operation_utils/balancer_utils'; -import { BUY_SOURCES, POSITIVE_INF, SELL_SOURCES, ZERO_AMOUNT } from '../src/utils/market_operation_utils/constants'; +import { + BUY_SOURCE_FILTER, + POSITIVE_INF, + SELL_SOURCE_FILTER, + ZERO_AMOUNT, +} from '../src/utils/market_operation_utils/constants'; import { createFillPaths } from '../src/utils/market_operation_utils/fills'; import { DexOrderSampler } from '../src/utils/market_operation_utils/sampler'; -import { DexSample, ERC20BridgeSource, FillData, NativeFillData } from '../src/utils/market_operation_utils/types'; +import { BATCH_SOURCE_FILTERS } from '../src/utils/market_operation_utils/sampler_operations'; +import { + DexSample, + ERC20BridgeSource, + FillData, + NativeFillData, + OptimizedMarketOrder, +} from '../src/utils/market_operation_utils/types'; const MAKER_TOKEN = randomAddress(); const TAKER_TOKEN = randomAddress(); @@ -36,7 +48,10 @@ const DEFAULT_EXCLUDED = [ ERC20BridgeSource.Bancor, ERC20BridgeSource.Swerve, ERC20BridgeSource.SushiSwap, + ERC20BridgeSource.MultiHop, ]; +const BUY_SOURCES = BUY_SOURCE_FILTER.sources; +const SELL_SOURCES = SELL_SOURCE_FILTER.sources; // tslint:disable: custom-no-magic-numbers promise-function-async describe('MarketOperationUtils tests', () => { @@ -167,7 +182,7 @@ describe('MarketOperationUtils tests', () => { fillAmounts: BigNumber[], _wethAddress: string, ) => { - return sources.map(s => createSamplesFromRates(s, fillAmounts, rates[s])); + return BATCH_SOURCE_FILTERS.getAllowed(sources).map(s => createSamplesFromRates(s, fillAmounts, rates[s])); }; } @@ -209,7 +224,9 @@ describe('MarketOperationUtils tests', () => { fillAmounts: BigNumber[], _wethAddress: string, ) => { - return sources.map(s => createSamplesFromRates(s, fillAmounts, rates[s].map(r => new BigNumber(1).div(r)))); + return BATCH_SOURCE_FILTERS.getAllowed(sources).map(s => + createSamplesFromRates(s, fillAmounts, rates[s].map(r => new BigNumber(1).div(r))), + ); }; } @@ -244,6 +261,27 @@ describe('MarketOperationUtils tests', () => { return rates; } + function getSortedOrderSources(side: MarketOperation, orders: OptimizedMarketOrder[]): ERC20BridgeSource[][] { + return ( + orders + // Sort orders by descending rate. + .sort((a, b) => + b.makerAssetAmount.div(b.takerAssetAmount).comparedTo(a.makerAssetAmount.div(a.takerAssetAmount)), + ) + // Then sort fills by descending rate. + .map(o => { + return o.fills + .slice() + .sort((a, b) => + side === MarketOperation.Sell + ? b.output.div(b.input).comparedTo(a.output.div(a.input)) + : b.input.div(b.output).comparedTo(a.input.div(a.output)), + ) + .map(f => f.source); + }) + ); + } + const NUM_SAMPLES = 3; interface RatesBySource { @@ -265,6 +303,7 @@ describe('MarketOperationUtils tests', () => { [ERC20BridgeSource.Mooniswap]: _.times(NUM_SAMPLES, () => 0), [ERC20BridgeSource.Swerve]: _.times(NUM_SAMPLES, () => 0), [ERC20BridgeSource.SushiSwap]: _.times(NUM_SAMPLES, () => 0), + [ERC20BridgeSource.MultiHop]: _.times(NUM_SAMPLES, () => 0), }; const DEFAULT_RATES: RatesBySource = { @@ -307,6 +346,9 @@ describe('MarketOperationUtils tests', () => { }, [ERC20BridgeSource.LiquidityProvider]: { poolAddress: randomAddress() }, [ERC20BridgeSource.SushiSwap]: { tokenAddressPath: [] }, + [ERC20BridgeSource.Mooniswap]: { poolAddress: randomAddress() }, + [ERC20BridgeSource.Native]: { order: createOrder() }, + [ERC20BridgeSource.MultiHop]: {}, }; const DEFAULT_OPS = { @@ -356,7 +398,7 @@ describe('MarketOperationUtils tests', () => { const MOCK_SAMPLER = ({ async executeAsync(...ops: any[]): Promise { - return ops; + return MOCK_SAMPLER.executeBatchAsync(ops); }, async executeBatchAsync(ops: any[]): Promise { return ops; @@ -377,33 +419,7 @@ describe('MarketOperationUtils tests', () => { intentOnFilling: false, }; - it('returns an empty array if native liquidity is excluded from the salad', async () => { - const requestor = TypeMoq.Mock.ofType(QuoteRequestor, TypeMoq.MockBehavior.Strict); - const result = await getRfqtIndicativeQuotesAsync( - MAKER_ASSET_DATA, - TAKER_ASSET_DATA, - MarketOperation.Sell, - new BigNumber('100e18'), - { - rfqt: { quoteRequestor: requestor.object, ...partialRfqt }, - excludedSources: [ERC20BridgeSource.Native], - }, - ); - expect(result.length).to.eql(0); - requestor.verify( - r => - r.requestRfqtIndicativeQuotesAsync( - TypeMoq.It.isAny(), - TypeMoq.It.isAny(), - TypeMoq.It.isAny(), - TypeMoq.It.isAny(), - TypeMoq.It.isAny(), - ), - TypeMoq.Times.never(), - ); - }); - - it('calls RFQT if Native source is not excluded', async () => { + it('calls RFQT', async () => { const requestor = TypeMoq.Mock.ofType(QuoteRequestor, TypeMoq.MockBehavior.Loose); requestor .setup(r => @@ -424,7 +440,6 @@ describe('MarketOperationUtils tests', () => { new BigNumber('100e18'), { rfqt: { quoteRequestor: requestor.object, ...partialRfqt }, - excludedSources: [], }, ); requestor.verifyAll(); @@ -481,6 +496,10 @@ describe('MarketOperationUtils tests', () => { sourcesPolled = sourcesPolled.concat(sources.slice()); return DEFAULT_OPS.getSellQuotes(sources, makerToken, takerToken, amounts, wethAddress); }, + getTwoHopSellQuotes: (...args: any[]) => { + sourcesPolled.push(ERC20BridgeSource.MultiHop); + return DEFAULT_OPS.getTwoHopSellQuotes(...args); + }, getBalancerSellQuotesOffChainAsync: ( makerToken: string, takerToken: string, @@ -494,7 +513,7 @@ describe('MarketOperationUtils tests', () => { ...DEFAULT_OPTS, excludedSources: [], }); - expect(sourcesPolled.sort()).to.deep.equals(SELL_SOURCES.slice().sort()); + expect(_.uniq(sourcesPolled).sort()).to.deep.equals(SELL_SOURCES.slice().sort()); }); it('polls the liquidity provider when the registry is provided in the arguments', async () => { @@ -504,6 +523,13 @@ describe('MarketOperationUtils tests', () => { ); replaceSamplerOps({ getSellQuotes: fn, + getTwoHopSellQuotes: (sources: ERC20BridgeSource[], ..._args: any[]) => { + if (sources.length !== 0) { + args.sources.push(ERC20BridgeSource.MultiHop); + args.sources.push(...sources); + } + return DEFAULT_OPS.getTwoHopSellQuotes(..._args); + }, getBalancerSellQuotesOffChainAsync: ( makerToken: string, takerToken: string, @@ -524,20 +550,27 @@ describe('MarketOperationUtils tests', () => { ...DEFAULT_OPTS, excludedSources: [], }); - expect(args.sources.sort()).to.deep.equals( + expect(_.uniq(args.sources).sort()).to.deep.equals( SELL_SOURCES.concat([ERC20BridgeSource.LiquidityProvider]).sort(), ); expect(args.liquidityProviderAddress).to.eql(registryAddress); }); it('does not poll DEXes in `excludedSources`', async () => { - const excludedSources = _.sampleSize(SELL_SOURCES, _.random(1, SELL_SOURCES.length)); + const excludedSources = [ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai]; let sourcesPolled: ERC20BridgeSource[] = []; replaceSamplerOps({ getSellQuotes: (sources, makerToken, takerToken, amounts, wethAddress) => { sourcesPolled = sourcesPolled.concat(sources.slice()); return DEFAULT_OPS.getSellQuotes(sources, makerToken, takerToken, amounts, wethAddress); }, + getTwoHopSellQuotes: (sources: ERC20BridgeSource[], ...args: any[]) => { + if (sources.length !== 0) { + sourcesPolled.push(ERC20BridgeSource.MultiHop); + sourcesPolled.push(...sources); + } + return DEFAULT_OPS.getTwoHopSellQuotes(...args); + }, getBalancerSellQuotesOffChainAsync: ( makerToken: string, takerToken: string, @@ -551,7 +584,39 @@ describe('MarketOperationUtils tests', () => { ...DEFAULT_OPTS, excludedSources, }); - expect(sourcesPolled.sort()).to.deep.equals(_.without(SELL_SOURCES, ...excludedSources).sort()); + expect(_.uniq(sourcesPolled).sort()).to.deep.equals(_.without(SELL_SOURCES, ...excludedSources).sort()); + }); + + it('only polls DEXes in `includedSources`', async () => { + const includedSources = [ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai]; + let sourcesPolled: ERC20BridgeSource[] = []; + replaceSamplerOps({ + getSellQuotes: (sources, makerToken, takerToken, amounts, wethAddress) => { + sourcesPolled = sourcesPolled.concat(sources.slice()); + return DEFAULT_OPS.getSellQuotes(sources, makerToken, takerToken, amounts, wethAddress); + }, + getTwoHopSellQuotes: (sources: ERC20BridgeSource[], ...args: any[]) => { + if (sources.length !== 0) { + sourcesPolled.push(ERC20BridgeSource.MultiHop); + sourcesPolled.push(...sources); + } + return DEFAULT_OPS.getTwoHopSellQuotes(sources, ...args); + }, + getBalancerSellQuotesOffChainAsync: ( + makerToken: string, + takerToken: string, + takerFillAmounts: BigNumber[], + ) => { + sourcesPolled = sourcesPolled.concat(ERC20BridgeSource.Balancer); + return DEFAULT_OPS.getBalancerSellQuotesOffChainAsync(makerToken, takerToken, takerFillAmounts); + }, + }); + await marketOperationUtils.getMarketSellOrdersAsync(ORDERS, FILL_AMOUNT, { + ...DEFAULT_OPTS, + excludedSources: [], + includedSources, + }); + expect(_.uniq(sourcesPolled).sort()).to.deep.equals(includedSources.sort()); }); it('generates bridge orders with correct asset data', async () => { @@ -858,7 +923,7 @@ describe('MarketOperationUtils tests', () => { ); const improvedOrders = improvedOrdersResponse.optimizedOrders; expect(improvedOrders).to.be.length(3); - const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source)); + const orderFillSources = getSortedOrderSources(MarketOperation.Sell, improvedOrders); expect(orderFillSources).to.deep.eq([ [ERC20BridgeSource.Uniswap], [ERC20BridgeSource.Native], @@ -910,6 +975,13 @@ describe('MarketOperationUtils tests', () => { sourcesPolled = sourcesPolled.concat(sources.slice()); return DEFAULT_OPS.getBuyQuotes(sources, makerToken, takerToken, amounts, wethAddress); }, + getTwoHopBuyQuotes: (sources: ERC20BridgeSource[], ..._args: any[]) => { + if (sources.length !== 0) { + sourcesPolled.push(ERC20BridgeSource.MultiHop); + sourcesPolled.push(...sources); + } + return DEFAULT_OPS.getTwoHopBuyQuotes(..._args); + }, getBalancerBuyQuotesOffChainAsync: ( makerToken: string, takerToken: string, @@ -923,7 +995,7 @@ describe('MarketOperationUtils tests', () => { ...DEFAULT_OPTS, excludedSources: [], }); - expect(sourcesPolled.sort()).to.deep.equals(BUY_SOURCES.sort()); + expect(_.uniq(sourcesPolled).sort()).to.deep.equals(BUY_SOURCES.sort()); }); it('polls the liquidity provider when the registry is provided in the arguments', async () => { @@ -933,6 +1005,13 @@ describe('MarketOperationUtils tests', () => { ); replaceSamplerOps({ getBuyQuotes: fn, + getTwoHopBuyQuotes: (sources: ERC20BridgeSource[], ..._args: any[]) => { + if (sources.length !== 0) { + args.sources.push(ERC20BridgeSource.MultiHop); + args.sources.push(...sources); + } + return DEFAULT_OPS.getTwoHopBuyQuotes(..._args); + }, getBalancerBuyQuotesOffChainAsync: ( makerToken: string, takerToken: string, @@ -953,20 +1032,27 @@ describe('MarketOperationUtils tests', () => { ...DEFAULT_OPTS, excludedSources: [], }); - expect(args.sources.sort()).to.deep.eq( + expect(_.uniq(args.sources).sort()).to.deep.eq( BUY_SOURCES.concat([ERC20BridgeSource.LiquidityProvider]).sort(), ); expect(args.liquidityProviderAddress).to.eql(registryAddress); }); it('does not poll DEXes in `excludedSources`', async () => { - const excludedSources = _.sampleSize(SELL_SOURCES, _.random(1, SELL_SOURCES.length)); + const excludedSources = [ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai]; let sourcesPolled: ERC20BridgeSource[] = []; replaceSamplerOps({ getBuyQuotes: (sources, makerToken, takerToken, amounts, wethAddress) => { sourcesPolled = sourcesPolled.concat(sources.slice()); return DEFAULT_OPS.getBuyQuotes(sources, makerToken, takerToken, amounts, wethAddress); }, + getTwoHopBuyQuotes: (sources: ERC20BridgeSource[], ..._args: any[]) => { + if (sources.length !== 0) { + sourcesPolled.push(ERC20BridgeSource.MultiHop); + sourcesPolled.push(...sources); + } + return DEFAULT_OPS.getTwoHopBuyQuotes(..._args); + }, getBalancerBuyQuotesOffChainAsync: ( makerToken: string, takerToken: string, @@ -980,7 +1066,39 @@ describe('MarketOperationUtils tests', () => { ...DEFAULT_OPTS, excludedSources, }); - expect(sourcesPolled.sort()).to.deep.eq(_.without(BUY_SOURCES, ...excludedSources).sort()); + expect(_.uniq(sourcesPolled).sort()).to.deep.eq(_.without(BUY_SOURCES, ...excludedSources).sort()); + }); + + it('only polls DEXes in `includedSources`', async () => { + const includedSources = [ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai]; + let sourcesPolled: ERC20BridgeSource[] = []; + replaceSamplerOps({ + getBuyQuotes: (sources, makerToken, takerToken, amounts, wethAddress) => { + sourcesPolled = sourcesPolled.concat(sources.slice()); + return DEFAULT_OPS.getBuyQuotes(sources, makerToken, takerToken, amounts, wethAddress); + }, + getTwoHopBuyQuotes: (sources: ERC20BridgeSource[], ..._args: any[]) => { + if (sources.length !== 0) { + sourcesPolled.push(ERC20BridgeSource.MultiHop); + sourcesPolled.push(...sources); + } + return DEFAULT_OPS.getTwoHopBuyQuotes(..._args); + }, + getBalancerBuyQuotesOffChainAsync: ( + makerToken: string, + takerToken: string, + makerFillAmounts: BigNumber[], + ) => { + sourcesPolled = sourcesPolled.concat(ERC20BridgeSource.Balancer); + return DEFAULT_OPS.getBalancerBuyQuotesOffChainAsync(makerToken, takerToken, makerFillAmounts); + }, + }); + await marketOperationUtils.getMarketBuyOrdersAsync(ORDERS, FILL_AMOUNT, { + ...DEFAULT_OPTS, + excludedSources: [], + includedSources, + }); + expect(_.uniq(sourcesPolled).sort()).to.deep.eq(includedSources.sort()); }); it('generates bridge orders with correct asset data', async () => { @@ -1198,7 +1316,7 @@ describe('MarketOperationUtils tests', () => { ); const improvedOrders = improvedOrdersResponse.optimizedOrders; expect(improvedOrders).to.be.length(2); - const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source)); + const orderFillSources = getSortedOrderSources(MarketOperation.Sell, improvedOrders); expect(orderFillSources).to.deep.eq([ [ERC20BridgeSource.Native], [ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap], diff --git a/packages/contract-artifacts/CHANGELOG.json b/packages/contract-artifacts/CHANGELOG.json index 1afeec54a6..096af3b0fe 100644 --- a/packages/contract-artifacts/CHANGELOG.json +++ b/packages/contract-artifacts/CHANGELOG.json @@ -13,6 +13,10 @@ { "note": "Remove `ERC20BridgeSampler` artifact", "pr": 2647 + }, + { + "note": "Regenerate artifacts", + "pr": 2703 } ] }, diff --git a/packages/contract-artifacts/artifacts/IZeroEx.json b/packages/contract-artifacts/artifacts/IZeroEx.json index ca2d796526..4255ce4384 100644 --- a/packages/contract-artifacts/artifacts/IZeroEx.json +++ b/packages/contract-artifacts/artifacts/IZeroEx.json @@ -85,7 +85,7 @@ { "internalType": "contract IERC20TokenV06", "name": "feeToken", "type": "address" }, { "internalType": "uint256", "name": "feeAmount", "type": "uint256" } ], - "internalType": "struct IMetaTransactions.MetaTransactionData", + "internalType": "struct IMetaTransactionsFeature.MetaTransactionData", "name": "mtx", "type": "tuple" }, @@ -122,14 +122,14 @@ { "internalType": "uint32", "name": "deploymentNonce", "type": "uint32" }, { "internalType": "bytes", "name": "data", "type": "bytes" } ], - "internalType": "struct ITransformERC20.Transformation[]", + "internalType": "struct ITransformERC20Feature.Transformation[]", "name": "transformations", "type": "tuple[]" }, { "internalType": "bytes32", "name": "callDataHash", "type": "bytes32" }, { "internalType": "bytes", "name": "callDataSignature", "type": "bytes" } ], - "internalType": "struct ITransformERC20.TransformERC20Args", + "internalType": "struct ITransformERC20Feature.TransformERC20Args", "name": "args", "type": "tuple" } @@ -154,7 +154,7 @@ { "internalType": "contract IERC20TokenV06", "name": "feeToken", "type": "address" }, { "internalType": "uint256", "name": "feeAmount", "type": "uint256" } ], - "internalType": "struct IMetaTransactions.MetaTransactionData[]", + "internalType": "struct IMetaTransactionsFeature.MetaTransactionData[]", "name": "mtxs", "type": "tuple[]" }, @@ -187,7 +187,7 @@ { "internalType": "contract IERC20TokenV06", "name": "feeToken", "type": "address" }, { "internalType": "uint256", "name": "feeAmount", "type": "uint256" } ], - "internalType": "struct IMetaTransactions.MetaTransactionData", + "internalType": "struct IMetaTransactionsFeature.MetaTransactionData", "name": "mtx", "type": "tuple" }, @@ -237,7 +237,7 @@ { "internalType": "contract IERC20TokenV06", "name": "feeToken", "type": "address" }, { "internalType": "uint256", "name": "feeAmount", "type": "uint256" } ], - "internalType": "struct IMetaTransactions.MetaTransactionData", + "internalType": "struct IMetaTransactionsFeature.MetaTransactionData", "name": "mtx", "type": "tuple" } @@ -262,7 +262,7 @@ { "internalType": "contract IERC20TokenV06", "name": "feeToken", "type": "address" }, { "internalType": "uint256", "name": "feeAmount", "type": "uint256" } ], - "internalType": "struct IMetaTransactions.MetaTransactionData", + "internalType": "struct IMetaTransactionsFeature.MetaTransactionData", "name": "mtx", "type": "tuple" } @@ -366,6 +366,18 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { "internalType": "contract IERC20TokenV06[]", "name": "tokens", "type": "address[]" }, + { "internalType": "uint256", "name": "sellAmount", "type": "uint256" }, + { "internalType": "uint256", "name": "minBuyAmount", "type": "uint256" }, + { "internalType": "bool", "name": "isSushi", "type": "bool" } + ], + "name": "sellToUniswap", + "outputs": [{ "internalType": "uint256", "name": "buyAmount", "type": "uint256" }], + "stateMutability": "payable", + "type": "function" + }, { "inputs": [{ "internalType": "address", "name": "quoteSigner", "type": "address" }], "name": "setQuoteSigner", @@ -398,7 +410,7 @@ { "internalType": "uint32", "name": "deploymentNonce", "type": "uint32" }, { "internalType": "bytes", "name": "data", "type": "bytes" } ], - "internalType": "struct ITransformERC20.Transformation[]", + "internalType": "struct ITransformERC20Feature.Transformation[]", "name": "transformations", "type": "tuple[]" } @@ -552,6 +564,16 @@ "targetImpl": "The address of an older implementation of the function." } }, + "sellToUniswap(address[],uint256,uint256,bool)": { + "details": "Efficiently sell directly to uniswap/sushiswap.", + "params": { + "isSushi": "Use sushiswap if true.", + "minBuyAmount": "Minimum amount of `tokens[-1]` to buy.", + "sellAmount": "of `tokens[0]` Amount to sell.", + "tokens": "Sell path." + }, + "returns": { "buyAmount": "Amount of `tokens[-1]` bought." } + }, "setQuoteSigner(address)": { "details": "Replace the optional signer for `transformERC20()` calldata. Only callable by the owner.", "params": { "quoteSigner": "The address of the new calldata signer." } diff --git a/packages/contract-wrappers/CHANGELOG.json b/packages/contract-wrappers/CHANGELOG.json index 99cfbdac98..80deb3687a 100644 --- a/packages/contract-wrappers/CHANGELOG.json +++ b/packages/contract-wrappers/CHANGELOG.json @@ -13,6 +13,10 @@ { "note": "Add `exchangeProxy` to `ContractWrappers` type.", "pr": 2649 + }, + { + "note": "Regenerate wrappers", + "pr": 2703 } ] }, diff --git a/packages/contract-wrappers/src/generated-wrappers/i_zero_ex.ts b/packages/contract-wrappers/src/generated-wrappers/i_zero_ex.ts index 94ffa18680..faf40e2b47 100644 --- a/packages/contract-wrappers/src/generated-wrappers/i_zero_ex.ts +++ b/packages/contract-wrappers/src/generated-wrappers/i_zero_ex.ts @@ -1000,6 +1000,35 @@ export class IZeroExContract extends BaseContract { stateMutability: 'nonpayable', type: 'function', }, + { + inputs: [ + { + name: 'tokens', + type: 'address[]', + }, + { + name: 'sellAmount', + type: 'uint256', + }, + { + name: 'minBuyAmount', + type: 'uint256', + }, + { + name: 'isSushi', + type: 'bool', + }, + ], + name: 'sellToUniswap', + outputs: [ + { + name: 'buyAmount', + type: 'uint256', + }, + ], + stateMutability: 'payable', + type: 'function', + }, { inputs: [ { @@ -2418,6 +2447,68 @@ export class IZeroExContract extends BaseContract { }, }; } + /** + * Efficiently sell directly to uniswap/sushiswap. + * @param tokens Sell path. + * @param sellAmount of `tokens[0]` Amount to sell. + * @param minBuyAmount Minimum amount of `tokens[-1]` to buy. + * @param isSushi Use sushiswap if true. + */ + public sellToUniswap( + tokens: string[], + sellAmount: BigNumber, + minBuyAmount: BigNumber, + isSushi: boolean, + ): ContractTxFunctionObj { + const self = (this as any) as IZeroExContract; + assert.isArray('tokens', tokens); + assert.isBigNumber('sellAmount', sellAmount); + assert.isBigNumber('minBuyAmount', minBuyAmount); + assert.isBoolean('isSushi', isSushi); + const functionSignature = 'sellToUniswap(address[],uint256,uint256,bool)'; + + return { + async sendTransactionAsync( + txData?: Partial | undefined, + opts: SendTransactionOpts = { shouldValidate: true }, + ): Promise { + const txDataWithDefaults = await self._applyDefaultsToTxDataAsync( + { data: this.getABIEncodedTransactionData(), ...txData }, + this.estimateGasAsync.bind(this), + ); + if (opts.shouldValidate !== false) { + await this.callAsync(txDataWithDefaults); + } + return self._web3Wrapper.sendTransactionAsync(txDataWithDefaults); + }, + awaitTransactionSuccessAsync( + txData?: Partial, + opts: AwaitTransactionSuccessOpts = { shouldValidate: true }, + ): PromiseWithTransactionHash { + return self._promiseWithTransactionHash(this.sendTransactionAsync(txData, opts), opts); + }, + async estimateGasAsync(txData?: Partial | undefined): Promise { + const txDataWithDefaults = await self._applyDefaultsToTxDataAsync({ + data: this.getABIEncodedTransactionData(), + ...txData, + }); + return self._web3Wrapper.estimateGasAsync(txDataWithDefaults); + }, + async callAsync(callData: Partial = {}, defaultBlock?: BlockParam): Promise { + BaseContract._assertCallParams(callData, defaultBlock); + const rawCallResult = await self._performCallAsync( + { data: this.getABIEncodedTransactionData(), ...callData }, + defaultBlock, + ); + const abiEncoder = self._lookupAbiEncoder(functionSignature); + BaseContract._throwIfUnexpectedEmptyCallResult(rawCallResult, abiEncoder); + return abiEncoder.strictDecodeReturnValue(rawCallResult); + }, + getABIEncodedTransactionData(): string { + return self._strictEncodeArguments(functionSignature, [tokens, sellAmount, minBuyAmount, isSushi]); + }, + }; + } /** * Replace the optional signer for `transformERC20()` calldata. * Only callable by the owner.