diff --git a/package.json b/package.json index 836757e0..90eb4343 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@cowprotocol/contracts": "github:cowprotocol/contracts.git#a10f40788a", "@openzeppelin/contracts": "5.0.2", "composable-cow": "github:cowprotocol/composable-cow.git#24d556b", + "cow-amm": "github:cowprotocol/cow-amm.git#6566128", "solmate": "github:transmissions11/solmate#c892309" }, "devDependencies": { diff --git a/remappings.txt b/remappings.txt index 5b011a43..08fc002a 100644 --- a/remappings.txt +++ b/remappings.txt @@ -5,6 +5,9 @@ solmate/=node_modules/solmate/src @cowprotocol/=node_modules/@cowprotocol/contracts/src/contracts cowprotocol/=node_modules/@cowprotocol/contracts/src/ @composable-cow/=node_modules/composable-cow/ +@cow-amm/=node_modules/cow-amm/src +lib/openzeppelin/=node_modules/@openzeppelin contracts/=src/contracts interfaces/=src/interfaces +libraries/=src/libraries diff --git a/src/contracts/BCoWHelper.sol b/src/contracts/BCoWHelper.sol new file mode 100644 index 00000000..4562bcda --- /dev/null +++ b/src/contracts/BCoWHelper.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.25; + +import {IBCoWFactory} from 'interfaces/IBCoWFactory.sol'; +import {IBCoWPool} from 'interfaces/IBCoWPool.sol'; + +import {ICOWAMMPoolHelper} from '@cow-amm/interfaces/ICOWAMMPoolHelper.sol'; +import {GetTradeableOrder} from '@cow-amm/libraries/GetTradeableOrder.sol'; + +import {IERC20} from '@cowprotocol/interfaces/IERC20.sol'; +import {GPv2Interaction} from '@cowprotocol/libraries/GPv2Interaction.sol'; +import {GPv2Order} from '@cowprotocol/libraries/GPv2Order.sol'; + +import {BMath} from 'contracts/BMath.sol'; + +/** + * @title BCoWHelper + * @notice Helper contract that allows to trade on CoW Swap Protocol. + * @dev This contract supports only 2-token equal-weights pools. + */ +contract BCoWHelper is ICOWAMMPoolHelper, BMath { + using GPv2Order for GPv2Order.Data; + + /// @notice The app data used by this helper's factory. + bytes32 internal immutable _APP_DATA; + + /// @inheritdoc ICOWAMMPoolHelper + // solhint-disable-next-line style-guide-casing + address public immutable factory; + + constructor(address factory_) { + factory = factory_; + _APP_DATA = IBCoWFactory(factory_).APP_DATA(); + } + + /// @inheritdoc ICOWAMMPoolHelper + function order( + address pool, + uint256[] calldata prices + ) + external + view + returns ( + GPv2Order.Data memory order_, + GPv2Interaction.Data[] memory preInteractions, + GPv2Interaction.Data[] memory postInteractions, + bytes memory sig + ) + { + address[] memory tokens_ = tokens(pool); + + GetTradeableOrder.GetTradeableOrderParams memory params = GetTradeableOrder.GetTradeableOrderParams({ + pool: pool, + token0: IERC20(tokens_[0]), + token1: IERC20(tokens_[1]), + // The price of this function is expressed as amount of + // token1 per amount of token0. The `prices` vector is + // expressed the other way around. + priceNumerator: prices[1], + priceDenominator: prices[0], + appData: _APP_DATA + }); + + order_ = GetTradeableOrder.getTradeableOrder(params); + + { + // NOTE: Using calcOutGivenIn for the sell amount in order to avoid possible rounding + // issues that may cause invalid orders. This prevents CoW Protocol back-end from generating + // orders that may be ignored due to rounding-induced reverts. + + uint256 balanceToken0 = IERC20(tokens_[0]).balanceOf(pool); + uint256 balanceToken1 = IERC20(tokens_[1]).balanceOf(pool); + (uint256 balanceIn, uint256 balanceOut) = + address(order_.buyToken) == tokens_[0] ? (balanceToken0, balanceToken1) : (balanceToken1, balanceToken0); + + order_.sellAmount = calcOutGivenIn({ + tokenBalanceIn: balanceIn, + tokenWeightIn: 1e18, + tokenBalanceOut: balanceOut, + tokenWeightOut: 1e18, + tokenAmountIn: order_.buyAmount, + swapFee: 0 + }); + } + + // A ERC-1271 signature on CoW Protocol is composed of two parts: the + // signer address and the valid ERC-1271 signature data for that signer. + bytes memory eip1271sig; + eip1271sig = abi.encode(order_); + sig = abi.encodePacked(pool, eip1271sig); + + // Generate the order commitment pre-interaction + bytes32 domainSeparator = IBCoWPool(pool).SOLUTION_SETTLER_DOMAIN_SEPARATOR(); + bytes32 orderCommitment = order_.hash(domainSeparator); + + preInteractions = new GPv2Interaction.Data[](1); + preInteractions[0] = GPv2Interaction.Data({ + target: pool, + value: 0, + callData: abi.encodeWithSelector(IBCoWPool.commit.selector, orderCommitment) + }); + + return (order_, preInteractions, postInteractions, sig); + } + + /// @inheritdoc ICOWAMMPoolHelper + function tokens(address pool) public view virtual returns (address[] memory tokens_) { + // reverts in case pool is not deployed by the helper's factory + if (!IBCoWFactory(factory).isBPool(pool)) { + revert PoolDoesNotExist(); + } + + // call reverts with `BPool_PoolNotFinalized()` in case pool is not finalized + tokens_ = IBCoWPool(pool).getFinalTokens(); + + // reverts in case pool is not supported (non-2-token pool) + if (tokens_.length != 2) { + revert PoolDoesNotExist(); + } + // reverts in case pool is not supported (non-equal weights) + if (IBCoWPool(pool).getNormalizedWeight(tokens_[0]) != IBCoWPool(pool).getNormalizedWeight(tokens_[1])) { + revert PoolDoesNotExist(); + } + } +} diff --git a/test/integration/BCoWHelper.t.sol b/test/integration/BCoWHelper.t.sol new file mode 100644 index 00000000..7c1fc536 --- /dev/null +++ b/test/integration/BCoWHelper.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.24; + +import {Test} from 'forge-std/Test.sol'; + +import {IERC20} from '@cowprotocol/interfaces/IERC20.sol'; + +import {IBCoWPool} from 'interfaces/IBCoWPool.sol'; +import {IBPool} from 'interfaces/IBPool.sol'; +import {ISettlement} from 'interfaces/ISettlement.sol'; + +import {ICOWAMMPoolHelper} from '@cow-amm/interfaces/ICOWAMMPoolHelper.sol'; +import {GPv2Interaction} from '@cowprotocol/libraries/GPv2Interaction.sol'; +import {GPv2Order} from '@cowprotocol/libraries/GPv2Order.sol'; +import {GPv2Trade} from '@cowprotocol/libraries/GPv2Trade.sol'; +import {GPv2Signing} from '@cowprotocol/mixins/GPv2Signing.sol'; + +import {GPv2TradeEncoder} from '@composable-cow/test/vendored/GPv2TradeEncoder.sol'; + +import {BCoWFactory} from 'contracts/BCoWFactory.sol'; +import {BCoWHelper} from 'contracts/BCoWHelper.sol'; + +contract BCoWHelperIntegrationTest is Test { + using GPv2Order for GPv2Order.Data; + + BCoWHelper private helper; + + // All hardcoded addresses are mainnet addresses + address public lp = makeAddr('lp'); + + ISettlement private settlement = ISettlement(0x9008D19f58AAbD9eD0D60971565AA8510560ab41); + address private vaultRelayer; + + address private solver = 0x423cEc87f19F0778f549846e0801ee267a917935; + + BCoWFactory private ammFactory; + IBPool private weightedPool; + IBPool private basicPool; + + IERC20 private constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); + IERC20 private constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + + uint256 constant TEN_PERCENT = 0.1 ether; + // NOTE: 1 ETH = 1000 DAI + uint256 constant INITIAL_DAI_BALANCE = 1000 ether; + uint256 constant INITIAL_WETH_BALANCE = 1 ether; + uint256 constant INITIAL_SPOT_PRICE = 0.001e18; + + uint256 constant SKEWENESS_RATIO = 95; // -5% skewness + uint256 constant EXPECTED_FINAL_SPOT_PRICE = INITIAL_SPOT_PRICE * 100 / SKEWENESS_RATIO; + + function setUp() public { + vm.createSelectFork('mainnet', 20_012_063); + + vaultRelayer = address(settlement.vaultRelayer()); + + ammFactory = new BCoWFactory(address(settlement), bytes32('appData')); + helper = new BCoWHelper(address(ammFactory)); + + deal(address(DAI), lp, type(uint256).max, false); + deal(address(WETH), lp, type(uint256).max, false); + + vm.startPrank(lp); + basicPool = ammFactory.newBPool(); + weightedPool = ammFactory.newBPool(); + + DAI.approve(address(basicPool), type(uint256).max); + WETH.approve(address(basicPool), type(uint256).max); + basicPool.bind(address(DAI), INITIAL_DAI_BALANCE, 4.2e18); // no weight + basicPool.bind(address(WETH), INITIAL_WETH_BALANCE, 4.2e18); // no weight + + DAI.approve(address(weightedPool), type(uint256).max); + WETH.approve(address(weightedPool), type(uint256).max); + // NOTE: pool is 80-20 DAI-WETH, has 4xDAI balance than basic, same spot price + weightedPool.bind(address(DAI), 4 * INITIAL_DAI_BALANCE, 8e18); // 80% weight + weightedPool.bind(address(WETH), INITIAL_WETH_BALANCE, 2e18); // 20% weight + + // finalize + basicPool.finalize(); + weightedPool.finalize(); + + vm.stopPrank(); + } + + function testBasicOrder() public { + IBCoWPool pool = IBCoWPool(address(basicPool)); + + uint256 spotPrice = pool.getSpotPriceSansFee(address(WETH), address(DAI)); + assertEq(spotPrice, INITIAL_SPOT_PRICE); + + _executeHelperOrder(pool); + + uint256 postSpotPrice = pool.getSpotPriceSansFee(address(WETH), address(DAI)); + assertEq(postSpotPrice, EXPECTED_FINAL_SPOT_PRICE); + } + + // NOTE: reverting test, weighted pools are not supported + function testWeightedOrder() public { + IBCoWPool pool = IBCoWPool(address(weightedPool)); + + uint256 spotPrice = pool.getSpotPriceSansFee(address(WETH), address(DAI)); + assertEq(spotPrice, INITIAL_SPOT_PRICE); + + vm.expectRevert(ICOWAMMPoolHelper.PoolDoesNotExist.selector); + helper.order(address(pool), new uint256[](2)); + } + + function _executeHelperOrder(IBPool pool) internal { + address[] memory tokens = helper.tokens(address(pool)); + uint256 daiIndex = 0; + uint256 wethIndex = 1; + assertEq(tokens.length, 2); + assertEq(tokens[daiIndex], address(DAI)); + assertEq(tokens[wethIndex], address(WETH)); + + // Prepare the price vector used in the execution of the settlement in + // CoW Protocol. We skew the price by ~5% towards a cheaper WETH, so + // that the AMM wants to buy WETH. + uint256[] memory prices = new uint256[](2); + // Note: oracle price are expressed in the same format as prices in + // a call to `settle`, where the price vector is expressed so that + // if the first token is DAI and the second WETH then a price of 3000 + // DAI per WETH means a price vector of [1, 3000] (if the decimals are + // different, as in WETH/USDC, then the atom amount is what counts). + prices[daiIndex] = INITIAL_WETH_BALANCE; + prices[wethIndex] = INITIAL_DAI_BALANCE * SKEWENESS_RATIO / 100; + + // The helper generates the AMM order + GPv2Order.Data memory ammOrder; + GPv2Interaction.Data[] memory preInteractions; + GPv2Interaction.Data[] memory postInteractions; + bytes memory sig; + (ammOrder, preInteractions, postInteractions, sig) = helper.order(address(pool), prices); + + // We expect a commit interaction in pre interactions + assertEq(preInteractions.length, 1); + assertEq(postInteractions.length, 0); + + // Because of how we changed the price, we expect to buy WETH + assertEq(address(ammOrder.sellToken), address(DAI)); + assertEq(address(ammOrder.buyToken), address(WETH)); + + // Check that the amounts and price aren't unreasonable. We changed the + // price by about 5%, so the amounts aren't expected to change + // significantly more (say, about 2.5% of the original balance). + assertApproxEqRel(ammOrder.sellAmount, INITIAL_DAI_BALANCE * 25 / 1000, TEN_PERCENT); + assertApproxEqRel(ammOrder.buyAmount, INITIAL_WETH_BALANCE * 25 / 1000, TEN_PERCENT); + + GPv2Trade.Data[] memory trades = new GPv2Trade.Data[](1); + + // pool's trade + trades[0] = GPv2Trade.Data({ + sellTokenIndex: 0, + buyTokenIndex: 1, + receiver: ammOrder.receiver, + sellAmount: ammOrder.sellAmount, + buyAmount: ammOrder.buyAmount, + validTo: ammOrder.validTo, + appData: ammOrder.appData, + feeAmount: ammOrder.feeAmount, + flags: GPv2TradeEncoder.encodeFlags(ammOrder, GPv2Signing.Scheme.Eip1271), + executedAmount: ammOrder.sellAmount, + signature: sig + }); + + GPv2Interaction.Data[][3] memory interactions = + [new GPv2Interaction.Data[](1), new GPv2Interaction.Data[](0), new GPv2Interaction.Data[](0)]; + + interactions[0][0] = preInteractions[0]; + + // cast tokens array to IERC20 array + IERC20[] memory ierc20vec; + assembly { + ierc20vec := tokens + } + + // finally, settle + vm.prank(solver); + settlement.settle(ierc20vec, prices, trades, interactions); + } +} diff --git a/test/integration/BCowPool.t.sol b/test/integration/BCowPool.t.sol index 83a16922..da711035 100644 --- a/test/integration/BCowPool.t.sol +++ b/test/integration/BCowPool.t.sol @@ -8,6 +8,7 @@ import {GPv2Interaction} from '@cowprotocol/libraries/GPv2Interaction.sol'; import {GPv2Order} from '@cowprotocol/libraries/GPv2Order.sol'; import {GPv2Trade} from '@cowprotocol/libraries/GPv2Trade.sol'; import {GPv2Signing} from '@cowprotocol/mixins/GPv2Signing.sol'; + import {BCoWConst} from 'contracts/BCoWConst.sol'; import {BCoWFactory} from 'contracts/BCoWFactory.sol'; diff --git a/test/manual-smock/MockBCoWFactory.sol b/test/manual-smock/MockBCoWFactory.sol index 5e7baa5a..c4b2e427 100644 --- a/test/manual-smock/MockBCoWFactory.sol +++ b/test/manual-smock/MockBCoWFactory.sol @@ -5,6 +5,16 @@ import {BCoWFactory, BCoWPool, BFactory, IBCoWFactory, IBPool} from '../../src/c import {Test} from 'forge-std/Test.sol'; contract MockBCoWFactory is BCoWFactory, Test { + // NOTE: manually added methods (immutable overrides not supported in smock) + function mock_call_APP_DATA(bytes32 _appData) public { + vm.mockCall(address(this), abi.encodeWithSignature('APP_DATA()'), abi.encode(_appData)); + } + + function expectCall_APP_DATA() public { + vm.expectCall(address(this), abi.encodeWithSignature('APP_DATA()')); + } + + // BCoWFactory methods constructor(address solutionSettler, bytes32 appData) BCoWFactory(solutionSettler, appData) {} function mock_call_logBCoWPool() public { @@ -31,8 +41,39 @@ contract MockBCoWFactory is BCoWFactory, Test { } // MockBFactory methods - function set__isBPool(address _key0, bool _value) public { _isBPool[_key0] = _value; } + + function call__isBPool(address _key0) public view returns (bool) { + return _isBPool[_key0]; + } + + function set__bDao(address __bDao) public { + _bDao = __bDao; + } + + function call__bDao() public view returns (address) { + return _bDao; + } + + function mock_call_newBPool(IBPool bPool) public { + vm.mockCall(address(this), abi.encodeWithSignature('newBPool()'), abi.encode(bPool)); + } + + function mock_call_setBDao(address bDao) public { + vm.mockCall(address(this), abi.encodeWithSignature('setBDao(address)', bDao), abi.encode()); + } + + function mock_call_collect(IBPool bPool) public { + vm.mockCall(address(this), abi.encodeWithSignature('collect(IBPool)', bPool), abi.encode()); + } + + function mock_call_isBPool(address bPool, bool _returnParam0) public { + vm.mockCall(address(this), abi.encodeWithSignature('isBPool(address)', bPool), abi.encode(_returnParam0)); + } + + function mock_call_getBDao(address _returnParam0) public { + vm.mockCall(address(this), abi.encodeWithSignature('getBDao()'), abi.encode(_returnParam0)); + } } diff --git a/test/manual-smock/MockBCoWHelper.sol b/test/manual-smock/MockBCoWHelper.sol new file mode 100644 index 00000000..003b4ce2 --- /dev/null +++ b/test/manual-smock/MockBCoWHelper.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import { + BCoWHelper, + BMath, + GPv2Interaction, + GPv2Order, + GetTradeableOrder, + IBCoWFactory, + IBCoWPool, + ICOWAMMPoolHelper, + IERC20 +} from '../../src/contracts/BCoWHelper.sol'; +import {Test} from 'forge-std/Test.sol'; + +contract MockBCoWHelper is BCoWHelper, Test { + // NOTE: manually added methods (internal immutable exposers not supported in smock) + function call__APP_DATA() external view returns (bytes32) { + return _APP_DATA; + } + + // NOTE: manually added method (public overrides not supported in smock) + function tokens(address pool) public view override returns (address[] memory tokens_) { + (bool _success, bytes memory _data) = address(this).staticcall(abi.encodeWithSignature('tokens(address)', pool)); + + if (_success) return abi.decode(_data, (address[])); + else return super.tokens(pool); + } + + // NOTE: manually added method (public overrides not supported in smock) + function expectCall_tokens(address pool) public { + vm.expectCall(address(this), abi.encodeWithSignature('tokens(address)', pool)); + } + + // BCoWHelper methods + constructor(address factory_) BCoWHelper(factory_) {} + + function mock_call_order( + address pool, + uint256[] calldata prices, + GPv2Order.Data memory order_, + GPv2Interaction.Data[] memory preInteractions, + GPv2Interaction.Data[] memory postInteractions, + bytes memory sig + ) public { + vm.mockCall( + address(this), + abi.encodeWithSignature('order(address,uint256[])', pool, prices), + abi.encode(order_, preInteractions, postInteractions, sig) + ); + } + + function mock_call_tokens(address pool, address[] memory tokens_) public { + vm.mockCall(address(this), abi.encodeWithSignature('tokens(address)', pool), abi.encode(tokens_)); + } +} diff --git a/test/manual-smock/MockBCoWPool.sol b/test/manual-smock/MockBCoWPool.sol index 5ce2c703..a0f1176c 100644 --- a/test/manual-smock/MockBCoWPool.sol +++ b/test/manual-smock/MockBCoWPool.sol @@ -21,8 +21,18 @@ contract MockBCoWPool is BCoWPool, Test { vm.expectCall(address(this), abi.encodeWithSignature('verify(GPv2Order.Data)', order)); } - /// MockBCoWPool mock methods + // NOTE: manually added methods (immutable overrides not supported in smock) + function mock_call_SOLUTION_SETTLER_DOMAIN_SEPARATOR(bytes32 domainSeparator) public { + vm.mockCall( + address(this), abi.encodeWithSignature('SOLUTION_SETTLER_DOMAIN_SEPARATOR()'), abi.encode(domainSeparator) + ); + } + function expectCall_SOLUTION_SETTLER_DOMAIN_SEPARATOR() public { + vm.expectCall(address(this), abi.encodeWithSignature('SOLUTION_SETTLER_DOMAIN_SEPARATOR()')); + } + + /// MockBCoWPool mock methods constructor(address cowSolutionSettler, bytes32 appData) BCoWPool(cowSolutionSettler, appData) {} function mock_call_commit(bytes32 orderHash) public { @@ -367,6 +377,84 @@ contract MockBCoWPool is BCoWPool, Test { vm.expectCall(address(this), abi.encodeWithSignature('_afterFinalize()')); } + function mock_call__pullPoolShare(address from, uint256 amount) public { + vm.mockCall(address(this), abi.encodeWithSignature('_pullPoolShare(address,uint256)', from, amount), abi.encode()); + } + + function _pullPoolShare(address from, uint256 amount) internal override { + (bool _success, bytes memory _data) = + address(this).call(abi.encodeWithSignature('_pullPoolShare(address,uint256)', from, amount)); + + if (_success) return abi.decode(_data, ()); + else return super._pullPoolShare(from, amount); + } + + function call__pullPoolShare(address from, uint256 amount) public { + return _pullPoolShare(from, amount); + } + + function expectCall__pullPoolShare(address from, uint256 amount) public { + vm.expectCall(address(this), abi.encodeWithSignature('_pullPoolShare(address,uint256)', from, amount)); + } + + function mock_call__pushPoolShare(address to, uint256 amount) public { + vm.mockCall(address(this), abi.encodeWithSignature('_pushPoolShare(address,uint256)', to, amount), abi.encode()); + } + + function _pushPoolShare(address to, uint256 amount) internal override { + (bool _success, bytes memory _data) = + address(this).call(abi.encodeWithSignature('_pushPoolShare(address,uint256)', to, amount)); + + if (_success) return abi.decode(_data, ()); + else return super._pushPoolShare(to, amount); + } + + function call__pushPoolShare(address to, uint256 amount) public { + return _pushPoolShare(to, amount); + } + + function expectCall__pushPoolShare(address to, uint256 amount) public { + vm.expectCall(address(this), abi.encodeWithSignature('_pushPoolShare(address,uint256)', to, amount)); + } + + function mock_call__mintPoolShare(uint256 amount) public { + vm.mockCall(address(this), abi.encodeWithSignature('_mintPoolShare(uint256)', amount), abi.encode()); + } + + function _mintPoolShare(uint256 amount) internal override { + (bool _success, bytes memory _data) = address(this).call(abi.encodeWithSignature('_mintPoolShare(uint256)', amount)); + + if (_success) return abi.decode(_data, ()); + else return super._mintPoolShare(amount); + } + + function call__mintPoolShare(uint256 amount) public { + return _mintPoolShare(amount); + } + + function expectCall__mintPoolShare(uint256 amount) public { + vm.expectCall(address(this), abi.encodeWithSignature('_mintPoolShare(uint256)', amount)); + } + + function mock_call__burnPoolShare(uint256 amount) public { + vm.mockCall(address(this), abi.encodeWithSignature('_burnPoolShare(uint256)', amount), abi.encode()); + } + + function _burnPoolShare(uint256 amount) internal override { + (bool _success, bytes memory _data) = address(this).call(abi.encodeWithSignature('_burnPoolShare(uint256)', amount)); + + if (_success) return abi.decode(_data, ()); + else return super._burnPoolShare(amount); + } + + function call__burnPoolShare(uint256 amount) public { + return _burnPoolShare(amount); + } + + function expectCall__burnPoolShare(uint256 amount) public { + vm.expectCall(address(this), abi.encodeWithSignature('_burnPoolShare(uint256)', amount)); + } + function mock_call__getLock(bytes32 value) public { vm.mockCall(address(this), abi.encodeWithSignature('_getLock()'), abi.encode(value)); } @@ -378,7 +466,7 @@ contract MockBCoWPool is BCoWPool, Test { else return super._getLock(); } - function call__getLock() public returns (bytes32 value) { + function call__getLock() public view returns (bytes32 value) { return _getLock(); } diff --git a/test/unit/BCoWHelper.t.sol b/test/unit/BCoWHelper.t.sol new file mode 100644 index 00000000..ef6d03b5 --- /dev/null +++ b/test/unit/BCoWHelper.t.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {Test} from 'forge-std/Test.sol'; +import {MockBCoWHelper} from 'test/manual-smock/MockBCoWHelper.sol'; + +import {IBCoWPool} from 'interfaces/IBCoWPool.sol'; +import {IBPool} from 'interfaces/IBPool.sol'; +import {ISettlement} from 'interfaces/ISettlement.sol'; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +import {ICOWAMMPoolHelper} from '@cow-amm/interfaces/ICOWAMMPoolHelper.sol'; +import {GPv2Interaction} from '@cowprotocol/libraries/GPv2Interaction.sol'; +import {GPv2Order} from '@cowprotocol/libraries/GPv2Order.sol'; + +import {MockBCoWFactory} from 'test/manual-smock/MockBCoWFactory.sol'; +import {MockBCoWPool} from 'test/manual-smock/MockBCoWPool.sol'; + +contract BCoWHelperTest is Test { + MockBCoWHelper helper; + + MockBCoWFactory factory; + MockBCoWPool pool; + address invalidPool = makeAddr('invalidPool'); + address[] tokens = new address[](2); + uint256[] priceVector = new uint256[](2); + + uint256 constant VALID_WEIGHT = 1e18; + uint256 constant BASE = 1e18; + + function setUp() external { + factory = new MockBCoWFactory(address(0), bytes32(0)); + + address solutionSettler = makeAddr('solutionSettler'); + vm.mockCall( + solutionSettler, abi.encodePacked(ISettlement.domainSeparator.selector), abi.encode(bytes32('domainSeparator')) + ); + vm.mockCall( + solutionSettler, abi.encodePacked(ISettlement.vaultRelayer.selector), abi.encode(makeAddr('vaultRelayer')) + ); + pool = new MockBCoWPool(makeAddr('solutionSettler'), bytes32(0)); + + // creating a valid pool setup + factory.mock_call_isBPool(address(pool), true); + tokens[0] = makeAddr('token0'); + tokens[1] = makeAddr('token1'); + pool.set__tokens(tokens); + pool.set__records(tokens[0], IBPool.Record({bound: true, index: 0, denorm: VALID_WEIGHT})); + pool.set__records(tokens[1], IBPool.Record({bound: true, index: 1, denorm: VALID_WEIGHT})); + pool.set__totalWeight(2 * VALID_WEIGHT); + pool.set__finalized(true); + + priceVector[0] = 1e18; + priceVector[1] = 1.05e18; + + vm.mockCall(tokens[0], abi.encodePacked(IERC20.balanceOf.selector), abi.encode(priceVector[0])); + vm.mockCall(tokens[1], abi.encodePacked(IERC20.balanceOf.selector), abi.encode(priceVector[1])); + + factory.mock_call_APP_DATA(bytes32('appData')); + helper = new MockBCoWHelper(address(factory)); + } + + function test_ConstructorWhenCalled(bytes32 _appData) external { + factory.expectCall_APP_DATA(); + factory.mock_call_APP_DATA(_appData); + helper = new MockBCoWHelper(address(factory)); + // it should set factory + assertEq(helper.factory(), address(factory)); + // it should set app data from factory + assertEq(helper.call__APP_DATA(), _appData); + } + + function test_TokensRevertWhen_PoolIsNotRegisteredInFactory() external { + factory.mock_call_isBPool(address(pool), false); + // it should revert + vm.expectRevert(ICOWAMMPoolHelper.PoolDoesNotExist.selector); + helper.tokens(address(pool)); + } + + function test_TokensRevertWhen_PoolHasLessThan2Tokens() external { + address[] memory invalidTokens = new address[](1); + invalidTokens[0] = makeAddr('token0'); + pool.set__tokens(invalidTokens); + // it should revert + vm.expectRevert(ICOWAMMPoolHelper.PoolDoesNotExist.selector); + helper.tokens(address(pool)); + } + + function test_TokensRevertWhen_PoolHasMoreThan2Tokens() external { + address[] memory invalidTokens = new address[](3); + invalidTokens[0] = makeAddr('token0'); + invalidTokens[1] = makeAddr('token1'); + invalidTokens[2] = makeAddr('token2'); + pool.set__tokens(invalidTokens); + // it should revert + vm.expectRevert(ICOWAMMPoolHelper.PoolDoesNotExist.selector); + helper.tokens(address(pool)); + } + + function test_TokensRevertWhen_PoolTokensHaveDifferentWeights() external { + pool.mock_call_getNormalizedWeight(tokens[0], VALID_WEIGHT); + pool.mock_call_getNormalizedWeight(tokens[1], VALID_WEIGHT + 1); + + vm.expectRevert(ICOWAMMPoolHelper.PoolDoesNotExist.selector); + // it should revert + helper.tokens(address(pool)); + } + + function test_TokensWhenPoolIsSupported() external view { + // it should return pool tokens + address[] memory returned = helper.tokens(address(pool)); + assertEq(returned[0], tokens[0]); + assertEq(returned[1], tokens[1]); + } + + function test_OrderRevertWhen_ThePoolIsNotSupported() external { + // it should revert + vm.expectRevert(ICOWAMMPoolHelper.PoolDoesNotExist.selector); + helper.order(invalidPool, priceVector); + } + + function test_OrderWhenThePoolIsSupported(bytes32 domainSeparator) external { + // it should call tokens + helper.mock_call_tokens(address(pool), tokens); + helper.expectCall_tokens(address(pool)); + + // it should query the domain separator from the pool + pool.expectCall_SOLUTION_SETTLER_DOMAIN_SEPARATOR(); + pool.mock_call_SOLUTION_SETTLER_DOMAIN_SEPARATOR(domainSeparator); + + ( + GPv2Order.Data memory order_, + GPv2Interaction.Data[] memory preInteractions, + GPv2Interaction.Data[] memory postInteractions, + bytes memory sig + ) = helper.order(address(pool), priceVector); + + // it should return a valid pool order + assertEq(order_.receiver, GPv2Order.RECEIVER_SAME_AS_OWNER); + assertLe(order_.validTo, block.timestamp + 5 minutes); + assertEq(order_.feeAmount, 0); + assertEq(order_.appData, factory.APP_DATA()); + assertEq(order_.kind, GPv2Order.KIND_SELL); + assertEq(order_.buyTokenBalance, GPv2Order.BALANCE_ERC20); + assertEq(order_.sellTokenBalance, GPv2Order.BALANCE_ERC20); + + // it should return a commit pre-interaction + assertEq(preInteractions.length, 1); + assertEq(preInteractions[0].target, address(pool)); + assertEq(preInteractions[0].value, 0); + bytes memory commitment = abi.encodeCall(IBCoWPool.commit, GPv2Order.hash(order_, domainSeparator)); + assertEq(keccak256(preInteractions[0].callData), keccak256(commitment)); + + // it should return an empty post-interaction + assertTrue(postInteractions.length == 0); + + // it should return a valid signature + bytes memory validSig = abi.encodePacked(pool, abi.encode(order_)); + assertEq(keccak256(validSig), keccak256(sig)); + } + + function test_OrderGivenAPriceSkewenessToToken1( + uint256 priceSkewness, + uint256 balanceToken0, + uint256 balanceToken1 + ) external { + // skew the price by max 50% (more could result in reverts bc of max swap ratio) + // avoids no-skewness revert + priceSkewness = bound(priceSkewness, BASE + 0.0001e18, 1.5e18); + + balanceToken0 = bound(balanceToken0, 1e18, 1e27); + balanceToken1 = bound(balanceToken1, 1e18, 1e27); + vm.mockCall(tokens[0], abi.encodePacked(IERC20.balanceOf.selector), abi.encode(balanceToken0)); + vm.mockCall(tokens[1], abi.encodePacked(IERC20.balanceOf.selector), abi.encode(balanceToken1)); + + // NOTE: the price of token 1 is increased by the skeweness + uint256[] memory prices = new uint256[](2); + prices[0] = balanceToken1; + prices[1] = balanceToken0 * priceSkewness / BASE; + + // it should return a valid pool order + (GPv2Order.Data memory ammOrder,,,) = helper.order(address(pool), prices); + + // it should buy token0 + assertEq(address(ammOrder.buyToken), tokens[0]); + + // it should return a valid pool order + // this call should not revert + pool.verify(ammOrder); + } + + function test_OrderGivenAPriceSkewenessToToken0( + uint256 priceSkewness, + uint256 balanceToken0, + uint256 balanceToken1 + ) external { + // skew the price by max 50% (more could result in reverts bc of max swap ratio) + // avoids no-skewness revert + priceSkewness = bound(priceSkewness, 0.5e18, BASE - 0.0001e18); + + balanceToken0 = bound(balanceToken0, 1e18, 1e27); + balanceToken1 = bound(balanceToken1, 1e18, 1e27); + vm.mockCall(tokens[0], abi.encodePacked(IERC20.balanceOf.selector), abi.encode(balanceToken0)); + vm.mockCall(tokens[1], abi.encodePacked(IERC20.balanceOf.selector), abi.encode(balanceToken1)); + + // NOTE: the price of token 1 is decrease by the skeweness + uint256[] memory prices = new uint256[](2); + prices[0] = balanceToken1; + prices[1] = balanceToken0 * priceSkewness / BASE; + + // it should return a valid pool order + (GPv2Order.Data memory ammOrder,,,) = helper.order(address(pool), prices); + + // it should buy token1 + assertEq(address(ammOrder.buyToken), tokens[1]); + + // it should return a valid pool order + // this call should not revert + pool.verify(ammOrder); + } +} diff --git a/test/unit/BCoWHelper.tree b/test/unit/BCoWHelper.tree new file mode 100644 index 00000000..e27a5207 --- /dev/null +++ b/test/unit/BCoWHelper.tree @@ -0,0 +1,33 @@ +BCoWHelperTest::constructor +└── when called + ├── it should set factory + └── it should set app data from factory + +BCoWHelperTest::tokens +├── when pool is not registered in factory +│ └── it should revert +├── when pool has less than 2 tokens +│ └── it should revert +├── when pool has more than 2 tokens +│ └── it should revert +├── when pool tokens have different weights +│ └── it should revert +└── when pool is supported + └── it should return pool tokens + +BCoWHelperTest::order +├── when the pool is not supported +│ └── it should revert +├── when the pool is supported +│ ├── it should call tokens +│ ├── it should query the domain separator from the pool +│ ├── it should return a valid pool order +│ ├── it should return a commit pre-interaction +│ ├── it should return an empty post-interaction +│ └── it should return a valid signature +├── given a price skeweness to token1 +│ ├── it should buy token0 +│ └── it should return a valid pool order +└── given a price skeweness to token0 + ├── it should buy token1 + └── it should return a valid pool order diff --git a/yarn.lock b/yarn.lock index 27b5429f..bba6419e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -603,6 +603,10 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" +"cow-amm@github:cowprotocol/cow-amm.git#6566128": + version "0.0.0" + resolved "https://codeload.github.com/cowprotocol/cow-amm/tar.gz/6566128b6c73008062cf4a6d1957db602409b719" + cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"