diff --git a/contracts/zero-ex/CHANGELOG.json b/contracts/zero-ex/CHANGELOG.json index 7a2ccc6a7c..dd950575f6 100644 --- a/contracts/zero-ex/CHANGELOG.json +++ b/contracts/zero-ex/CHANGELOG.json @@ -57,6 +57,10 @@ { "note": "Fix versioning (`_encodeVersion()`) bug", "pr": 2703 + }, + { + "note": "Added LiquidityProviderFeature", + "pr": 2691 } ] }, diff --git a/contracts/zero-ex/contracts/src/IZeroEx.sol b/contracts/zero-ex/contracts/src/IZeroEx.sol index b31371a02f..8a84a0425a 100644 --- a/contracts/zero-ex/contracts/src/IZeroEx.sol +++ b/contracts/zero-ex/contracts/src/IZeroEx.sol @@ -26,6 +26,7 @@ import "./features/ISignatureValidatorFeature.sol"; import "./features/ITransformERC20Feature.sol"; import "./features/IMetaTransactionsFeature.sol"; import "./features/IUniswapFeature.sol"; +import "./features/ILiquidityProviderFeature.sol"; /// @dev Interface for a fully featured Exchange Proxy. @@ -36,7 +37,8 @@ interface IZeroEx is ISignatureValidatorFeature, ITransformERC20Feature, IMetaTransactionsFeature, - IUniswapFeature + IUniswapFeature, + ILiquidityProviderFeature { // solhint-disable state-visibility diff --git a/contracts/zero-ex/contracts/src/errors/LibLiquidityProviderRichErrors.sol b/contracts/zero-ex/contracts/src/errors/LibLiquidityProviderRichErrors.sol new file mode 100644 index 0000000000..177df23a08 --- /dev/null +++ b/contracts/zero-ex/contracts/src/errors/LibLiquidityProviderRichErrors.sol @@ -0,0 +1,63 @@ +/* + + 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; + + +library LibLiquidityProviderRichErrors { + + // solhint-disable func-name-mixedcase + + function LiquidityProviderIncompleteSellError( + address providerAddress, + address makerToken, + address takerToken, + uint256 sellAmount, + uint256 boughtAmount, + uint256 minBuyAmount + ) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + bytes4(keccak256("LiquidityProviderIncompleteSellError(address,address,address,uint256,uint256,uint256)")), + providerAddress, + makerToken, + takerToken, + sellAmount, + boughtAmount, + minBuyAmount + ); + } + + function NoLiquidityProviderForMarketError( + address xAsset, + address yAsset + ) + internal + pure + returns (bytes memory) + { + return abi.encodeWithSelector( + bytes4(keccak256("NoLiquidityProviderForMarketError(address,address)")), + xAsset, + yAsset + ); + } +} diff --git a/contracts/zero-ex/contracts/src/features/ILiquidityProviderFeature.sol b/contracts/zero-ex/contracts/src/features/ILiquidityProviderFeature.sol new file mode 100644 index 0000000000..02792df8f6 --- /dev/null +++ b/contracts/zero-ex/contracts/src/features/ILiquidityProviderFeature.sol @@ -0,0 +1,66 @@ +/* + + 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; + + +/// @dev Feature to swap directly with an on-chain liquidity provider. +interface ILiquidityProviderFeature { + event LiquidityProviderForMarketUpdated( + address indexed xAsset, + address indexed yAsset, + address providerAddress + ); + + function sellToLiquidityProvider( + address makerToken, + address takerToken, + address payable recipient, + uint256 sellAmount, + uint256 minBuyAmount + ) + external + payable + returns (uint256 boughtAmount); + + /// @dev Sets address of the liquidity provider for a market given + /// (xAsset, yAsset). + /// @param xAsset First asset managed by the liquidity provider. + /// @param yAsset Second asset managed by the liquidity provider. + /// @param providerAddress Address of the liquidity provider. + function setLiquidityProviderForMarket( + address xAsset, + address yAsset, + address providerAddress + ) + external; + + /// @dev Returns the address of the liquidity provider for a market given + /// (xAsset, yAsset), or reverts if pool does not exist. + /// @param xAsset First asset managed by the liquidity provider. + /// @param yAsset Second asset managed by the liquidity provider. + /// @return providerAddress Address of the liquidity provider. + function getLiquidityProviderForMarket( + address xAsset, + address yAsset + ) + external + view + returns (address providerAddress); +} diff --git a/contracts/zero-ex/contracts/src/features/LiquidityProviderFeature.sol b/contracts/zero-ex/contracts/src/features/LiquidityProviderFeature.sol new file mode 100644 index 0000000000..61bb080866 --- /dev/null +++ b/contracts/zero-ex/contracts/src/features/LiquidityProviderFeature.sol @@ -0,0 +1,200 @@ +/* + + 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 "@0x/contracts-erc20/contracts/src/v06/LibERC20TokenV06.sol"; +import "@0x/contracts-utils/contracts/src/v06/errors/LibRichErrorsV06.sol"; +import "@0x/contracts-utils/contracts/src/v06/LibSafeMathV06.sol"; +import "../errors/LibLiquidityProviderRichErrors.sol"; +import "../fixins/FixinCommon.sol"; +import "../migrations/LibMigrate.sol"; +import "../storage/LibLiquidityProviderStorage.sol"; +import "../vendor/v3/IERC20Bridge.sol"; +import "./IFeature.sol"; +import "./ILiquidityProviderFeature.sol"; +import "./ITokenSpenderFeature.sol"; + + +contract LiquidityProviderFeature is + IFeature, + ILiquidityProviderFeature, + FixinCommon +{ + using LibERC20TokenV06 for IERC20TokenV06; + using LibSafeMathV06 for uint256; + using LibRichErrorsV06 for bytes; + + /// @dev Name of this feature. + string public constant override FEATURE_NAME = "LiquidityProviderFeature"; + /// @dev Version of this feature. + uint256 public immutable override FEATURE_VERSION = _encodeVersion(1, 0, 0); + + /// @dev ETH pseudo-token address. + address constant internal ETH_TOKEN_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + /// @dev The WETH contract address. + IEtherTokenV06 public immutable weth; + + /// @dev Store the WETH address in an immutable. + /// @param weth_ The weth token. + constructor(IEtherTokenV06 weth_) + public + FixinCommon() + { + weth = weth_; + } + + /// @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.sellToLiquidityProvider.selector); + _registerFeatureFunction(this.setLiquidityProviderForMarket.selector); + _registerFeatureFunction(this.getLiquidityProviderForMarket.selector); + return LibMigrate.MIGRATE_SUCCESS; + } + + function sellToLiquidityProvider( + address makerToken, + address takerToken, + address payable recipient, + uint256 sellAmount, + uint256 minBuyAmount + ) + external + override + payable + returns (uint256 boughtAmount) + { + address providerAddress = getLiquidityProviderForMarket(makerToken, takerToken); + if (recipient == address(0)) { + recipient = msg.sender; + } + + if (takerToken == ETH_TOKEN_ADDRESS) { + // Wrap ETH. + weth.deposit{value: sellAmount}(); + weth.transfer(providerAddress, sellAmount); + } else { + ITokenSpenderFeature(address(this))._spendERC20Tokens( + IERC20TokenV06(takerToken), + msg.sender, + providerAddress, + sellAmount + ); + } + + if (makerToken == ETH_TOKEN_ADDRESS) { + uint256 balanceBefore = weth.balanceOf(address(this)); + IERC20Bridge(providerAddress).bridgeTransferFrom( + address(weth), + address(0), + address(this), + minBuyAmount, + "" + ); + boughtAmount = weth.balanceOf(address(this)).safeSub(balanceBefore); + // Unwrap wETH and send ETH to recipient. + weth.withdraw(boughtAmount); + recipient.transfer(boughtAmount); + } else { + uint256 balanceBefore = IERC20TokenV06(makerToken).balanceOf(recipient); + IERC20Bridge(providerAddress).bridgeTransferFrom( + makerToken, + address(0), + recipient, + minBuyAmount, + "" + ); + boughtAmount = IERC20TokenV06(makerToken).balanceOf(recipient).safeSub(balanceBefore); + } + if (boughtAmount < minBuyAmount) { + LibLiquidityProviderRichErrors.LiquidityProviderIncompleteSellError( + providerAddress, + makerToken, + takerToken, + sellAmount, + boughtAmount, + minBuyAmount + ).rrevert(); + } + } + + /// @dev Sets address of the liquidity provider for a market given + /// (xAsset, yAsset). + /// @param xAsset First asset managed by the liquidity provider. + /// @param yAsset Second asset managed by the liquidity provider. + /// @param providerAddress Address of the liquidity provider. + function setLiquidityProviderForMarket( + address xAsset, + address yAsset, + address providerAddress + ) + external + override + onlyOwner + { + LibLiquidityProviderStorage.getStorage() + .addressBook[xAsset][yAsset] = providerAddress; + LibLiquidityProviderStorage.getStorage() + .addressBook[yAsset][xAsset] = providerAddress; + emit LiquidityProviderForMarketUpdated( + xAsset, + yAsset, + providerAddress + ); + } + + /// @dev Returns the address of the liquidity provider for a market given + /// (xAsset, yAsset), or reverts if pool does not exist. + /// @param xAsset First asset managed by the liquidity provider. + /// @param yAsset Second asset managed by the liquidity provider. + /// @return providerAddress Address of the liquidity provider. + function getLiquidityProviderForMarket( + address xAsset, + address yAsset + ) + public + view + override + returns (address providerAddress) + { + if (xAsset == ETH_TOKEN_ADDRESS) { + providerAddress = LibLiquidityProviderStorage.getStorage() + .addressBook[address(weth)][yAsset]; + } else if (yAsset == ETH_TOKEN_ADDRESS) { + providerAddress = LibLiquidityProviderStorage.getStorage() + .addressBook[xAsset][address(weth)]; + } else { + providerAddress = LibLiquidityProviderStorage.getStorage() + .addressBook[xAsset][yAsset]; + } + if (providerAddress == address(0)) { + LibLiquidityProviderRichErrors.NoLiquidityProviderForMarketError( + xAsset, + yAsset + ).rrevert(); + } + } +} diff --git a/contracts/zero-ex/contracts/src/features/MetaTransactionsFeature.sol b/contracts/zero-ex/contracts/src/features/MetaTransactionsFeature.sol index bd6e40b1c5..6ac4475d8d 100644 --- a/contracts/zero-ex/contracts/src/features/MetaTransactionsFeature.sol +++ b/contracts/zero-ex/contracts/src/features/MetaTransactionsFeature.sol @@ -184,7 +184,7 @@ contract MetaTransactionsFeature is /// @dev Execute a meta-transaction via `sender`. Privileged variant. /// Only callable from within. - /// @param sender Who is executing the meta-transaction.. + /// @param sender Who is executing the meta-transaction. /// @param mtx The meta-transaction. /// @param signature The signature by `mtx.signer`. /// @return returnResult The ABI-encoded result of the underlying call. @@ -454,7 +454,7 @@ contract MetaTransactionsFeature is } /// @dev Make an arbitrary internal, meta-transaction call. - /// Warning: Do not let unadulerated `callData` into this function. + /// Warning: Do not let unadulterated `callData` into this function. function _callSelf(bytes32 hash, bytes memory callData, uint256 value) private returns (bytes memory returnResult) diff --git a/contracts/zero-ex/contracts/src/storage/LibLiquidityProviderStorage.sol b/contracts/zero-ex/contracts/src/storage/LibLiquidityProviderStorage.sol new file mode 100644 index 0000000000..99f0361ef0 --- /dev/null +++ b/contracts/zero-ex/contracts/src/storage/LibLiquidityProviderStorage.sol @@ -0,0 +1,45 @@ +/* + + 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 "./LibStorage.sol"; + + +/// @dev Storage helpers for `LiquidityProviderFeature`. +library LibLiquidityProviderStorage { + + /// @dev Storage bucket for this feature. + struct Storage { + // Mapping of taker token -> maker token -> liquidity provider address + // Note that addressBook[x][y] == addressBook[y][x] will always hold. + mapping (address => mapping (address => address)) addressBook; + } + + /// @dev Get the storage bucket for this contract. + function getStorage() internal pure returns (Storage storage stor) { + uint256 storageSlot = LibStorage.getStorageSlot( + LibStorage.StorageId.LiquidityProvider + ); + // Dip into assembly to change the slot pointed to by the local + // variable `stor`. + // See https://solidity.readthedocs.io/en/v0.6.8/assembly.html?highlight=slot#access-to-external-variables-functions-and-libraries + assembly { stor_slot := storageSlot } + } +} diff --git a/contracts/zero-ex/contracts/src/storage/LibStorage.sol b/contracts/zero-ex/contracts/src/storage/LibStorage.sol index 809977d4a8..129254f82a 100644 --- a/contracts/zero-ex/contracts/src/storage/LibStorage.sol +++ b/contracts/zero-ex/contracts/src/storage/LibStorage.sol @@ -36,7 +36,8 @@ library LibStorage { TokenSpender, TransformERC20, MetaTransactions, - ReentrancyGuard + ReentrancyGuard, + LiquidityProvider } /// @dev Get the storage slot given a storage ID. We assign unique, well-spaced diff --git a/contracts/zero-ex/contracts/test/TestBridge.sol b/contracts/zero-ex/contracts/test/TestBridge.sol new file mode 100644 index 0000000000..4f94594649 --- /dev/null +++ b/contracts/zero-ex/contracts/test/TestBridge.sol @@ -0,0 +1,69 @@ +/* + + 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 "../src/vendor/v3/IERC20Bridge.sol"; + + +contract TestBridge is + IERC20Bridge +{ + IERC20TokenV06 public immutable xAsset; + IERC20TokenV06 public immutable yAsset; + + constructor(IERC20TokenV06 xAsset_, IERC20TokenV06 yAsset_) + public + { + xAsset = xAsset_; + yAsset = yAsset_; + } + + /// @dev Transfers `amount` of the ERC20 `tokenAddress` from `from` to `to`. + /// @param tokenAddress The address of the ERC20 token to transfer. + /// @param from Address to transfer asset from. + /// @param to Address to transfer asset to. + /// @param amount Amount of asset to transfer. + /// @param bridgeData Arbitrary asset data needed by the bridge contract. + /// @return success The magic bytes `0xdc1600f3` if successful. + function bridgeTransferFrom( + address tokenAddress, + address from, + address to, + uint256 amount, + bytes calldata bridgeData + ) + external + override + returns (bytes4 success) + { + IERC20TokenV06 takerToken = tokenAddress == address(xAsset) ? yAsset : xAsset; + uint256 takerTokenBalance = takerToken.balanceOf(address(this)); + emit ERC20BridgeTransfer( + address(takerToken), + tokenAddress, + takerTokenBalance, + amount, + from, + to + ); + return 0xdecaf000; + } +} diff --git a/contracts/zero-ex/package.json b/contracts/zero-ex/package.json index 89d547f032..ca8e98467f 100644 --- a/contracts/zero-ex/package.json +++ b/contracts/zero-ex/package.json @@ -39,9 +39,9 @@ "publish:private": "yarn build && gitpkg publish" }, "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", + "publicInterfaceContracts": "IZeroEx,ZeroEx,FullMigration,InitialMigration,IFlashWallet,IAllowanceTarget,IERC20Transformer,IOwnableFeature,ISimpleFunctionRegistryFeature,ITokenSpenderFeature,ITransformERC20Feature,FillQuoteTransformer,PayTakerTransformer,WethTransformer,OwnableFeature,SimpleFunctionRegistryFeature,TransformERC20Feature,TokenSpenderFeature,AffiliateFeeTransformer,SignatureValidatorFeature,MetaTransactionsFeature,LogMetadataTransformer,BridgeAdapter,LiquidityProviderFeature", "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|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" + "abis": "./test/generated-artifacts/@(AffiliateFeeTransformer|AllowanceTarget|BootstrapFeature|BridgeAdapter|FillQuoteTransformer|FixinCommon|FixinEIP712|FixinReentrancyGuard|FlashWallet|FullMigration|IAllowanceTarget|IBootstrapFeature|IBridgeAdapter|IERC20Bridge|IERC20Transformer|IExchange|IFeature|IFlashWallet|IGasToken|ILiquidityProviderFeature|IMetaTransactionsFeature|IOwnableFeature|ISignatureValidatorFeature|ISimpleFunctionRegistryFeature|ITestSimpleFunctionRegistryFeature|ITokenSpenderFeature|ITransformERC20Feature|IUniswapFeature|IZeroEx|InitialMigration|LibBootstrap|LibCommonRichErrors|LibERC20Transformer|LibLiquidityProviderRichErrors|LibLiquidityProviderStorage|LibMetaTransactionsRichErrors|LibMetaTransactionsStorage|LibMigrate|LibOwnableRichErrors|LibOwnableStorage|LibProxyRichErrors|LibProxyStorage|LibReentrancyGuardStorage|LibSignatureRichErrors|LibSignedCallData|LibSimpleFunctionRegistryRichErrors|LibSimpleFunctionRegistryStorage|LibSpenderRichErrors|LibStorage|LibTokenSpenderStorage|LibTransformERC20RichErrors|LibTransformERC20Storage|LibWalletRichErrors|LiquidityProviderFeature|LogMetadataTransformer|MetaTransactionsFeature|MixinAdapterAddresses|MixinBalancer|MixinCurve|MixinKyber|MixinMStable|MixinMooniswap|MixinOasis|MixinUniswap|MixinUniswapV2|MixinZeroExBridge|OwnableFeature|PayTakerTransformer|SignatureValidatorFeature|SimpleFunctionRegistryFeature|TestBridge|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", @@ -55,6 +55,7 @@ "devDependencies": { "@0x/abi-gen": "^5.3.1", "@0x/contracts-gen": "^2.0.10", + "@0x/contracts-erc20": "^3.2.1", "@0x/contracts-test-utils": "^5.3.4", "@0x/dev-utils": "^3.3.0", "@0x/order-utils": "^10.3.0", diff --git a/contracts/zero-ex/src/artifacts.ts b/contracts/zero-ex/src/artifacts.ts index b51add69e6..9e41b7606f 100644 --- a/contracts/zero-ex/src/artifacts.ts +++ b/contracts/zero-ex/src/artifacts.ts @@ -18,6 +18,7 @@ import * as ISimpleFunctionRegistryFeature from '../generated-artifacts/ISimpleF import * as ITokenSpenderFeature from '../generated-artifacts/ITokenSpenderFeature.json'; import * as ITransformERC20Feature from '../generated-artifacts/ITransformERC20Feature.json'; import * as IZeroEx from '../generated-artifacts/IZeroEx.json'; +import * as LiquidityProviderFeature from '../generated-artifacts/LiquidityProviderFeature.json'; import * as LogMetadataTransformer from '../generated-artifacts/LogMetadataTransformer.json'; import * as MetaTransactionsFeature from '../generated-artifacts/MetaTransactionsFeature.json'; import * as OwnableFeature from '../generated-artifacts/OwnableFeature.json'; @@ -52,4 +53,5 @@ export const artifacts = { MetaTransactionsFeature: MetaTransactionsFeature as ContractArtifact, LogMetadataTransformer: LogMetadataTransformer as ContractArtifact, BridgeAdapter: BridgeAdapter as ContractArtifact, + LiquidityProviderFeature: LiquidityProviderFeature as ContractArtifact, }; diff --git a/contracts/zero-ex/src/wrappers.ts b/contracts/zero-ex/src/wrappers.ts index ea09d3416d..0c88b3fa82 100644 --- a/contracts/zero-ex/src/wrappers.ts +++ b/contracts/zero-ex/src/wrappers.ts @@ -16,6 +16,7 @@ export * from '../generated-wrappers/i_token_spender_feature'; export * from '../generated-wrappers/i_transform_erc20_feature'; export * from '../generated-wrappers/i_zero_ex'; export * from '../generated-wrappers/initial_migration'; +export * from '../generated-wrappers/liquidity_provider_feature'; export * from '../generated-wrappers/log_metadata_transformer'; export * from '../generated-wrappers/meta_transactions_feature'; export * from '../generated-wrappers/ownable_feature'; diff --git a/contracts/zero-ex/test/artifacts.ts b/contracts/zero-ex/test/artifacts.ts index d97231955e..6de120b62e 100644 --- a/contracts/zero-ex/test/artifacts.ts +++ b/contracts/zero-ex/test/artifacts.ts @@ -24,6 +24,7 @@ import * as IExchange from '../test/generated-artifacts/IExchange.json'; import * as IFeature from '../test/generated-artifacts/IFeature.json'; import * as IFlashWallet from '../test/generated-artifacts/IFlashWallet.json'; import * as IGasToken from '../test/generated-artifacts/IGasToken.json'; +import * as ILiquidityProviderFeature from '../test/generated-artifacts/ILiquidityProviderFeature.json'; import * as IMetaTransactionsFeature from '../test/generated-artifacts/IMetaTransactionsFeature.json'; import * as InitialMigration from '../test/generated-artifacts/InitialMigration.json'; import * as IOwnableFeature from '../test/generated-artifacts/IOwnableFeature.json'; @@ -37,6 +38,8 @@ 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'; import * as LibERC20Transformer from '../test/generated-artifacts/LibERC20Transformer.json'; +import * as LibLiquidityProviderRichErrors from '../test/generated-artifacts/LibLiquidityProviderRichErrors.json'; +import * as LibLiquidityProviderStorage from '../test/generated-artifacts/LibLiquidityProviderStorage.json'; import * as LibMetaTransactionsRichErrors from '../test/generated-artifacts/LibMetaTransactionsRichErrors.json'; import * as LibMetaTransactionsStorage from '../test/generated-artifacts/LibMetaTransactionsStorage.json'; import * as LibMigrate from '../test/generated-artifacts/LibMigrate.json'; @@ -55,6 +58,7 @@ import * as LibTokenSpenderStorage from '../test/generated-artifacts/LibTokenSpe import * as LibTransformERC20RichErrors from '../test/generated-artifacts/LibTransformERC20RichErrors.json'; import * as LibTransformERC20Storage from '../test/generated-artifacts/LibTransformERC20Storage.json'; import * as LibWalletRichErrors from '../test/generated-artifacts/LibWalletRichErrors.json'; +import * as LiquidityProviderFeature from '../test/generated-artifacts/LiquidityProviderFeature.json'; import * as LogMetadataTransformer from '../test/generated-artifacts/LogMetadataTransformer.json'; import * as MetaTransactionsFeature from '../test/generated-artifacts/MetaTransactionsFeature.json'; import * as MixinAdapterAddresses from '../test/generated-artifacts/MixinAdapterAddresses.json'; @@ -71,6 +75,7 @@ import * as OwnableFeature from '../test/generated-artifacts/OwnableFeature.json import * as PayTakerTransformer from '../test/generated-artifacts/PayTakerTransformer.json'; import * as SignatureValidatorFeature from '../test/generated-artifacts/SignatureValidatorFeature.json'; import * as SimpleFunctionRegistryFeature from '../test/generated-artifacts/SimpleFunctionRegistryFeature.json'; +import * as TestBridge from '../test/generated-artifacts/TestBridge.json'; import * as TestCallTarget from '../test/generated-artifacts/TestCallTarget.json'; import * as TestDelegateCaller from '../test/generated-artifacts/TestDelegateCaller.json'; import * as TestFillQuoteTransformerBridge from '../test/generated-artifacts/TestFillQuoteTransformerBridge.json'; @@ -104,6 +109,7 @@ export const artifacts = { IZeroEx: IZeroEx as ContractArtifact, ZeroEx: ZeroEx as ContractArtifact, LibCommonRichErrors: LibCommonRichErrors as ContractArtifact, + LibLiquidityProviderRichErrors: LibLiquidityProviderRichErrors as ContractArtifact, LibMetaTransactionsRichErrors: LibMetaTransactionsRichErrors as ContractArtifact, LibOwnableRichErrors: LibOwnableRichErrors as ContractArtifact, LibProxyRichErrors: LibProxyRichErrors as ContractArtifact, @@ -120,6 +126,7 @@ export const artifacts = { BootstrapFeature: BootstrapFeature as ContractArtifact, IBootstrapFeature: IBootstrapFeature as ContractArtifact, IFeature: IFeature as ContractArtifact, + ILiquidityProviderFeature: ILiquidityProviderFeature as ContractArtifact, IMetaTransactionsFeature: IMetaTransactionsFeature as ContractArtifact, IOwnableFeature: IOwnableFeature as ContractArtifact, ISignatureValidatorFeature: ISignatureValidatorFeature as ContractArtifact, @@ -127,6 +134,7 @@ export const artifacts = { ITokenSpenderFeature: ITokenSpenderFeature as ContractArtifact, ITransformERC20Feature: ITransformERC20Feature as ContractArtifact, IUniswapFeature: IUniswapFeature as ContractArtifact, + LiquidityProviderFeature: LiquidityProviderFeature as ContractArtifact, MetaTransactionsFeature: MetaTransactionsFeature as ContractArtifact, OwnableFeature: OwnableFeature as ContractArtifact, SignatureValidatorFeature: SignatureValidatorFeature as ContractArtifact, @@ -142,6 +150,7 @@ export const artifacts = { InitialMigration: InitialMigration as ContractArtifact, LibBootstrap: LibBootstrap as ContractArtifact, LibMigrate: LibMigrate as ContractArtifact, + LibLiquidityProviderStorage: LibLiquidityProviderStorage as ContractArtifact, LibMetaTransactionsStorage: LibMetaTransactionsStorage as ContractArtifact, LibOwnableStorage: LibOwnableStorage as ContractArtifact, LibProxyStorage: LibProxyStorage as ContractArtifact, @@ -174,6 +183,7 @@ export const artifacts = { IExchange: IExchange as ContractArtifact, IGasToken: IGasToken as ContractArtifact, ITestSimpleFunctionRegistryFeature: ITestSimpleFunctionRegistryFeature as ContractArtifact, + TestBridge: TestBridge as ContractArtifact, TestCallTarget: TestCallTarget as ContractArtifact, TestDelegateCaller: TestDelegateCaller as ContractArtifact, TestFillQuoteTransformerBridge: TestFillQuoteTransformerBridge as ContractArtifact, diff --git a/contracts/zero-ex/test/features/liquidity_provider_test.ts b/contracts/zero-ex/test/features/liquidity_provider_test.ts new file mode 100644 index 0000000000..12fbfc0e9a --- /dev/null +++ b/contracts/zero-ex/test/features/liquidity_provider_test.ts @@ -0,0 +1,250 @@ +import { artifacts as erc20Artifacts, DummyERC20TokenContract } from '@0x/contracts-erc20'; +import { blockchainTests, constants, expect, randomAddress, verifyEventsFromLogs } from '@0x/contracts-test-utils'; +import { BigNumber, OwnableRevertErrors, ZeroExRevertErrors } from '@0x/utils'; + +import { + IOwnableFeatureContract, + IZeroExContract, + LiquidityProviderFeatureContract, + TokenSpenderFeatureContract, +} from '../../src/wrappers'; +import { artifacts } from '../artifacts'; +import { abis } from '../utils/abis'; +import { fullMigrateAsync } from '../utils/migration'; +import { IERC20BridgeEvents, TestBridgeContract, TestWethContract } from '../wrappers'; + +blockchainTests('LiquidityProvider feature', env => { + let zeroEx: IZeroExContract; + let feature: LiquidityProviderFeatureContract; + let token: DummyERC20TokenContract; + let weth: TestWethContract; + let owner: string; + let taker: string; + + before(async () => { + [owner, taker] = await env.getAccountAddressesAsync(); + zeroEx = await fullMigrateAsync(owner, env.provider, env.txDefaults, { + tokenSpender: (await TokenSpenderFeatureContract.deployFrom0xArtifactAsync( + artifacts.TestTokenSpender, + env.provider, + env.txDefaults, + artifacts, + )).address, + }); + const tokenSpender = new TokenSpenderFeatureContract(zeroEx.address, env.provider, env.txDefaults, abis); + const allowanceTarget = await tokenSpender.getAllowanceTarget().callAsync(); + + token = await DummyERC20TokenContract.deployFrom0xArtifactAsync( + erc20Artifacts.DummyERC20Token, + env.provider, + env.txDefaults, + erc20Artifacts, + constants.DUMMY_TOKEN_NAME, + constants.DUMMY_TOKEN_SYMBOL, + constants.DUMMY_TOKEN_DECIMALS, + constants.DUMMY_TOKEN_TOTAL_SUPPLY, + ); + await token.setBalance(taker, constants.INITIAL_ERC20_BALANCE).awaitTransactionSuccessAsync(); + weth = await TestWethContract.deployFrom0xArtifactAsync( + artifacts.TestWeth, + env.provider, + env.txDefaults, + artifacts, + ); + await token + .approve(allowanceTarget, constants.INITIAL_ERC20_ALLOWANCE) + .awaitTransactionSuccessAsync({ from: taker }); + + feature = new LiquidityProviderFeatureContract(zeroEx.address, env.provider, env.txDefaults, abis); + const featureImpl = await LiquidityProviderFeatureContract.deployFrom0xArtifactAsync( + artifacts.LiquidityProviderFeature, + env.provider, + env.txDefaults, + artifacts, + weth.address, + ); + await new IOwnableFeatureContract(zeroEx.address, env.provider, env.txDefaults, abis) + .migrate(featureImpl.address, featureImpl.migrate().getABIEncodedTransactionData(), owner) + .awaitTransactionSuccessAsync(); + }); + describe('Registry', () => { + it('`getLiquidityProviderForMarket` reverts if address is not set', async () => { + const [xAsset, yAsset] = [randomAddress(), randomAddress()]; + let tx = feature.getLiquidityProviderForMarket(xAsset, yAsset).awaitTransactionSuccessAsync(); + expect(tx).to.revertWith( + new ZeroExRevertErrors.LiquidityProvider.NoLiquidityProviderForMarketError(xAsset, yAsset), + ); + tx = feature.getLiquidityProviderForMarket(yAsset, xAsset).awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith( + new ZeroExRevertErrors.LiquidityProvider.NoLiquidityProviderForMarketError(yAsset, xAsset), + ); + }); + it('can set/get a liquidity provider address for a given market', async () => { + const expectedAddress = randomAddress(); + await feature + .setLiquidityProviderForMarket(token.address, weth.address, expectedAddress) + .awaitTransactionSuccessAsync(); + let actualAddress = await feature.getLiquidityProviderForMarket(token.address, weth.address).callAsync(); + expect(actualAddress).to.equal(expectedAddress); + actualAddress = await feature.getLiquidityProviderForMarket(weth.address, token.address).callAsync(); + expect(actualAddress).to.equal(expectedAddress); + }); + it('can update a liquidity provider address for a given market', async () => { + const expectedAddress = randomAddress(); + await feature + .setLiquidityProviderForMarket(token.address, weth.address, expectedAddress) + .awaitTransactionSuccessAsync(); + let actualAddress = await feature.getLiquidityProviderForMarket(token.address, weth.address).callAsync(); + expect(actualAddress).to.equal(expectedAddress); + actualAddress = await feature.getLiquidityProviderForMarket(weth.address, token.address).callAsync(); + expect(actualAddress).to.equal(expectedAddress); + }); + it('can effectively remove a liquidity provider for a market by setting the address to 0', async () => { + await feature + .setLiquidityProviderForMarket(token.address, weth.address, constants.NULL_ADDRESS) + .awaitTransactionSuccessAsync(); + const tx = feature + .getLiquidityProviderForMarket(token.address, weth.address) + .awaitTransactionSuccessAsync(); + return expect(tx).to.revertWith( + new ZeroExRevertErrors.LiquidityProvider.NoLiquidityProviderForMarketError(token.address, weth.address), + ); + }); + it('reverts if non-owner attempts to set an address', async () => { + const tx = feature + .setLiquidityProviderForMarket(randomAddress(), randomAddress(), randomAddress()) + .awaitTransactionSuccessAsync({ from: taker }); + return expect(tx).to.revertWith(new OwnableRevertErrors.OnlyOwnerError(taker, owner)); + }); + }); + blockchainTests.resets('Swap', () => { + let liquidityProvider: TestBridgeContract; + const ETH_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; + + before(async () => { + liquidityProvider = await TestBridgeContract.deployFrom0xArtifactAsync( + artifacts.TestBridge, + env.provider, + env.txDefaults, + artifacts, + token.address, + weth.address, + ); + await feature + .setLiquidityProviderForMarket(token.address, weth.address, liquidityProvider.address) + .awaitTransactionSuccessAsync(); + }); + it('Cannot execute a swap for a market without a liquidity provider set', async () => { + const [xAsset, yAsset] = [randomAddress(), randomAddress()]; + const tx = feature + .sellToLiquidityProvider( + xAsset, + yAsset, + constants.NULL_ADDRESS, + constants.ONE_ETHER, + constants.ZERO_AMOUNT, + ) + .awaitTransactionSuccessAsync({ from: taker }); + return expect(tx).to.revertWith( + new ZeroExRevertErrors.LiquidityProvider.NoLiquidityProviderForMarketError(xAsset, yAsset), + ); + }); + it('Successfully executes an ERC20-ERC20 swap', async () => { + const tx = await feature + .sellToLiquidityProvider( + weth.address, + token.address, + constants.NULL_ADDRESS, + constants.ONE_ETHER, + constants.ZERO_AMOUNT, + ) + .awaitTransactionSuccessAsync({ from: taker }); + verifyEventsFromLogs( + tx.logs, + [ + { + inputToken: token.address, + outputToken: weth.address, + inputTokenAmount: constants.ONE_ETHER, + outputTokenAmount: constants.ZERO_AMOUNT, + from: constants.NULL_ADDRESS, + to: taker, + }, + ], + IERC20BridgeEvents.ERC20BridgeTransfer, + ); + }); + it('Reverts if cannot fulfill the minimum buy amount', async () => { + const minBuyAmount = new BigNumber(1); + const tx = feature + .sellToLiquidityProvider( + weth.address, + token.address, + constants.NULL_ADDRESS, + constants.ONE_ETHER, + minBuyAmount, + ) + .awaitTransactionSuccessAsync({ from: taker }); + return expect(tx).to.revertWith( + new ZeroExRevertErrors.LiquidityProvider.LiquidityProviderIncompleteSellError( + liquidityProvider.address, + weth.address, + token.address, + constants.ONE_ETHER, + constants.ZERO_AMOUNT, + minBuyAmount, + ), + ); + }); + it('Successfully executes an ETH-ERC20 swap', async () => { + const tx = await feature + .sellToLiquidityProvider( + token.address, + ETH_TOKEN_ADDRESS, + constants.NULL_ADDRESS, + constants.ONE_ETHER, + constants.ZERO_AMOUNT, + ) + .awaitTransactionSuccessAsync({ from: taker, value: constants.ONE_ETHER }); + verifyEventsFromLogs( + tx.logs, + [ + { + inputToken: weth.address, + outputToken: token.address, + inputTokenAmount: constants.ONE_ETHER, + outputTokenAmount: constants.ZERO_AMOUNT, + from: constants.NULL_ADDRESS, + to: taker, + }, + ], + IERC20BridgeEvents.ERC20BridgeTransfer, + ); + }); + it('Successfully executes an ERC20-ETH swap', async () => { + const tx = await feature + .sellToLiquidityProvider( + ETH_TOKEN_ADDRESS, + token.address, + constants.NULL_ADDRESS, + constants.ONE_ETHER, + constants.ZERO_AMOUNT, + ) + .awaitTransactionSuccessAsync({ from: taker }); + verifyEventsFromLogs( + tx.logs, + [ + { + inputToken: token.address, + outputToken: weth.address, + inputTokenAmount: constants.ONE_ETHER, + outputTokenAmount: constants.ZERO_AMOUNT, + from: constants.NULL_ADDRESS, + to: zeroEx.address, + }, + ], + IERC20BridgeEvents.ERC20BridgeTransfer, + ); + }); + }); +}); diff --git a/contracts/zero-ex/test/wrappers.ts b/contracts/zero-ex/test/wrappers.ts index 904e4db89a..76dac9c6b4 100644 --- a/contracts/zero-ex/test/wrappers.ts +++ b/contracts/zero-ex/test/wrappers.ts @@ -22,6 +22,7 @@ export * from '../test/generated-wrappers/i_exchange'; export * from '../test/generated-wrappers/i_feature'; export * from '../test/generated-wrappers/i_flash_wallet'; export * from '../test/generated-wrappers/i_gas_token'; +export * from '../test/generated-wrappers/i_liquidity_provider_feature'; export * from '../test/generated-wrappers/i_meta_transactions_feature'; export * from '../test/generated-wrappers/i_ownable_feature'; export * from '../test/generated-wrappers/i_signature_validator_feature'; @@ -35,6 +36,8 @@ export * from '../test/generated-wrappers/initial_migration'; export * from '../test/generated-wrappers/lib_bootstrap'; export * from '../test/generated-wrappers/lib_common_rich_errors'; export * from '../test/generated-wrappers/lib_erc20_transformer'; +export * from '../test/generated-wrappers/lib_liquidity_provider_rich_errors'; +export * from '../test/generated-wrappers/lib_liquidity_provider_storage'; export * from '../test/generated-wrappers/lib_meta_transactions_rich_errors'; export * from '../test/generated-wrappers/lib_meta_transactions_storage'; export * from '../test/generated-wrappers/lib_migrate'; @@ -53,6 +56,7 @@ export * from '../test/generated-wrappers/lib_token_spender_storage'; export * from '../test/generated-wrappers/lib_transform_erc20_rich_errors'; export * from '../test/generated-wrappers/lib_transform_erc20_storage'; export * from '../test/generated-wrappers/lib_wallet_rich_errors'; +export * from '../test/generated-wrappers/liquidity_provider_feature'; export * from '../test/generated-wrappers/log_metadata_transformer'; export * from '../test/generated-wrappers/meta_transactions_feature'; export * from '../test/generated-wrappers/mixin_adapter_addresses'; @@ -69,6 +73,7 @@ export * from '../test/generated-wrappers/ownable_feature'; export * from '../test/generated-wrappers/pay_taker_transformer'; export * from '../test/generated-wrappers/signature_validator_feature'; export * from '../test/generated-wrappers/simple_function_registry_feature'; +export * from '../test/generated-wrappers/test_bridge'; export * from '../test/generated-wrappers/test_call_target'; export * from '../test/generated-wrappers/test_delegate_caller'; export * from '../test/generated-wrappers/test_fill_quote_transformer_bridge'; diff --git a/contracts/zero-ex/tsconfig.json b/contracts/zero-ex/tsconfig.json index c74120dd9d..4cc0fb27af 100644 --- a/contracts/zero-ex/tsconfig.json +++ b/contracts/zero-ex/tsconfig.json @@ -16,6 +16,7 @@ "generated-artifacts/ITransformERC20Feature.json", "generated-artifacts/IZeroEx.json", "generated-artifacts/InitialMigration.json", + "generated-artifacts/LiquidityProviderFeature.json", "generated-artifacts/LogMetadataTransformer.json", "generated-artifacts/MetaTransactionsFeature.json", "generated-artifacts/OwnableFeature.json", @@ -45,6 +46,7 @@ "test/generated-artifacts/IFeature.json", "test/generated-artifacts/IFlashWallet.json", "test/generated-artifacts/IGasToken.json", + "test/generated-artifacts/ILiquidityProviderFeature.json", "test/generated-artifacts/IMetaTransactionsFeature.json", "test/generated-artifacts/IOwnableFeature.json", "test/generated-artifacts/ISignatureValidatorFeature.json", @@ -58,6 +60,8 @@ "test/generated-artifacts/LibBootstrap.json", "test/generated-artifacts/LibCommonRichErrors.json", "test/generated-artifacts/LibERC20Transformer.json", + "test/generated-artifacts/LibLiquidityProviderRichErrors.json", + "test/generated-artifacts/LibLiquidityProviderStorage.json", "test/generated-artifacts/LibMetaTransactionsRichErrors.json", "test/generated-artifacts/LibMetaTransactionsStorage.json", "test/generated-artifacts/LibMigrate.json", @@ -76,6 +80,7 @@ "test/generated-artifacts/LibTransformERC20RichErrors.json", "test/generated-artifacts/LibTransformERC20Storage.json", "test/generated-artifacts/LibWalletRichErrors.json", + "test/generated-artifacts/LiquidityProviderFeature.json", "test/generated-artifacts/LogMetadataTransformer.json", "test/generated-artifacts/MetaTransactionsFeature.json", "test/generated-artifacts/MixinAdapterAddresses.json", @@ -92,6 +97,7 @@ "test/generated-artifacts/PayTakerTransformer.json", "test/generated-artifacts/SignatureValidatorFeature.json", "test/generated-artifacts/SimpleFunctionRegistryFeature.json", + "test/generated-artifacts/TestBridge.json", "test/generated-artifacts/TestCallTarget.json", "test/generated-artifacts/TestDelegateCaller.json", "test/generated-artifacts/TestFillQuoteTransformerBridge.json", diff --git a/packages/asset-swapper/CHANGELOG.json b/packages/asset-swapper/CHANGELOG.json index cc4d84e97a..71d531e7c1 100644 --- a/packages/asset-swapper/CHANGELOG.json +++ b/packages/asset-swapper/CHANGELOG.json @@ -133,6 +133,10 @@ { "note": "Respect max slippage in EP consumer", "pr": 2712 + }, + { + "note": "Introduced Path class, exchangeProxyOverhead parameter", + "pr": 2691 } ] }, diff --git a/packages/asset-swapper/src/index.ts b/packages/asset-swapper/src/index.ts index f9029e256b..5f0cf98d06 100644 --- a/packages/asset-swapper/src/index.ts +++ b/packages/asset-swapper/src/index.ts @@ -119,6 +119,7 @@ export { SwapQuoterRfqtOpts, } from './types'; export { affiliateFeeUtils } from './utils/affiliate_fee_utils'; +export { SOURCE_FLAGS } from './utils/market_operation_utils/constants'; export { Parameters, SamplerContractCall, @@ -133,10 +134,10 @@ export { CurveInfo, DexSample, ERC20BridgeSource, + ExchangeProxyOverhead, FeeSchedule, Fill, FillData, - FillFlags, GetMarketOrdersRfqtOpts, KyberFillData, LiquidityProviderFillData, 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 1b65b5538a..ce48b44fb4 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts @@ -3,7 +3,7 @@ import { BigNumber } from '@0x/utils'; import { SourceFilters } from './source_filters'; import { CurveFunctionSelectors, CurveInfo, ERC20BridgeSource, GetMarketOrdersOpts } from './types'; -// tslint:disable: custom-no-magic-numbers +// tslint:disable: custom-no-magic-numbers no-bitwise /** * Valid sources for market sell. @@ -58,6 +58,7 @@ export const DEFAULT_GET_MARKET_ORDERS_OPTS: GetMarketOrdersOpts = { sampleDistributionBase: 1.05, feeSchedule: {}, gasSchedule: {}, + exchangeProxyOverhead: () => ZERO_AMOUNT, allowFallback: true, shouldBatchBridgeOrders: true, shouldGenerateQuoteReport: false, @@ -68,6 +69,11 @@ export const DEFAULT_GET_MARKET_ORDERS_OPTS: GetMarketOrdersOpts = { */ export const FEE_QUOTE_SOURCES = [ERC20BridgeSource.Uniswap, ERC20BridgeSource.UniswapV2]; +export const SOURCE_FLAGS: { [source in ERC20BridgeSource]: number } = Object.assign( + {}, + ...Object.values(ERC20BridgeSource).map((source: ERC20BridgeSource, index) => ({ [source]: 1 << index })), +); + /** * Mainnet Curve configuration */ diff --git a/packages/asset-swapper/src/utils/market_operation_utils/fills.ts b/packages/asset-swapper/src/utils/market_operation_utils/fills.ts index 7ae13d60af..4806c71c09 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/fills.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/fills.ts @@ -3,15 +3,15 @@ import { BigNumber, hexUtils } from '@0x/utils'; import { MarketOperation, SignedOrderWithFillableAmounts } from '../../types'; import { fillableAmountsUtils } from '../../utils/fillable_amounts_utils'; -import { POSITIVE_INF, ZERO_AMOUNT } from './constants'; -import { CollapsedFill, DexSample, ERC20BridgeSource, FeeSchedule, Fill, FillFlags, MultiHopFillData } from './types'; +import { POSITIVE_INF, SOURCE_FLAGS, ZERO_AMOUNT } from './constants'; +import { DexSample, ERC20BridgeSource, FeeSchedule, Fill } from './types'; // tslint:disable: prefer-for-of no-bitwise completed-docs /** - * Create fill paths from orders and dex quotes. + * Create `Fill` objects from orders and dex quotes. */ -export function createFillPaths(opts: { +export function createFills(opts: { side: MarketOperation; orders?: SignedOrderWithFillableAmounts[]; dexQuotes?: DexSample[][]; @@ -28,30 +28,50 @@ export function createFillPaths(opts: { const dexQuotes = opts.dexQuotes || []; const ethToOutputRate = opts.ethToOutputRate || ZERO_AMOUNT; const ethToInputRate = opts.ethToInputRate || ZERO_AMOUNT; - // Create native fill paths. - const nativePath = nativeOrdersToPath(side, orders, opts.targetInput, ethToOutputRate, ethToInputRate, feeSchedule); - // Create DEX fill paths. - const dexPaths = dexQuotesToPaths(side, dexQuotes, ethToOutputRate, feeSchedule); - return filterPaths([...dexPaths, nativePath].map(p => clipPathToInput(p, opts.targetInput)), excludedSources); + // Create native fills. + const nativeFills = nativeOrdersToFills( + side, + orders, + opts.targetInput, + ethToOutputRate, + ethToInputRate, + feeSchedule, + ); + // Create DEX fills. + const dexFills = dexQuotes.map(singleSourceSamples => + dexSamplesToFills(side, singleSourceSamples, ethToOutputRate, ethToInputRate, feeSchedule), + ); + return [...dexFills, nativeFills] + .map(p => clipFillsToInput(p, opts.targetInput)) + .filter(fills => hasLiquidity(fills) && !excludedSources.includes(fills[0].source)); } -function filterPaths(paths: Fill[][], excludedSources: ERC20BridgeSource[]): Fill[][] { - return paths.filter(path => { - if (path.length === 0) { - return false; +function clipFillsToInput(fills: Fill[], targetInput: BigNumber = POSITIVE_INF): Fill[] { + const clipped: Fill[] = []; + let input = ZERO_AMOUNT; + for (const fill of fills) { + if (input.gte(targetInput)) { + break; } - const [input, output] = getPathSize(path); - if (input.eq(0) || output.eq(0)) { - return false; - } - if (excludedSources.includes(path[0].source)) { - return false; - } - return true; - }); + input = input.plus(fill.input); + clipped.push(fill); + } + return clipped; } -function nativeOrdersToPath( +function hasLiquidity(fills: Fill[]): boolean { + if (fills.length === 0) { + return false; + } + const totalInput = BigNumber.sum(...fills.map(fill => fill.input)); + const totalOutput = BigNumber.sum(...fills.map(fill => fill.output)); + if (totalInput.isZero() || totalOutput.isZero()) { + return false; + } + return true; +} + +function nativeOrdersToFills( side: MarketOperation, orders: SignedOrderWithFillableAmounts[], targetInput: BigNumber = POSITIVE_INF, @@ -61,7 +81,7 @@ function nativeOrdersToPath( ): Fill[] { const sourcePathId = hexUtils.random(); // Create a single path from all orders. - let path: Array = []; + let fills: Array = []; for (const order of orders) { const makerAmount = fillableAmountsUtils.getMakerAssetAmountSwappedAfterOrderFees(order); const takerAmount = fillableAmountsUtils.getTakerAssetAmountSwappedAfterOrderFees(order); @@ -87,13 +107,13 @@ function nativeOrdersToPath( if (adjustedRate.lte(0)) { continue; } - path.push({ + fills.push({ sourcePathId, adjustedRate, adjustedOutput, input: clippedInput, output: clippedOutput, - flags: 0, + flags: SOURCE_FLAGS[ERC20BridgeSource.Native], index: 0, // TBD parent: undefined, // TBD source: ERC20BridgeSource.Native, @@ -101,240 +121,56 @@ function nativeOrdersToPath( }); } // Sort by descending adjusted rate. - path = path.sort((a, b) => b.adjustedRate.comparedTo(a.adjustedRate)); + fills = fills.sort((a, b) => b.adjustedRate.comparedTo(a.adjustedRate)); // Re-index fills. - for (let i = 0; i < path.length; ++i) { - path[i].parent = i === 0 ? undefined : path[i - 1]; - path[i].index = i; + for (let i = 0; i < fills.length; ++i) { + fills[i].parent = i === 0 ? undefined : fills[i - 1]; + fills[i].index = i; } - return path; + return fills; } -function dexQuotesToPaths( +function dexSamplesToFills( side: MarketOperation, - dexQuotes: DexSample[][], + samples: DexSample[], ethToOutputRate: BigNumber, + ethToInputRate: BigNumber, fees: FeeSchedule, -): Fill[][] { - const paths: Fill[][] = []; - for (let quote of dexQuotes) { - const sourcePathId = hexUtils.random(); - const path: Fill[] = []; - // Drop any non-zero entries. This can occur if the any fills on Kyber were UniswapReserves - // We need not worry about Kyber fills going to UniswapReserve as the input amount - // we fill is the same as we sampled. I.e we received [0,20,30] output from [1,2,3] input - // and we only fill [2,3] on Kyber (as 1 returns 0 output) - quote = quote.filter(q => !q.output.isZero()); - for (let i = 0; i < quote.length; i++) { - const sample = quote[i]; - const prevSample = i === 0 ? undefined : quote[i - 1]; - const { source, fillData } = sample; - const input = sample.input.minus(prevSample ? prevSample.input : 0); - const output = sample.output.minus(prevSample ? prevSample.output : 0); - const fee = fees[source] === undefined ? 0 : fees[source]!(sample.fillData); - const penalty = - i === 0 // Only the first fill in a DEX path incurs a penalty. - ? ethToOutputRate.times(fee) - : ZERO_AMOUNT; - const adjustedOutput = side === MarketOperation.Sell ? output.minus(penalty) : output.plus(penalty); - - path.push({ - sourcePathId, - input, - output, - adjustedOutput, - source, - fillData, - index: i, - parent: i !== 0 ? path[path.length - 1] : undefined, - flags: sourceToFillFlags(source), - }); +): Fill[] { + const sourcePathId = hexUtils.random(); + const fills: Fill[] = []; + // Drop any non-zero entries. This can occur if the any fills on Kyber were UniswapReserves + // We need not worry about Kyber fills going to UniswapReserve as the input amount + // we fill is the same as we sampled. I.e we received [0,20,30] output from [1,2,3] input + // and we only fill [2,3] on Kyber (as 1 returns 0 output) + const nonzeroSamples = samples.filter(q => !q.output.isZero()); + for (let i = 0; i < nonzeroSamples.length; i++) { + const sample = nonzeroSamples[i]; + const prevSample = i === 0 ? undefined : nonzeroSamples[i - 1]; + const { source, fillData } = sample; + const input = sample.input.minus(prevSample ? prevSample.input : 0); + const output = sample.output.minus(prevSample ? prevSample.output : 0); + const fee = fees[source] === undefined ? 0 : fees[source]!(sample.fillData); + let penalty = ZERO_AMOUNT; + if (i === 0) { + // Only the first fill in a DEX path incurs a penalty. + penalty = !ethToOutputRate.isZero() + ? ethToOutputRate.times(fee) + : ethToInputRate.times(fee).times(output.dividedToIntegerBy(input)); } - paths.push(path); - } - return paths; -} + const adjustedOutput = side === MarketOperation.Sell ? output.minus(penalty) : output.plus(penalty); -export function getTwoHopAdjustedRate( - side: MarketOperation, - twoHopQuote: DexSample, - targetInput: BigNumber, - ethToOutputRate: BigNumber, - fees: FeeSchedule = {}, -): BigNumber { - const { output, input, fillData } = twoHopQuote; - if (input.isLessThan(targetInput) || output.isZero()) { - return ZERO_AMOUNT; - } - const penalty = ethToOutputRate.times(fees[ERC20BridgeSource.MultiHop]!(fillData)); - const adjustedOutput = side === MarketOperation.Sell ? output.minus(penalty) : output.plus(penalty); - return side === MarketOperation.Sell ? adjustedOutput.div(input) : input.div(adjustedOutput); -} - -function sourceToFillFlags(source: ERC20BridgeSource): number { - switch (source) { - case ERC20BridgeSource.Uniswap: - return FillFlags.ConflictsWithMultiBridge; - case ERC20BridgeSource.LiquidityProvider: - return FillFlags.ConflictsWithMultiBridge; - case ERC20BridgeSource.MultiBridge: - return FillFlags.MultiBridge; - default: - return 0; - } -} - -export function getPathSize(path: Fill[], targetInput: BigNumber = POSITIVE_INF): [BigNumber, BigNumber] { - let input = ZERO_AMOUNT; - let output = ZERO_AMOUNT; - for (const fill of path) { - if (input.plus(fill.input).gte(targetInput)) { - const di = targetInput.minus(input); - input = input.plus(di); - output = output.plus(fill.output.times(di.div(fill.input))); - break; - } else { - input = input.plus(fill.input); - output = output.plus(fill.output); - } - } - return [input.integerValue(), output.integerValue()]; -} - -export function getPathAdjustedSize(path: Fill[], targetInput: BigNumber = POSITIVE_INF): [BigNumber, BigNumber] { - let input = ZERO_AMOUNT; - let output = ZERO_AMOUNT; - for (const fill of path) { - if (input.plus(fill.input).gte(targetInput)) { - const di = targetInput.minus(input); - if (di.gt(0)) { - input = input.plus(di); - // Penalty does not get interpolated. - const penalty = fill.adjustedOutput.minus(fill.output); - output = output.plus(fill.output.times(di.div(fill.input)).plus(penalty)); - } - break; - } else { - input = input.plus(fill.input); - output = output.plus(fill.adjustedOutput); - } - } - return [input.integerValue(), output.integerValue()]; -} - -export function isValidPath(path: Fill[], skipDuplicateCheck: boolean = false): boolean { - let flags = 0; - for (let i = 0; i < path.length; ++i) { - // Fill must immediately follow its parent. - if (path[i].parent) { - if (i === 0 || path[i - 1] !== path[i].parent) { - return false; - } - } - if (!skipDuplicateCheck) { - // Fill must not be duplicated. - for (let j = 0; j < i; ++j) { - if (path[i] === path[j]) { - return false; - } - } - } - flags |= path[i].flags; - } - return arePathFlagsAllowed(flags); -} - -export function arePathFlagsAllowed(flags: number): boolean { - const multiBridgeConflict = FillFlags.MultiBridge | FillFlags.ConflictsWithMultiBridge; - return (flags & multiBridgeConflict) !== multiBridgeConflict; -} - -export function clipPathToInput(path: Fill[], targetInput: BigNumber = POSITIVE_INF): Fill[] { - const clipped: Fill[] = []; - let input = ZERO_AMOUNT; - for (const fill of path) { - if (input.gte(targetInput)) { - break; - } - input = input.plus(fill.input); - clipped.push(fill); - } - return clipped; -} - -export function collapsePath(path: Fill[]): CollapsedFill[] { - const collapsed: CollapsedFill[] = []; - for (const fill of path) { - const source = fill.source; - if (collapsed.length !== 0 && source !== ERC20BridgeSource.Native) { - const prevFill = collapsed[collapsed.length - 1]; - // If the last fill is from the same source, merge them. - if (prevFill.sourcePathId === fill.sourcePathId) { - prevFill.input = prevFill.input.plus(fill.input); - prevFill.output = prevFill.output.plus(fill.output); - prevFill.fillData = fill.fillData; - prevFill.subFills.push(fill); - continue; - } - } - collapsed.push({ - sourcePathId: fill.sourcePathId, - source: fill.source, - fillData: fill.fillData, - input: fill.input, - output: fill.output, - subFills: [fill], + fills.push({ + sourcePathId, + input, + output, + adjustedOutput, + source, + fillData, + index: i, + parent: i !== 0 ? fills[fills.length - 1] : undefined, + flags: SOURCE_FLAGS[source], }); } - return collapsed; -} - -export function getPathAdjustedCompleteRate(side: MarketOperation, path: Fill[], targetInput: BigNumber): BigNumber { - const [input, output] = getPathAdjustedSize(path, targetInput); - return getCompleteRate(side, input, output, targetInput); -} - -export function getPathAdjustedRate(side: MarketOperation, path: Fill[], targetInput: BigNumber): BigNumber { - const [input, output] = getPathAdjustedSize(path, targetInput); - return getRate(side, input, output); -} - -export function getPathAdjustedSlippage( - side: MarketOperation, - path: Fill[], - inputAmount: BigNumber, - maxRate: BigNumber, -): number { - if (maxRate.eq(0)) { - return 0; - } - const totalRate = getPathAdjustedRate(side, path, inputAmount); - const rateChange = maxRate.minus(totalRate); - return rateChange.div(maxRate).toNumber(); -} - -export function getCompleteRate( - side: MarketOperation, - input: BigNumber, - output: BigNumber, - targetInput: BigNumber, -): BigNumber { - if (input.eq(0) || output.eq(0) || targetInput.eq(0)) { - return ZERO_AMOUNT; - } - // Penalize paths that fall short of the entire input amount by a factor of - // input / targetInput => (i / t) - if (side === MarketOperation.Sell) { - // (o / i) * (i / t) => (o / t) - return output.div(targetInput); - } - // (i / o) * (i / t) - return input.div(output).times(input.div(targetInput)); -} - -export function getRate(side: MarketOperation, input: BigNumber, output: BigNumber): BigNumber { - if (input.eq(0) || output.eq(0)) { - return ZERO_AMOUNT; - } - return side === MarketOperation.Sell ? output.div(input) : input.div(output); + return fills; } 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 dea88d3537..38c10a1f26 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/index.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/index.ts @@ -14,12 +14,12 @@ import { FEE_QUOTE_SOURCES, ONE_ETHER, SELL_SOURCE_FILTER, + SOURCE_FLAGS, ZERO_AMOUNT, } from './constants'; -import { createFillPaths, getPathAdjustedRate, getPathAdjustedSlippage } from './fills'; +import { createFills } from './fills'; import { getBestTwoHopQuote } from './multihop_utils'; import { - createOrdersFromPath, createOrdersFromTwoHopSample, createSignedOrdersFromRfqtIndicativeQuotes, createSignedOrdersWithFillableAmounts, @@ -30,14 +30,15 @@ import { DexOrderSampler, getSampleAmounts } from './sampler'; import { SourceFilters } from './source_filters'; import { AggregationError, + CollapsedFill, DexSample, ERC20BridgeSource, + ExchangeProxyOverhead, FeeSchedule, GetMarketOrdersOpts, MarketSideLiquidity, OptimizedMarketOrder, OptimizerResult, - OptimizerResultWithReport, OrderDomain, TokenAdjacencyGraph, } from './types'; @@ -359,7 +360,7 @@ export class MarketOperationUtils { nativeOrders: SignedOrder[], takerAmount: BigNumber, opts?: Partial, - ): Promise { + ): Promise { const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; const marketSideLiquidity = await this.getMarketSellLiquidityAsync(nativeOrders, takerAmount, _opts); const optimizerResult = await this._generateOptimizedOrdersAsync(marketSideLiquidity, { @@ -367,6 +368,7 @@ export class MarketOperationUtils { maxFallbackSlippage: _opts.maxFallbackSlippage, excludedSources: _opts.excludedSources, feeSchedule: _opts.feeSchedule, + exchangeProxyOverhead: _opts.exchangeProxyOverhead, allowFallback: _opts.allowFallback, shouldBatchBridgeOrders: _opts.shouldBatchBridgeOrders, }); @@ -396,7 +398,7 @@ export class MarketOperationUtils { nativeOrders: SignedOrder[], makerAmount: BigNumber, opts?: Partial, - ): Promise { + ): Promise { const _opts = { ...DEFAULT_GET_MARKET_ORDERS_OPTS, ...opts }; const marketSideLiquidity = await this.getMarketBuyLiquidityAsync(nativeOrders, makerAmount, _opts); const optimizerResult = await this._generateOptimizedOrdersAsync(marketSideLiquidity, { @@ -404,6 +406,7 @@ export class MarketOperationUtils { maxFallbackSlippage: _opts.maxFallbackSlippage, excludedSources: _opts.excludedSources, feeSchedule: _opts.feeSchedule, + exchangeProxyOverhead: _opts.exchangeProxyOverhead, allowFallback: _opts.allowFallback, shouldBatchBridgeOrders: _opts.shouldBatchBridgeOrders, }); @@ -526,6 +529,7 @@ export class MarketOperationUtils { maxFallbackSlippage?: number; excludedSources?: ERC20BridgeSource[]; feeSchedule?: FeeSchedule; + exchangeProxyOverhead?: ExchangeProxyOverhead; allowFallback?: boolean; shouldBatchBridgeOrders?: boolean; }, @@ -554,8 +558,8 @@ export class MarketOperationUtils { shouldBatchBridgeOrders: !!opts.shouldBatchBridgeOrders, }; - // Convert native orders and dex quotes into fill paths. - const paths = createFillPaths({ + // Convert native orders and dex quotes into `Fill` objects. + const fills = createFills({ side, // Augment native orders with their fillable amounts. orders: [ @@ -571,51 +575,53 @@ export class MarketOperationUtils { }); // Find the optimal path. - let optimalPath = (await findOptimalPathAsync(side, paths, inputAmount, opts.runLimit)) || []; - if (optimalPath.length === 0) { + const optimizerOpts = { + ethToOutputRate, + ethToInputRate, + exchangeProxyOverhead: opts.exchangeProxyOverhead || (() => ZERO_AMOUNT), + }; + const optimalPath = await findOptimalPathAsync(side, fills, inputAmount, opts.runLimit, optimizerOpts); + if (optimalPath === undefined) { throw new Error(AggregationError.NoOptimalPath); } - const optimalPathRate = getPathAdjustedRate(side, optimalPath, inputAmount); + const optimalPathRate = optimalPath.adjustedRate(); const { adjustedRate: bestTwoHopRate, quote: bestTwoHopQuote } = getBestTwoHopQuote( marketSideLiquidity, opts.feeSchedule, + opts.exchangeProxyOverhead, ); if (bestTwoHopQuote && bestTwoHopRate.isGreaterThan(optimalPathRate)) { const twoHopOrders = createOrdersFromTwoHopSample(bestTwoHopQuote, orderOpts); - return { optimizedOrders: twoHopOrders, liquidityDelivered: bestTwoHopQuote, isTwoHop: true }; + return { + optimizedOrders: twoHopOrders, + liquidityDelivered: bestTwoHopQuote, + sourceFlags: SOURCE_FLAGS[ERC20BridgeSource.MultiHop], + }; } // Generate a fallback path if native orders are in the optimal path. - const nativeSubPath = optimalPath.filter(f => f.source === ERC20BridgeSource.Native); - if (opts.allowFallback && nativeSubPath.length !== 0) { + const nativeFills = optimalPath.fills.filter(f => f.source === ERC20BridgeSource.Native); + if (opts.allowFallback && nativeFills.length !== 0) { // We create a fallback path that is exclusive of Native liquidity // This is the optimal on-chain path for the entire input amount - const nonNativePaths = paths.filter(p => p.length > 0 && p[0].source !== ERC20BridgeSource.Native); - const nonNativeOptimalPath = - (await findOptimalPathAsync(side, nonNativePaths, inputAmount, opts.runLimit)) || []; + const nonNativeFills = fills.filter(p => p.length > 0 && p[0].source !== ERC20BridgeSource.Native); + const nonNativeOptimalPath = await findOptimalPathAsync(side, nonNativeFills, inputAmount, opts.runLimit); // Calculate the slippage of on-chain sources compared to the most optimal path - const fallbackSlippage = getPathAdjustedSlippage(side, nonNativeOptimalPath, inputAmount, optimalPathRate); - if (nativeSubPath.length === optimalPath.length || fallbackSlippage <= maxFallbackSlippage) { - // If the last fill is Native and penultimate is not, then the intention was to partial fill - // In this case we drop it entirely as we can't handle a failure at the end and we don't - // want to fully fill when it gets prepended to the front below - const [last, penultimateIfExists] = optimalPath.slice().reverse(); - const lastNativeFillIfExists = - last.source === ERC20BridgeSource.Native && - penultimateIfExists && - penultimateIfExists.source !== ERC20BridgeSource.Native - ? last - : undefined; - // By prepending native paths to the front they cannot split on-chain sources and incur - // an additional protocol fee. I.e [Uniswap,Native,Kyber] becomes [Native,Uniswap,Kyber] - // In the previous step we dropped any hanging Native partial fills, as to not fully fill - optimalPath = [...nativeSubPath.filter(f => f !== lastNativeFillIfExists), ...nonNativeOptimalPath]; + if ( + nonNativeOptimalPath !== undefined && + (nativeFills.length === optimalPath.fills.length || + nonNativeOptimalPath.adjustedSlippage(optimalPathRate) <= maxFallbackSlippage) + ) { + optimalPath.addFallback(nonNativeOptimalPath); } } - const optimizedOrders = createOrdersFromPath(optimalPath, orderOpts); - const liquidityDelivered = _.flatten(optimizedOrders.map(order => order.fills)); - return { optimizedOrders, liquidityDelivered, isTwoHop: false }; + const collapsedPath = optimalPath.collapse(orderOpts); + return { + optimizedOrders: collapsedPath.orders, + liquidityDelivered: collapsedPath.collapsedFills as CollapsedFill[], + sourceFlags: collapsedPath.sourceFlags, + }; } } diff --git a/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts b/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts index 28c5043d62..9d6832b858 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/multihop_utils.ts @@ -2,8 +2,15 @@ import { BigNumber } from '@0x/utils'; import * as _ from 'lodash'; import { ZERO_AMOUNT } from './constants'; -import { getTwoHopAdjustedRate } from './fills'; -import { DexSample, FeeSchedule, MarketSideLiquidity, MultiHopFillData, TokenAdjacencyGraph } from './types'; +import { getTwoHopAdjustedRate } from './rate_utils'; +import { + DexSample, + ExchangeProxyOverhead, + FeeSchedule, + MarketSideLiquidity, + MultiHopFillData, + TokenAdjacencyGraph, +} from './types'; /** * Given a token pair, returns the intermediate tokens to consider for two-hop routes. @@ -36,18 +43,28 @@ export function getIntermediateTokens( export function getBestTwoHopQuote( marketSideLiquidity: MarketSideLiquidity, feeSchedule?: FeeSchedule, + exchangeProxyOverhead?: ExchangeProxyOverhead, ): { quote: DexSample | undefined; adjustedRate: BigNumber } { const { side, inputAmount, ethToOutputRate, twoHopQuotes } = marketSideLiquidity; if (twoHopQuotes.length === 0) { return { adjustedRate: ZERO_AMOUNT, quote: undefined }; } const best = twoHopQuotes - .map(quote => getTwoHopAdjustedRate(side, quote, inputAmount, ethToOutputRate, feeSchedule)) + .map(quote => + getTwoHopAdjustedRate(side, quote, inputAmount, ethToOutputRate, feeSchedule, exchangeProxyOverhead), + ) .reduce( (prev, curr, i) => curr.isGreaterThan(prev.adjustedRate) ? { adjustedRate: curr, quote: twoHopQuotes[i] } : prev, { - adjustedRate: getTwoHopAdjustedRate(side, twoHopQuotes[0], inputAmount, ethToOutputRate, feeSchedule), + adjustedRate: getTwoHopAdjustedRate( + side, + twoHopQuotes[0], + inputAmount, + ethToOutputRate, + feeSchedule, + exchangeProxyOverhead, + ), quote: twoHopQuotes[0], }, ); diff --git a/packages/asset-swapper/src/utils/market_operation_utils/orders.ts b/packages/asset-swapper/src/utils/market_operation_utils/orders.ts index efcb699a7a..67cfbc118e 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/orders.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/orders.ts @@ -16,7 +16,6 @@ import { WALLET_SIGNATURE, ZERO_AMOUNT, } from './constants'; -import { collapsePath } from './fills'; import { getMultiBridgeIntermediateToken } from './multibridge_utils'; import { AggregationError, @@ -26,7 +25,6 @@ import { CurveFillData, DexSample, ERC20BridgeSource, - Fill, KyberFillData, LiquidityProviderFillData, MooniswapFillData, @@ -155,37 +153,6 @@ export interface CreateOrderFromPathOpts { shouldBatchBridgeOrders: boolean; } -// Convert sell fills into orders. -export function createOrdersFromPath(path: Fill[], opts: CreateOrderFromPathOpts): OptimizedMarketOrder[] { - const [makerToken, takerToken] = getMakerTakerTokens(opts); - const collapsedPath = collapsePath(path); - const orders: OptimizedMarketOrder[] = []; - for (let i = 0; i < collapsedPath.length; ) { - if (collapsedPath[i].source === ERC20BridgeSource.Native) { - orders.push(createNativeOrder(collapsedPath[i] as NativeCollapsedFill)); - ++i; - continue; - } - // If there are contiguous bridge orders, we can batch them together. - const contiguousBridgeFills = [collapsedPath[i]]; - for (let j = i + 1; j < collapsedPath.length; ++j) { - if (collapsedPath[j].source === ERC20BridgeSource.Native) { - break; - } - contiguousBridgeFills.push(collapsedPath[j]); - } - // Always use DexForwarderBridge unless configured not to - if (!opts.shouldBatchBridgeOrders) { - orders.push(createBridgeOrder(contiguousBridgeFills[0], makerToken, takerToken, opts)); - i += 1; - } else { - orders.push(createBatchedBridgeOrder(contiguousBridgeFills, opts)); - i += contiguousBridgeFills.length; - } - } - return orders; -} - export function createOrdersFromTwoHopSample( sample: DexSample, opts: CreateOrderFromPathOpts, @@ -248,7 +215,7 @@ function getBridgeAddressFromFill(fill: CollapsedFill, opts: CreateOrderFromPath throw new Error(AggregationError.NoBridgeForSource); } -function createBridgeOrder( +export function createBridgeOrder( fill: CollapsedFill, makerToken: string, takerToken: string, @@ -362,7 +329,7 @@ function createBridgeOrder( }; } -function createBatchedBridgeOrder(fills: CollapsedFill[], opts: CreateOrderFromPathOpts): OptimizedMarketOrder { +export function createBatchedBridgeOrder(fills: CollapsedFill[], opts: CreateOrderFromPathOpts): OptimizedMarketOrder { const [makerToken, takerToken] = getMakerTakerTokens(opts); let totalMakerAssetAmount = ZERO_AMOUNT; let totalTakerAssetAmount = ZERO_AMOUNT; @@ -403,7 +370,7 @@ function createBatchedBridgeOrder(fills: CollapsedFill[], opts: CreateOrderFromP }; } -function getMakerTakerTokens(opts: CreateOrderFromPathOpts): [string, string] { +export function getMakerTakerTokens(opts: CreateOrderFromPathOpts): [string, string] { const makerToken = opts.side === MarketOperation.Sell ? opts.outputToken : opts.inputToken; const takerToken = opts.side === MarketOperation.Sell ? opts.inputToken : opts.outputToken; return [makerToken, takerToken]; @@ -525,7 +492,7 @@ function createCommonBridgeOrderFields(orderDomain: OrderDomain): CommonBridgeOr }; } -function createNativeOrder(fill: NativeCollapsedFill): OptimizedMarketOrder { +export function createNativeOrder(fill: NativeCollapsedFill): OptimizedMarketOrder { return { fills: [fill], ...fill.fillData!.order, // tslint:disable-line:no-non-null-assertion diff --git a/packages/asset-swapper/src/utils/market_operation_utils/path.ts b/packages/asset-swapper/src/utils/market_operation_utils/path.ts new file mode 100644 index 0000000000..e75e589081 --- /dev/null +++ b/packages/asset-swapper/src/utils/market_operation_utils/path.ts @@ -0,0 +1,287 @@ +import { BigNumber } from '@0x/utils'; + +import { MarketOperation } from '../../types'; + +import { POSITIVE_INF, SOURCE_FLAGS, ZERO_AMOUNT } from './constants'; +import { + createBatchedBridgeOrder, + createBridgeOrder, + createNativeOrder, + CreateOrderFromPathOpts, + getMakerTakerTokens, +} from './orders'; +import { getCompleteRate, getRate } from './rate_utils'; +import { + CollapsedFill, + ERC20BridgeSource, + ExchangeProxyOverhead, + Fill, + NativeCollapsedFill, + OptimizedMarketOrder, +} from './types'; + +// tslint:disable: prefer-for-of no-bitwise completed-docs + +export interface PathSize { + input: BigNumber; + output: BigNumber; +} + +export interface PathPenaltyOpts { + ethToOutputRate: BigNumber; + ethToInputRate: BigNumber; + exchangeProxyOverhead: ExchangeProxyOverhead; +} + +export const DEFAULT_PATH_PENALTY_OPTS: PathPenaltyOpts = { + ethToOutputRate: ZERO_AMOUNT, + ethToInputRate: ZERO_AMOUNT, + exchangeProxyOverhead: () => ZERO_AMOUNT, +}; + +export class Path { + public collapsedFills?: ReadonlyArray; + public orders?: OptimizedMarketOrder[]; + public sourceFlags: number = 0; + protected _size: PathSize = { input: ZERO_AMOUNT, output: ZERO_AMOUNT }; + protected _adjustedSize: PathSize = { input: ZERO_AMOUNT, output: ZERO_AMOUNT }; + + public static create( + side: MarketOperation, + fills: ReadonlyArray, + targetInput: BigNumber = POSITIVE_INF, + pathPenaltyOpts: PathPenaltyOpts = DEFAULT_PATH_PENALTY_OPTS, + ): Path { + const path = new Path(side, fills, targetInput, pathPenaltyOpts); + fills.forEach(fill => { + path.sourceFlags |= fill.flags; + path._addFillSize(fill); + }); + return path; + } + + public static clone(base: Path): Path { + const clonedPath = new Path(base.side, base.fills.slice(), base.targetInput, base.pathPenaltyOpts); + clonedPath.sourceFlags = base.sourceFlags; + clonedPath._size = { ...base._size }; + clonedPath._adjustedSize = { ...base._adjustedSize }; + clonedPath.collapsedFills = base.collapsedFills === undefined ? undefined : base.collapsedFills.slice(); + clonedPath.orders = base.orders === undefined ? undefined : base.orders.slice(); + return clonedPath; + } + + protected constructor( + protected readonly side: MarketOperation, + public fills: ReadonlyArray, + protected readonly targetInput: BigNumber, + public readonly pathPenaltyOpts: PathPenaltyOpts, + ) {} + + public append(fill: Fill): this { + (this.fills as Fill[]).push(fill); + this.sourceFlags |= fill.flags; + this._addFillSize(fill); + return this; + } + + public addFallback(fallback: Path): this { + // If the last fill is Native and penultimate is not, then the intention was to partial fill + // In this case we drop it entirely as we can't handle a failure at the end and we don't + // want to fully fill when it gets prepended to the front below + const [last, penultimateIfExists] = this.fills.slice().reverse(); + const lastNativeFillIfExists = + last.source === ERC20BridgeSource.Native && + penultimateIfExists && + penultimateIfExists.source !== ERC20BridgeSource.Native + ? last + : undefined; + // By prepending native paths to the front they cannot split on-chain sources and incur + // an additional protocol fee. I.e [Uniswap,Native,Kyber] becomes [Native,Uniswap,Kyber] + // In the previous step we dropped any hanging Native partial fills, as to not fully fill + const nativeFills = this.fills.filter(f => f.source === ERC20BridgeSource.Native); + this.fills = [...nativeFills.filter(f => f !== lastNativeFillIfExists), ...fallback.fills]; + // Recompute the source flags + this.sourceFlags = this.fills.reduce((flags, fill) => flags | fill.flags, 0); + return this; + } + + public collapse(opts: CreateOrderFromPathOpts): CollapsedPath { + const [makerToken, takerToken] = getMakerTakerTokens(opts); + const collapsedFills = this.collapsedFills === undefined ? this._collapseFills() : this.collapsedFills; + this.orders = []; + for (let i = 0; i < collapsedFills.length; ) { + if (collapsedFills[i].source === ERC20BridgeSource.Native) { + this.orders.push(createNativeOrder(collapsedFills[i] as NativeCollapsedFill)); + ++i; + continue; + } + // If there are contiguous bridge orders, we can batch them together. + const contiguousBridgeFills = [collapsedFills[i]]; + for (let j = i + 1; j < collapsedFills.length; ++j) { + if (collapsedFills[j].source === ERC20BridgeSource.Native) { + break; + } + contiguousBridgeFills.push(collapsedFills[j]); + } + // Always use DexForwarderBridge unless configured not to + if (!opts.shouldBatchBridgeOrders) { + this.orders.push(createBridgeOrder(contiguousBridgeFills[0], makerToken, takerToken, opts)); + i += 1; + } else { + this.orders.push(createBatchedBridgeOrder(contiguousBridgeFills, opts)); + i += contiguousBridgeFills.length; + } + } + return this as CollapsedPath; + } + + public size(): PathSize { + return this._size; + } + + public adjustedSize(): PathSize { + const { input, output } = this._adjustedSize; + const { exchangeProxyOverhead, ethToOutputRate, ethToInputRate } = this.pathPenaltyOpts; + const gasOverhead = exchangeProxyOverhead(this.sourceFlags); + const pathPenalty = !ethToOutputRate.isZero() + ? ethToOutputRate.times(gasOverhead) + : ethToInputRate.times(gasOverhead).times(output.dividedToIntegerBy(input)); + return { + input, + output: this.side === MarketOperation.Sell ? output.minus(pathPenalty) : output.plus(pathPenalty), + }; + } + + public adjustedCompleteRate(): BigNumber { + const { input, output } = this.adjustedSize(); + return getCompleteRate(this.side, input, output, this.targetInput); + } + + public adjustedRate(): BigNumber { + const { input, output } = this.adjustedSize(); + return getRate(this.side, input, output); + } + + public adjustedSlippage(maxRate: BigNumber): number { + if (maxRate.eq(0)) { + return 0; + } + const totalRate = this.adjustedRate(); + const rateChange = maxRate.minus(totalRate); + return rateChange.div(maxRate).toNumber(); + } + + public isBetterThan(other: Path): boolean { + if (!this.targetInput.isEqualTo(other.targetInput)) { + throw new Error(`Target input mismatch: ${this.targetInput} !== ${other.targetInput}`); + } + const { targetInput } = this; + const { input } = this._size; + const { input: otherInput } = other._size; + if (input.isLessThan(targetInput) || otherInput.isLessThan(targetInput)) { + return input.isGreaterThan(otherInput); + } else { + return this.adjustedCompleteRate().isGreaterThan(other.adjustedCompleteRate()); + } + // if (otherInput.isLessThan(targetInput)) { + // return input.isGreaterThan(otherInput); + // } else if (input.isGreaterThanOrEqualTo(targetInput)) { + // return this.adjustedCompleteRate().isGreaterThan(other.adjustedCompleteRate()); + // } + // return false; + } + + public isComplete(): boolean { + const { input } = this._size; + return input.gte(this.targetInput); + } + + public isValid(skipDuplicateCheck: boolean = false): boolean { + for (let i = 0; i < this.fills.length; ++i) { + // Fill must immediately follow its parent. + if (this.fills[i].parent) { + if (i === 0 || this.fills[i - 1] !== this.fills[i].parent) { + return false; + } + } + if (!skipDuplicateCheck) { + // Fill must not be duplicated. + for (let j = 0; j < i; ++j) { + if (this.fills[i] === this.fills[j]) { + return false; + } + } + } + } + return doSourcesConflict(this.sourceFlags); + } + + public isValidNextFill(fill: Fill): boolean { + if (this.fills.length === 0) { + return !fill.parent; + } + if (this.fills[this.fills.length - 1] === fill.parent) { + return true; + } + if (fill.parent) { + return false; + } + return doSourcesConflict(this.sourceFlags | fill.flags); + } + + private _collapseFills(): ReadonlyArray { + this.collapsedFills = []; + for (const fill of this.fills) { + const source = fill.source; + if (this.collapsedFills.length !== 0 && source !== ERC20BridgeSource.Native) { + const prevFill = this.collapsedFills[this.collapsedFills.length - 1]; + // If the last fill is from the same source, merge them. + if (prevFill.sourcePathId === fill.sourcePathId) { + prevFill.input = prevFill.input.plus(fill.input); + prevFill.output = prevFill.output.plus(fill.output); + prevFill.fillData = fill.fillData; + prevFill.subFills.push(fill); + continue; + } + } + (this.collapsedFills as CollapsedFill[]).push({ + sourcePathId: fill.sourcePathId, + source: fill.source, + fillData: fill.fillData, + input: fill.input, + output: fill.output, + subFills: [fill], + }); + } + return this.collapsedFills; + } + + private _addFillSize(fill: Fill): void { + if (this._size.input.plus(fill.input).isGreaterThan(this.targetInput)) { + const remainingInput = this.targetInput.minus(this._size.input); + const scaledFillOutput = fill.output.times(remainingInput.div(fill.input)); + this._size.input = this.targetInput; + this._size.output = this._size.output.plus(scaledFillOutput); + // Penalty does not get interpolated. + const penalty = fill.adjustedOutput.minus(fill.output); + this._adjustedSize.input = this.targetInput; + this._adjustedSize.output = this._adjustedSize.output.plus(scaledFillOutput).plus(penalty); + } else { + this._size.input = this._size.input.plus(fill.input); + this._size.output = this._size.output.plus(fill.output); + this._adjustedSize.input = this._adjustedSize.input.plus(fill.input); + this._adjustedSize.output = this._adjustedSize.output.plus(fill.adjustedOutput); + } + } +} + +export interface CollapsedPath extends Path { + readonly collapsedFills: ReadonlyArray; + readonly orders: OptimizedMarketOrder[]; +} + +const MULTIBRIDGE_SOURCES = SOURCE_FLAGS.LiquidityProvider | SOURCE_FLAGS.Uniswap; +export function doSourcesConflict(flags: number): boolean { + const multiBridgeConflict = flags & SOURCE_FLAGS.MultiBridge && flags & MULTIBRIDGE_SOURCES; + return !multiBridgeConflict; +} diff --git a/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts b/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts index f649c59e07..5f0ce4b679 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/path_optimizer.ts @@ -1,17 +1,9 @@ import { BigNumber } from '@0x/utils'; +import * as _ from 'lodash'; import { MarketOperation } from '../../types'; -import { ZERO_AMOUNT } from './constants'; -import { - arePathFlagsAllowed, - getCompleteRate, - getPathAdjustedCompleteRate, - getPathAdjustedRate, - getPathAdjustedSize, - getPathSize, - isValidPath, -} from './fills'; +import { DEFAULT_PATH_PENALTY_OPTS, Path, PathPenaltyOpts } from './path'; import { Fill } from './types'; // tslint:disable: prefer-for-of custom-no-magic-numbers completed-docs no-bitwise @@ -19,134 +11,93 @@ import { Fill } from './types'; const RUN_LIMIT_DECAY_FACTOR = 0.5; /** - * Find the optimal mixture of paths that maximizes (for sells) or minimizes + * Find the optimal mixture of fills that maximizes (for sells) or minimizes * (for buys) output, while meeting the input requirement. */ export async function findOptimalPathAsync( side: MarketOperation, - paths: Fill[][], + fills: Fill[][], targetInput: BigNumber, runLimit: number = 2 ** 8, -): Promise { - // Sort paths by descending adjusted completed rate. - const sortedPaths = paths - .slice(0) - .sort((a, b) => - getPathAdjustedCompleteRate(side, b, targetInput).comparedTo( - getPathAdjustedCompleteRate(side, a, targetInput), - ), - ); - let optimalPath = sortedPaths[0] || []; + opts: PathPenaltyOpts = DEFAULT_PATH_PENALTY_OPTS, +): Promise { + const rates = rateBySourcePathId(side, fills, targetInput); + const paths = fills.map(singleSourceFills => Path.create(side, singleSourceFills, targetInput, opts)); + // Sort fill arrays by descending adjusted completed rate. + const sortedPaths = paths.sort((a, b) => b.adjustedCompleteRate().comparedTo(a.adjustedCompleteRate())); + if (sortedPaths.length === 0) { + return undefined; + } + let optimalPath = sortedPaths[0]; for (const [i, path] of sortedPaths.slice(1).entries()) { - optimalPath = mixPaths(side, optimalPath, path, targetInput, runLimit * RUN_LIMIT_DECAY_FACTOR ** i); + optimalPath = mixPaths(side, optimalPath, path, targetInput, runLimit * RUN_LIMIT_DECAY_FACTOR ** i, rates); // Yield to event loop. await Promise.resolve(); } - return isPathComplete(optimalPath, targetInput) ? optimalPath : undefined; + return optimalPath.isComplete() ? optimalPath : undefined; } function mixPaths( side: MarketOperation, - pathA: Fill[], - pathB: Fill[], + pathA: Path, + pathB: Path, targetInput: BigNumber, maxSteps: number, -): Fill[] { + rates: { [id: string]: BigNumber }, +): Path { const _maxSteps = Math.max(maxSteps, 32); let steps = 0; // We assume pathA is the better of the two initially. - let bestPath: Fill[] = pathA; - let [bestPathInput, bestPathOutput] = getPathAdjustedSize(pathA, targetInput); - let bestPathRate = getCompleteRate(side, bestPathInput, bestPathOutput, targetInput); - const _isBetterPath = (input: BigNumber, rate: BigNumber) => { - if (bestPathInput.lt(targetInput)) { - return input.gt(bestPathInput); - } else if (input.gte(targetInput)) { - return rate.gt(bestPathRate); - } - return false; - }; - const _walk = (path: Fill[], input: BigNumber, output: BigNumber, flags: number, remainingFills: Fill[]) => { + let bestPath: Path = pathA; + + const _walk = (path: Path, remainingFills: Fill[]) => { steps += 1; - const rate = getCompleteRate(side, input, output, targetInput); - if (_isBetterPath(input, rate)) { + if (path.isBetterThan(bestPath)) { bestPath = path; - bestPathInput = input; - bestPathOutput = output; - bestPathRate = rate; } - const remainingInput = targetInput.minus(input); - if (remainingInput.gt(0)) { + const remainingInput = targetInput.minus(path.size().input); + if (remainingInput.isGreaterThan(0)) { for (let i = 0; i < remainingFills.length && steps < _maxSteps; ++i) { const fill = remainingFills[i]; // Only walk valid paths. - if (!isValidNextPathFill(path, flags, fill)) { + if (!path.isValidNextFill(fill)) { continue; } // Remove this fill from the next list of candidate fills. const nextRemainingFills = remainingFills.slice(); nextRemainingFills.splice(i, 1); // Recurse. - _walk( - [...path, fill], - input.plus(BigNumber.min(remainingInput, fill.input)), - output.plus( - // Clip the output of the next fill to the remaining - // input. - clipFillAdjustedOutput(fill, remainingInput), - ), - flags | fill.flags, - nextRemainingFills, - ); + _walk(Path.clone(path).append(fill), nextRemainingFills); } } }; - const allFills = [...pathA, ...pathB]; - const sources = allFills.filter(f => f.index === 0).map(f => f.sourcePathId); - const rateBySource = Object.assign( - {}, - ...sources.map(s => ({ - [s]: getPathAdjustedRate(side, allFills.filter(f => f.sourcePathId === s), targetInput), - })), - ); + const allFills = [...pathA.fills, ...pathB.fills]; // Sort subpaths by rate and keep fills contiguous to improve our // chances of walking ideal, valid paths first. const sortedFills = allFills.sort((a, b) => { if (a.sourcePathId !== b.sourcePathId) { - return rateBySource[b.sourcePathId].comparedTo(rateBySource[a.sourcePathId]); + return rates[b.sourcePathId].comparedTo(rates[a.sourcePathId]); } return a.index - b.index; }); - _walk([], ZERO_AMOUNT, ZERO_AMOUNT, 0, sortedFills); - if (!isValidPath(bestPath)) { + _walk(Path.create(side, [], targetInput, pathA.pathPenaltyOpts), sortedFills); + if (!bestPath.isValid()) { throw new Error('nooope'); } return bestPath; } -function isValidNextPathFill(path: Fill[], pathFlags: number, fill: Fill): boolean { - if (path.length === 0) { - return !fill.parent; - } - if (path[path.length - 1] === fill.parent) { - return true; - } - if (fill.parent) { - return false; - } - return arePathFlagsAllowed(pathFlags | fill.flags); -} - -function isPathComplete(path: Fill[], targetInput: BigNumber): boolean { - const [input] = getPathSize(path); - return input.gte(targetInput); -} - -function clipFillAdjustedOutput(fill: Fill, remainingInput: BigNumber): BigNumber { - if (fill.input.lte(remainingInput)) { - return fill.adjustedOutput; - } - // Penalty does not get interpolated. - const penalty = fill.adjustedOutput.minus(fill.output); - return remainingInput.times(fill.output.div(fill.input)).plus(penalty); +function rateBySourcePathId( + side: MarketOperation, + fills: Fill[][], + targetInput: BigNumber, +): { [id: string]: BigNumber } { + const flattenedFills = _.flatten(fills); + const sourcePathIds = flattenedFills.filter(f => f.index === 0).map(f => f.sourcePathId); + return Object.assign( + {}, + ...sourcePathIds.map(s => ({ + [s]: Path.create(side, flattenedFills.filter(f => f.sourcePathId === s), targetInput).adjustedRate(), + })), + ); } diff --git a/packages/asset-swapper/src/utils/market_operation_utils/rate_utils.ts b/packages/asset-swapper/src/utils/market_operation_utils/rate_utils.ts new file mode 100644 index 0000000000..43d007b623 --- /dev/null +++ b/packages/asset-swapper/src/utils/market_operation_utils/rate_utils.ts @@ -0,0 +1,62 @@ +import { BigNumber } from '@0x/utils'; + +import { MarketOperation } from '../../types'; + +import { SOURCE_FLAGS, ZERO_AMOUNT } from './constants'; +import { DexSample, ERC20BridgeSource, ExchangeProxyOverhead, FeeSchedule, MultiHopFillData } from './types'; + +/** + * Returns the fee-adjusted rate of a two-hop quote. Returns zero if the + * quote falls short of the target input. + */ +export function getTwoHopAdjustedRate( + side: MarketOperation, + twoHopQuote: DexSample, + targetInput: BigNumber, + ethToOutputRate: BigNumber, + fees: FeeSchedule = {}, + exchangeProxyOverhead: ExchangeProxyOverhead = () => ZERO_AMOUNT, +): BigNumber { + const { output, input, fillData } = twoHopQuote; + if (input.isLessThan(targetInput) || output.isZero()) { + return ZERO_AMOUNT; + } + const penalty = ethToOutputRate.times( + exchangeProxyOverhead(SOURCE_FLAGS.MultiHop).plus(fees[ERC20BridgeSource.MultiHop]!(fillData)), + ); + const adjustedOutput = side === MarketOperation.Sell ? output.minus(penalty) : output.plus(penalty); + return side === MarketOperation.Sell ? adjustedOutput.div(input) : input.div(adjustedOutput); +} + +/** + * Computes the "complete" rate given the input/output of a path. + * This value penalizes the path if it falls short of the target input. + */ +export function getCompleteRate( + side: MarketOperation, + input: BigNumber, + output: BigNumber, + targetInput: BigNumber, +): BigNumber { + if (input.eq(0) || output.eq(0) || targetInput.eq(0)) { + return ZERO_AMOUNT; + } + // Penalize paths that fall short of the entire input amount by a factor of + // input / targetInput => (i / t) + if (side === MarketOperation.Sell) { + // (o / i) * (i / t) => (o / t) + return output.div(targetInput); + } + // (i / o) * (i / t) + return input.div(output).times(input.div(targetInput)); +} + +/** + * Computes the rate given the input/output of a path. + */ +export function getRate(side: MarketOperation, input: BigNumber, output: BigNumber): BigNumber { + if (input.eq(0) || output.eq(0)) { + return ZERO_AMOUNT; + } + return side === MarketOperation.Sell ? output.div(input) : input.div(output); +} 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 5e7ea158fd..c6af06a62e 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/types.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/types.ts @@ -156,16 +156,6 @@ export interface DexSample extends Source output: BigNumber; } -/** - * Flags for `Fill` objects. - */ -export enum FillFlags { - ConflictsWithKyber = 0x1, - Kyber = 0x2, - ConflictsWithMultiBridge = 0x4, - MultiBridge = 0x8, -} - /** * Represents a node on a fill path. */ @@ -174,8 +164,8 @@ export interface Fill extends SourceInfo< // This is generated when the path is generated and is useful to distinguish // paths that have the same `source` IDs but are distinct (e.g., Curves). sourcePathId: string; - // See `FillFlags`. - flags: FillFlags; + // See `SOURCE_FLAGS`. + flags: number; // Input fill amount (taker asset amount in a sell, maker asset amount in a buy). input: BigNumber; // Output fill amount (maker asset amount in a sell, taker asset amount in a buy). @@ -234,6 +224,7 @@ export interface GetMarketOrdersRfqtOpts extends RfqtRequestOpts { export type FeeEstimate = (fillData?: FillData) => number | BigNumber; export type FeeSchedule = Partial<{ [key in ERC20BridgeSource]: FeeEstimate }>; +export type ExchangeProxyOverhead = (sourceFlags: number) => BigNumber; /** * Options for `getMarketSellOrdersAsync()` and `getMarketBuyOrdersAsync()`. @@ -288,6 +279,7 @@ export interface GetMarketOrdersOpts { * Estimated gas consumed by each liquidity source. */ gasSchedule: FeeSchedule; + exchangeProxyOverhead: ExchangeProxyOverhead; /** * Whether to pad the quote with a redundant fallback quote using different * sources. Defaults to `true`. @@ -321,11 +313,8 @@ export interface SourceQuoteOperation export interface OptimizerResult { optimizedOrders: OptimizedMarketOrder[]; - isTwoHop: boolean; + sourceFlags: number; liquidityDelivered: CollapsedFill[] | DexSample; -} - -export interface OptimizerResultWithReport extends OptimizerResult { quoteReport?: QuoteReport; } diff --git a/packages/asset-swapper/src/utils/quote_report_generator.ts b/packages/asset-swapper/src/utils/quote_report_generator.ts index 81fce3c0f0..31b49dcda8 100644 --- a/packages/asset-swapper/src/utils/quote_report_generator.ts +++ b/packages/asset-swapper/src/utils/quote_report_generator.ts @@ -64,7 +64,7 @@ export function generateQuoteReport( multiHopQuotes: Array>, nativeOrders: SignedOrder[], orderFillableAmounts: BigNumber[], - liquidityDelivered: CollapsedFill[] | DexSample, + liquidityDelivered: ReadonlyArray | DexSample, quoteRequestor?: QuoteRequestor, ): QuoteReport { const dexReportSourcesConsidered = dexQuotes.map(quote => _dexSampleToReportSource(quote, marketOperation)); @@ -101,7 +101,9 @@ export function generateQuoteReport( } }); } else { - sourcesDelivered = [_multiHopSampleToReportSource(liquidityDelivered, marketOperation)]; + sourcesDelivered = [ + _multiHopSampleToReportSource(liquidityDelivered as DexSample, marketOperation), + ]; } return { sourcesConsidered, diff --git a/packages/asset-swapper/src/utils/quote_simulation.ts b/packages/asset-swapper/src/utils/quote_simulation.ts index 5e46046e79..863abb1489 100644 --- a/packages/asset-swapper/src/utils/quote_simulation.ts +++ b/packages/asset-swapper/src/utils/quote_simulation.ts @@ -349,7 +349,7 @@ function fromIntermediateQuoteFillResult(ir: IntermediateQuoteFillResult, quoteI }; } -export function getFlattenedFillsFromOrders(orders: OptimizedMarketOrder[]): CollapsedFill[] { +function getFlattenedFillsFromOrders(orders: OptimizedMarketOrder[]): CollapsedFill[] { const fills: CollapsedFill[] = []; for (const o of orders) { fills.push(...o.fills); diff --git a/packages/asset-swapper/src/utils/swap_quote_calculator.ts b/packages/asset-swapper/src/utils/swap_quote_calculator.ts index 2d16f48a3e..9ed44ba2c2 100644 --- a/packages/asset-swapper/src/utils/swap_quote_calculator.ts +++ b/packages/asset-swapper/src/utils/swap_quote_calculator.ts @@ -16,6 +16,7 @@ import { } from '../types'; import { MarketOperationUtils } from './market_operation_utils'; +import { SOURCE_FLAGS } from './market_operation_utils/constants'; import { convertNativeOrderToFullyFillableOptimizedOrders } from './market_operation_utils/orders'; import { ERC20BridgeSource, @@ -130,70 +131,74 @@ export class SwapQuoteCalculator { let optimizedOrders: OptimizedMarketOrder[]; let quoteReport: QuoteReport | undefined; - let isTwoHop = false; + let sourceFlags: number = 0; - { - // Scale fees by gas price. - const _opts: GetMarketOrdersOpts = { - ...opts, - feeSchedule: _.mapValues(opts.feeSchedule, gasCost => (fillData?: FillData) => - gasCost === undefined ? 0 : gasPrice.times(gasCost(fillData)), - ), - }; + // Scale fees by gas price. + const _opts: GetMarketOrdersOpts = { + ...opts, + feeSchedule: _.mapValues(opts.feeSchedule, gasCost => (fillData?: FillData) => + gasCost === undefined ? 0 : gasPrice.times(gasCost(fillData)), + ), + exchangeProxyOverhead: flags => gasPrice.times(opts.exchangeProxyOverhead(flags)), + }; - const firstOrderMakerAssetData = !!prunedOrders[0] - ? assetDataUtils.decodeAssetDataOrThrow(prunedOrders[0].makerAssetData) - : { assetProxyId: '' }; + const firstOrderMakerAssetData = !!prunedOrders[0] + ? assetDataUtils.decodeAssetDataOrThrow(prunedOrders[0].makerAssetData) + : { assetProxyId: '' }; - if (firstOrderMakerAssetData.assetProxyId === AssetProxyId.ERC721) { - // HACK: to conform ERC721 orders to the output of market operation utils, assumes complete fillable - optimizedOrders = prunedOrders.map(o => convertNativeOrderToFullyFillableOptimizedOrders(o)); + if (firstOrderMakerAssetData.assetProxyId === AssetProxyId.ERC721) { + // HACK: to conform ERC721 orders to the output of market operation utils, assumes complete fillable + optimizedOrders = prunedOrders.map(o => convertNativeOrderToFullyFillableOptimizedOrders(o)); + } else { + if (operation === MarketOperation.Buy) { + const buyResult = await this._marketOperationUtils.getMarketBuyOrdersAsync( + prunedOrders, + assetFillAmount, + _opts, + ); + optimizedOrders = buyResult.optimizedOrders; + quoteReport = buyResult.quoteReport; + sourceFlags = buyResult.sourceFlags; } else { - if (operation === MarketOperation.Buy) { - const buyResult = await this._marketOperationUtils.getMarketBuyOrdersAsync( - prunedOrders, - assetFillAmount, - _opts, - ); - optimizedOrders = buyResult.optimizedOrders; - quoteReport = buyResult.quoteReport; - isTwoHop = buyResult.isTwoHop; - } else { - const sellResult = await this._marketOperationUtils.getMarketSellOrdersAsync( - prunedOrders, - assetFillAmount, - _opts, - ); - optimizedOrders = sellResult.optimizedOrders; - quoteReport = sellResult.quoteReport; - isTwoHop = sellResult.isTwoHop; - } + const sellResult = await this._marketOperationUtils.getMarketSellOrdersAsync( + prunedOrders, + assetFillAmount, + _opts, + ); + optimizedOrders = sellResult.optimizedOrders; + quoteReport = sellResult.quoteReport; + sourceFlags = sellResult.sourceFlags; } } // assetData information for the result const { makerAssetData, takerAssetData } = prunedOrders[0]; - return isTwoHop - ? createTwoHopSwapQuote( - makerAssetData, - takerAssetData, - optimizedOrders, - operation, - assetFillAmount, - gasPrice, - opts.gasSchedule, - quoteReport, - ) - : createSwapQuote( - makerAssetData, - takerAssetData, - optimizedOrders, - operation, - assetFillAmount, - gasPrice, - opts.gasSchedule, - quoteReport, - ); + const swapQuote = + sourceFlags === SOURCE_FLAGS[ERC20BridgeSource.MultiHop] + ? createTwoHopSwapQuote( + makerAssetData, + takerAssetData, + optimizedOrders, + operation, + assetFillAmount, + gasPrice, + opts.gasSchedule, + quoteReport, + ) + : createSwapQuote( + makerAssetData, + takerAssetData, + optimizedOrders, + operation, + assetFillAmount, + gasPrice, + opts.gasSchedule, + quoteReport, + ); + const exchangeProxyOverhead = _opts.exchangeProxyOverhead(sourceFlags).toNumber(); + swapQuote.bestCaseQuoteInfo.gas += exchangeProxyOverhead; + swapQuote.worstCaseQuoteInfo.gas += exchangeProxyOverhead; + return swapQuote; } } diff --git a/packages/asset-swapper/test/market_operation_utils_test.ts b/packages/asset-swapper/test/market_operation_utils_test.ts index 77261f3b6b..688c1bcc39 100644 --- a/packages/asset-swapper/test/market_operation_utils_test.ts +++ b/packages/asset-swapper/test/market_operation_utils_test.ts @@ -22,9 +22,10 @@ import { BUY_SOURCE_FILTER, POSITIVE_INF, SELL_SOURCE_FILTER, + SOURCE_FLAGS, ZERO_AMOUNT, } from '../src/utils/market_operation_utils/constants'; -import { createFillPaths } from '../src/utils/market_operation_utils/fills'; +import { createFills } from '../src/utils/market_operation_utils/fills'; import { DexOrderSampler } from '../src/utils/market_operation_utils/sampler'; import { BATCH_SOURCE_FILTERS } from '../src/utils/market_operation_utils/sampler_operations'; import { @@ -264,11 +265,7 @@ describe('MarketOperationUtils tests', () => { 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. + // Sort fills by descending rate. .map(o => { return o.fills .slice() @@ -930,6 +927,49 @@ describe('MarketOperationUtils tests', () => { [ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Curve], ]); }); + it('factors in exchange proxy gas overhead', async () => { + // Uniswap has a slightly better rate than LiquidityProvider, + // but LiquidityProvider is better accounting for the EP gas overhead. + const rates: RatesBySource = { + [ERC20BridgeSource.Native]: [0.01, 0.01, 0.01, 0.01], + [ERC20BridgeSource.Uniswap]: [1, 1, 1, 1], + [ERC20BridgeSource.LiquidityProvider]: [0.9999, 0.9999, 0.9999, 0.9999], + }; + replaceSamplerOps({ + getSellQuotes: createGetMultipleSellQuotesOperationFromRates(rates), + getMedianSellRate: createGetMedianSellRate(ETH_TO_MAKER_RATE), + }); + const optimizer = new MarketOperationUtils( + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + randomAddress(), // liquidity provider registry + ); + const gasPrice = 100e9; // 100 gwei + const exchangeProxyOverhead = (sourceFlags: number) => + sourceFlags === SOURCE_FLAGS.LiquidityProvider + ? new BigNumber(3e4).times(gasPrice) + : new BigNumber(1.3e5).times(gasPrice); + const improvedOrdersResponse = await optimizer.getMarketSellOrdersAsync( + createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), + FILL_AMOUNT, + { + ...DEFAULT_OPTS, + numSamples: 4, + excludedSources: [ + ...DEFAULT_OPTS.excludedSources, + ERC20BridgeSource.Eth2Dai, + ERC20BridgeSource.Kyber, + ERC20BridgeSource.Bancor, + ], + exchangeProxyOverhead, + }, + ); + const improvedOrders = improvedOrdersResponse.optimizedOrders; + const orderSources = improvedOrders.map(o => o.fills[0].source); + const expectedSources = [ERC20BridgeSource.LiquidityProvider]; + expect(orderSources).to.deep.eq(expectedSources); + }); }); describe('getMarketBuyOrdersAsync()', () => { @@ -1299,7 +1339,7 @@ describe('MarketOperationUtils tests', () => { it('batches contiguous bridge sources', async () => { const rates: RatesBySource = { ...ZERO_RATES }; - rates[ERC20BridgeSource.Native] = [0.5, 0.01, 0.01, 0.01]; + rates[ERC20BridgeSource.Native] = [0.3, 0.01, 0.01, 0.01]; rates[ERC20BridgeSource.Eth2Dai] = [0.49, 0.02, 0.01, 0.01]; rates[ERC20BridgeSource.Uniswap] = [0.48, 0.01, 0.01, 0.01]; replaceSamplerOps({ @@ -1318,14 +1358,56 @@ describe('MarketOperationUtils tests', () => { expect(improvedOrders).to.be.length(2); const orderFillSources = getSortedOrderSources(MarketOperation.Sell, improvedOrders); expect(orderFillSources).to.deep.eq([ - [ERC20BridgeSource.Native], [ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap], + [ERC20BridgeSource.Native], ]); }); + it('factors in exchange proxy gas overhead', async () => { + // Uniswap has a slightly better rate than LiquidityProvider, + // but LiquidityProvider is better accounting for the EP gas overhead. + const rates: RatesBySource = { + [ERC20BridgeSource.Native]: [0.01, 0.01, 0.01, 0.01], + [ERC20BridgeSource.Uniswap]: [1, 1, 1, 1], + [ERC20BridgeSource.LiquidityProvider]: [0.9999, 0.9999, 0.9999, 0.9999], + }; + replaceSamplerOps({ + getBuyQuotes: createGetMultipleBuyQuotesOperationFromRates(rates), + getMedianSellRate: createGetMedianSellRate(ETH_TO_TAKER_RATE), + }); + const optimizer = new MarketOperationUtils( + MOCK_SAMPLER, + contractAddresses, + ORDER_DOMAIN, + randomAddress(), // liquidity provider registry + ); + const gasPrice = 100e9; // 100 gwei + const exchangeProxyOverhead = (sourceFlags: number) => + sourceFlags === SOURCE_FLAGS.LiquidityProvider + ? new BigNumber(3e4).times(gasPrice) + : new BigNumber(1.3e5).times(gasPrice); + const improvedOrdersResponse = await optimizer.getMarketBuyOrdersAsync( + createOrdersFromSellRates(FILL_AMOUNT, rates[ERC20BridgeSource.Native]), + FILL_AMOUNT, + { + ...DEFAULT_OPTS, + numSamples: 4, + excludedSources: [ + ...DEFAULT_OPTS.excludedSources, + ERC20BridgeSource.Eth2Dai, + ERC20BridgeSource.Kyber, + ], + exchangeProxyOverhead, + }, + ); + const improvedOrders = improvedOrdersResponse.optimizedOrders; + const orderSources = improvedOrders.map(o => o.fills[0].source); + const expectedSources = [ERC20BridgeSource.LiquidityProvider]; + expect(orderSources).to.deep.eq(expectedSources); + }); }); }); - describe('createFillPaths', () => { + describe('createFills', () => { const takerAssetAmount = new BigNumber(5000000); const ethToOutputRate = new BigNumber(0.5); // tslint:disable-next-line:no-object-literal-type-assertion @@ -1359,7 +1441,7 @@ describe('MarketOperationUtils tests', () => { }; it('penalizes native fill based on target amount when target is smaller', () => { - const path = createFillPaths({ + const path = createFills({ side: MarketOperation.Sell, orders, dexQuotes: [], @@ -1372,7 +1454,7 @@ describe('MarketOperationUtils tests', () => { }); it('penalizes native fill based on available amount when target is larger', () => { - const path = createFillPaths({ + const path = createFills({ side: MarketOperation.Sell, orders, dexQuotes: [], diff --git a/packages/contract-artifacts/CHANGELOG.json b/packages/contract-artifacts/CHANGELOG.json index 096af3b0fe..7702754f78 100644 --- a/packages/contract-artifacts/CHANGELOG.json +++ b/packages/contract-artifacts/CHANGELOG.json @@ -17,6 +17,10 @@ { "note": "Regenerate artifacts", "pr": 2703 + }, + { + "note": "Update IZeroEx artifact for LiquidityProviderFeature", + "pr": 2691 } ] }, diff --git a/packages/contract-artifacts/artifacts/IZeroEx.json b/packages/contract-artifacts/artifacts/IZeroEx.json index 4255ce4384..96f2b33437 100644 --- a/packages/contract-artifacts/artifacts/IZeroEx.json +++ b/packages/contract-artifacts/artifacts/IZeroEx.json @@ -3,6 +3,16 @@ "contractName": "IZeroEx", "compilerOutput": { "abi": [ + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "xAsset", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "yAsset", "type": "address" }, + { "indexed": false, "internalType": "address", "name": "providerAddress", "type": "address" } + ], + "name": "LiquidityProviderForMarketUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -222,6 +232,16 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { "internalType": "address", "name": "xAsset", "type": "address" }, + { "internalType": "address", "name": "yAsset", "type": "address" } + ], + "name": "getLiquidityProviderForMarket", + "outputs": [{ "internalType": "address", "name": "providerAddress", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -366,6 +386,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { "internalType": "address", "name": "makerToken", "type": "address" }, + { "internalType": "address", "name": "takerToken", "type": "address" }, + { "internalType": "address payable", "name": "recipient", "type": "address" }, + { "internalType": "uint256", "name": "sellAmount", "type": "uint256" }, + { "internalType": "uint256", "name": "minBuyAmount", "type": "uint256" } + ], + "name": "sellToLiquidityProvider", + "outputs": [{ "internalType": "uint256", "name": "boughtAmount", "type": "uint256" }], + "stateMutability": "payable", + "type": "function" + }, { "inputs": [ { "internalType": "contract IERC20TokenV06[]", "name": "tokens", "type": "address[]" }, @@ -378,6 +411,17 @@ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { "internalType": "address", "name": "xAsset", "type": "address" }, + { "internalType": "address", "name": "yAsset", "type": "address" }, + { "internalType": "address", "name": "providerAddress", "type": "address" } + ], + "name": "setLiquidityProviderForMarket", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [{ "internalType": "address", "name": "quoteSigner", "type": "address" }], "name": "setQuoteSigner", @@ -492,6 +536,14 @@ "params": { "selector": "The function selector." }, "returns": { "impl": "The implementation contract address." } }, + "getLiquidityProviderForMarket(address,address)": { + "details": "Returns the address of the liquidity provider for a market given (xAsset, yAsset), or reverts if pool does not exist.", + "params": { + "xAsset": "First asset managed by the liquidity provider.", + "yAsset": "Second asset managed by the liquidity provider." + }, + "returns": { "providerAddress": "Address of the liquidity provider." } + }, "getMetaTransactionExecutedBlock((address,address,uint256,uint256,uint256,uint256,bytes,uint256,address,uint256))": { "details": "Get the block at which a meta-transaction has been executed.", "params": { "mtx": "The meta-transaction." }, @@ -574,6 +626,14 @@ }, "returns": { "buyAmount": "Amount of `tokens[-1]` bought." } }, + "setLiquidityProviderForMarket(address,address,address)": { + "details": "Sets address of the liquidity provider for a market given (xAsset, yAsset).", + "params": { + "providerAddress": "Address of the liquidity provider.", + "xAsset": "First asset managed by the liquidity provider.", + "yAsset": "Second asset managed by the liquidity provider." + } + }, "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 80deb3687a..dffa364b75 100644 --- a/packages/contract-wrappers/CHANGELOG.json +++ b/packages/contract-wrappers/CHANGELOG.json @@ -17,6 +17,10 @@ { "note": "Regenerate wrappers", "pr": 2703 + }, + { + "note": "Update IZeroEx wrapper for LiquidityProviderFeature", + "pr": 2691 } ] }, 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 faf40e2b47..e0da11d70b 100644 --- a/packages/contract-wrappers/src/generated-wrappers/i_zero_ex.ts +++ b/packages/contract-wrappers/src/generated-wrappers/i_zero_ex.ts @@ -36,6 +36,7 @@ import * as ethers from 'ethers'; // tslint:enable:no-unused-variable export type IZeroExEventArgs = + | IZeroExLiquidityProviderForMarketUpdatedEventArgs | IZeroExMetaTransactionExecutedEventArgs | IZeroExMigratedEventArgs | IZeroExOwnershipTransferredEventArgs @@ -45,6 +46,7 @@ export type IZeroExEventArgs = | IZeroExTransformerDeployerUpdatedEventArgs; export enum IZeroExEvents { + LiquidityProviderForMarketUpdated = 'LiquidityProviderForMarketUpdated', MetaTransactionExecuted = 'MetaTransactionExecuted', Migrated = 'Migrated', OwnershipTransferred = 'OwnershipTransferred', @@ -54,6 +56,12 @@ export enum IZeroExEvents { TransformerDeployerUpdated = 'TransformerDeployerUpdated', } +export interface IZeroExLiquidityProviderForMarketUpdatedEventArgs extends DecodedLogArgs { + xAsset: string; + yAsset: string; + providerAddress: string; +} + export interface IZeroExMetaTransactionExecutedEventArgs extends DecodedLogArgs { hash: string; selector: string; @@ -211,6 +219,29 @@ export class IZeroExContract extends BaseContract { */ public static ABI(): ContractAbi { const abi = [ + { + anonymous: false, + inputs: [ + { + name: 'xAsset', + type: 'address', + indexed: true, + }, + { + name: 'yAsset', + type: 'address', + indexed: true, + }, + { + name: 'providerAddress', + type: 'address', + indexed: false, + }, + ], + name: 'LiquidityProviderForMarketUpdated', + outputs: [], + type: 'event', + }, { anonymous: false, inputs: [ @@ -697,6 +728,27 @@ export class IZeroExContract extends BaseContract { stateMutability: 'view', type: 'function', }, + { + inputs: [ + { + name: 'xAsset', + type: 'address', + }, + { + name: 'yAsset', + type: 'address', + }, + ], + name: 'getLiquidityProviderForMarket', + outputs: [ + { + name: 'providerAddress', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, { inputs: [ { @@ -1000,6 +1052,39 @@ export class IZeroExContract extends BaseContract { stateMutability: 'nonpayable', type: 'function', }, + { + inputs: [ + { + name: 'makerToken', + type: 'address', + }, + { + name: 'takerToken', + type: 'address', + }, + { + name: 'recipient', + type: 'address', + }, + { + name: 'sellAmount', + type: 'uint256', + }, + { + name: 'minBuyAmount', + type: 'uint256', + }, + ], + name: 'sellToLiquidityProvider', + outputs: [ + { + name: 'boughtAmount', + type: 'uint256', + }, + ], + stateMutability: 'payable', + type: 'function', + }, { inputs: [ { @@ -1029,6 +1114,26 @@ export class IZeroExContract extends BaseContract { stateMutability: 'payable', type: 'function', }, + { + inputs: [ + { + name: 'xAsset', + type: 'address', + }, + { + name: 'yAsset', + type: 'address', + }, + { + name: 'providerAddress', + type: 'address', + }, + ], + name: 'setLiquidityProviderForMarket', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, { inputs: [ { @@ -1743,6 +1848,60 @@ export class IZeroExContract extends BaseContract { }, }; } + /** + * Returns the address of the liquidity provider for a market given + * (xAsset, yAsset), or reverts if pool does not exist. + * @param xAsset First asset managed by the liquidity provider. + * @param yAsset Second asset managed by the liquidity provider. + */ + public getLiquidityProviderForMarket(xAsset: string, yAsset: string): ContractTxFunctionObj { + const self = (this as any) as IZeroExContract; + assert.isString('xAsset', xAsset); + assert.isString('yAsset', yAsset); + const functionSignature = 'getLiquidityProviderForMarket(address,address)'; + + 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, [xAsset.toLowerCase(), yAsset.toLowerCase()]); + }, + }; + } /** * Get the block at which a meta-transaction has been executed. * @param mtx The meta-transaction. @@ -2447,6 +2606,69 @@ export class IZeroExContract extends BaseContract { }, }; } + public sellToLiquidityProvider( + makerToken: string, + takerToken: string, + recipient: string, + sellAmount: BigNumber, + minBuyAmount: BigNumber, + ): ContractTxFunctionObj { + const self = (this as any) as IZeroExContract; + assert.isString('makerToken', makerToken); + assert.isString('takerToken', takerToken); + assert.isString('recipient', recipient); + assert.isBigNumber('sellAmount', sellAmount); + assert.isBigNumber('minBuyAmount', minBuyAmount); + const functionSignature = 'sellToLiquidityProvider(address,address,address,uint256,uint256)'; + + 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, [ + makerToken.toLowerCase(), + takerToken.toLowerCase(), + recipient.toLowerCase(), + sellAmount, + minBuyAmount, + ]); + }, + }; + } /** * Efficiently sell directly to uniswap/sushiswap. * @param tokens Sell path. @@ -2509,6 +2731,70 @@ export class IZeroExContract extends BaseContract { }, }; } + /** + * Sets address of the liquidity provider for a market given + * (xAsset, yAsset). + * @param xAsset First asset managed by the liquidity provider. + * @param yAsset Second asset managed by the liquidity provider. + * @param providerAddress Address of the liquidity provider. + */ + public setLiquidityProviderForMarket( + xAsset: string, + yAsset: string, + providerAddress: string, + ): ContractTxFunctionObj { + const self = (this as any) as IZeroExContract; + assert.isString('xAsset', xAsset); + assert.isString('yAsset', yAsset); + assert.isString('providerAddress', providerAddress); + const functionSignature = 'setLiquidityProviderForMarket(address,address,address)'; + + 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, [ + xAsset.toLowerCase(), + yAsset.toLowerCase(), + providerAddress.toLowerCase(), + ]); + }, + }; + } /** * Replace the optional signer for `transformERC20()` calldata. * Only callable by the owner. diff --git a/packages/contract-wrappers/src/index.ts b/packages/contract-wrappers/src/index.ts index f4025e4d01..1f19988892 100644 --- a/packages/contract-wrappers/src/index.ts +++ b/packages/contract-wrappers/src/index.ts @@ -125,6 +125,7 @@ export { IZeroExContract, IZeroExEventArgs, IZeroExEvents, + IZeroExLiquidityProviderForMarketUpdatedEventArgs, IZeroExMetaTransactionExecutedEventArgs, IZeroExMigratedEventArgs, IZeroExOwnershipTransferredEventArgs, diff --git a/packages/utils/CHANGELOG.json b/packages/utils/CHANGELOG.json index 2ccbfca7dc..eda013cdf4 100644 --- a/packages/utils/CHANGELOG.json +++ b/packages/utils/CHANGELOG.json @@ -9,6 +9,10 @@ { "note": "Add EP flavor of `IllegalReentrancyError`.", "pr": 2657 + }, + { + "note": "Added LiquidityProviderFeature errors", + "pr": 2691 } ] }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 52c2565aa3..74c518ddc4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -54,4 +54,5 @@ export const ZeroExRevertErrors = { Wallet: require('./revert_errors/zero-ex/wallet_revert_errors'), MetaTransactions: require('./revert_errors/zero-ex/meta_transaction_revert_errors'), SignatureValidator: require('./revert_errors/zero-ex/signature_validator_revert_errors'), + LiquidityProvider: require('./revert_errors/zero-ex/liquidity_provider_revert_errors'), }; diff --git a/packages/utils/src/revert_errors/zero-ex/liquidity_provider_revert_errors.ts b/packages/utils/src/revert_errors/zero-ex/liquidity_provider_revert_errors.ts new file mode 100644 index 0000000000..5adafe0127 --- /dev/null +++ b/packages/utils/src/revert_errors/zero-ex/liquidity_provider_revert_errors.ts @@ -0,0 +1,47 @@ +import { RevertError } from '../../revert_error'; +import { Numberish } from '../../types'; + +// tslint:disable:max-classes-per-file +export class LiquidityProviderIncompleteSellError extends RevertError { + constructor( + providerAddress?: string, + makerToken?: string, + takerToken?: string, + sellAmount?: Numberish, + boughtAmount?: Numberish, + minBuyAmount?: Numberish, + ) { + super( + 'LiquidityProviderIncompleteSellError', + 'LiquidityProviderIncompleteSellError(address providerAddress, address makerToken, address takerToken, uint256 sellAmount, uint256 boughtAmount, uint256 minBuyAmount)', + { + providerAddress, + makerToken, + takerToken, + sellAmount, + boughtAmount, + minBuyAmount, + }, + ); + } +} + +export class NoLiquidityProviderForMarketError extends RevertError { + constructor(xAsset?: string, yAsset?: string) { + super( + 'NoLiquidityProviderForMarketError', + 'NoLiquidityProviderForMarketError(address xAsset, address yAsset)', + { + xAsset, + yAsset, + }, + ); + } +} + +const types = [LiquidityProviderIncompleteSellError, NoLiquidityProviderForMarketError]; + +// Register the types we've defined. +for (const type of types) { + RevertError.registerType(type); +}