diff --git a/src/ConstantProduct.sol b/src/ConstantProduct.sol index 4889600..f49aaff 100644 --- a/src/ConstantProduct.sol +++ b/src/ConstantProduct.sol @@ -1,4 +1,131 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; -contract ConstantProduct {} +import {IERC20} from "lib/composable-cow/lib/@openzeppelin/contracts/interfaces/IERC20.sol"; +import {IUniswapV2Pair} from "lib/uniswap-v2-core/contracts/interfaces/IUniswapV2Pair.sol"; +import {ConditionalOrdersUtilsLib as Utils} from "lib/composable-cow/src/types/ConditionalOrdersUtilsLib.sol"; +import { + IConditionalOrderGenerator, + IConditionalOrder, + IERC165, + GPv2Order +} from "lib/composable-cow/src/BaseConditionalOrder.sol"; + +/** + * @title CoW AMM + * @author CoW Protocol Developers + * @dev Automated market maker based on the concept of function-maximising AMMs. + * It relies on the CoW Protocol infrastructure to guarantee batch execution of + * its orders. + * Order creation and execution is based on the Composable CoW base contracts. + */ +contract ConstantProduct is IConditionalOrderGenerator { + uint32 public constant MAX_ORDER_DURATION = 5 * 60; + + /// All data used by an order to validate the AMM conditions. + struct Data { + /// A Uniswap v2 pair. This is used to determine the tokens traded by + /// the AMM, and also use to establish the reference price used when + /// computing a valid tradable order. + IUniswapV2Pair referencePair; + /// The app data that must be used in the order. + /// See `GPv2Order.Data` for more information on the app data. + bytes32 appData; + } + + /** + * @inheritdoc IConditionalOrderGenerator + */ + function getTradeableOrder(address owner, address, bytes32, bytes calldata staticInput, bytes calldata) + public + view + override + returns (GPv2Order.Data memory order) + { + revert("unimplemented"); + } + + /** + * @inheritdoc IConditionalOrder + * @dev Most parameters are ignored: we only need to validate the order with + * the current reserves and the validated order parameters. + */ + function verify( + address owner, + address, + bytes32, + bytes32, + bytes32, + bytes calldata staticInput, + bytes calldata, + GPv2Order.Data calldata order + ) external view override { + _verify(owner, staticInput, order); + } + + /** + * @dev Wrapper for the `verify` function with only the parameters that are + * required for verification. Compared to implementing the logic inside + * `verify`, it frees up some stack slots and reduces "stack too deep" + * issues. + * @param owner the contract who is the owner of the order + * @param staticInput the static input for all discrete orders cut from this + * conditional order + * @param order `GPv2Order.Data` of a discrete order to be verified. + */ + function _verify(address owner, bytes calldata staticInput, GPv2Order.Data calldata order) internal view { + ConstantProduct.Data memory data = abi.decode(staticInput, (Data)); + + IERC20 sellToken = IERC20(data.referencePair.token0()); + IERC20 buyToken = IERC20(data.referencePair.token1()); + uint256 sellReserve = sellToken.balanceOf(owner); + uint256 buyReserve = buyToken.balanceOf(owner); + if (order.sellToken != sellToken) { + if (order.sellToken != buyToken) { + revert IConditionalOrder.OrderNotValid("invalid sell token"); + } + (sellToken, buyToken) = (buyToken, sellToken); + (sellReserve, buyReserve) = (buyReserve, sellReserve); + } + if (order.buyToken != buyToken) { + revert IConditionalOrder.OrderNotValid("invalid buy token"); + } + + if (order.receiver != GPv2Order.RECEIVER_SAME_AS_OWNER) { + revert IConditionalOrder.OrderNotValid("invalid receiver"); + } + // We add a maximum duration to avoid spamming the orderbook and force + // an order refresh if the order is old. + if (order.validTo > block.timestamp + MAX_ORDER_DURATION) { + revert IConditionalOrder.OrderNotValid("invalid validTo"); + } + if (order.appData != data.appData) { + revert IConditionalOrder.OrderNotValid("invalid appData"); + } + if (order.feeAmount != 0) { + revert IConditionalOrder.OrderNotValid("invalid feeAmount"); + } + if (order.buyTokenBalance != GPv2Order.BALANCE_ERC20) { + revert IConditionalOrder.OrderNotValid("invalid buyTokenBalance"); + } + if (order.sellTokenBalance != GPv2Order.BALANCE_ERC20) { + revert IConditionalOrder.OrderNotValid("invalid sellTokenBalance"); + } + // These are the checks needed to satisfy the conditions on in/out + // amounts for the function-maximising AMM. + if ((sellReserve - 2 * order.sellAmount) * order.buyAmount < buyReserve * order.sellAmount) { + revert IConditionalOrder.OrderNotValid("received amount too low"); + } + + // No checks on: + //bytes32 kind; + //bool partiallyFillable; + } + + /** + * @inheritdoc IERC165 + */ + function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { + return interfaceId == type(IConditionalOrderGenerator).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/test/ConstantProduct.t.sol b/test/ConstantProduct.t.sol index 185a887..9042387 100644 --- a/test/ConstantProduct.t.sol +++ b/test/ConstantProduct.t.sol @@ -1,4 +1,6 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.13; -contract E2eCounterTest {} +import {VerifyTest} from "./ConstantProduct/VerifyTest.sol"; + +contract ConstantProductTest is VerifyTest {} diff --git a/test/ConstantProduct/ConstantProductTestHarness.sol b/test/ConstantProduct/ConstantProductTestHarness.sol new file mode 100644 index 0000000..06ca396 --- /dev/null +++ b/test/ConstantProduct/ConstantProductTestHarness.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {BaseComposableCoWTest} from "lib/composable-cow/test/ComposableCoW.base.t.sol"; + +import {ConstantProduct, GPv2Order, IUniswapV2Pair, IERC20} from "../../src/ConstantProduct.sol"; + +abstract contract ConstantProductTestHarness is BaseComposableCoWTest { + ConstantProduct constantProduct; + address internal orderOwner = addressFromString("order owner"); + + address private USDC = addressFromString("USDC"); + address private WETH = addressFromString("WETH"); + address private DEFAULT_PAIR = addressFromString("default USDC/WETH pair"); + address private DEFAULT_RECEIVER = addressFromString("default receiver"); + bytes32 private DEFAULT_APPDATA = keccak256(bytes("unit test")); + + function setUp() public virtual override(BaseComposableCoWTest) { + super.setUp(); + + constantProduct = new ConstantProduct(); + } + + function setUpDefaultPair() internal returns (IUniswapV2Pair pair) { + vm.mockCall(DEFAULT_PAIR, abi.encodeWithSelector(IUniswapV2Pair.token0.selector), abi.encode(USDC)); + vm.mockCall(DEFAULT_PAIR, abi.encodeWithSelector(IUniswapV2Pair.token1.selector), abi.encode(WETH)); + // Reverts for everything else + vm.mockCallRevert(DEFAULT_PAIR, hex"", abi.encode("Called unexpected function on mock pair")); + pair = IUniswapV2Pair(DEFAULT_PAIR); + require(pair.token0() != pair.token1(), "Pair setup failed: should use distinct tokens"); + } + + function setUpDefaultData() internal returns (ConstantProduct.Data memory) { + setUpDefaultPair(); + return getDefaultData(); + } + + function setUpDefaultReserves(address owner) internal { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + vm.mockCall( + defaultData.referencePair.token0(), + abi.encodeWithSelector(IERC20.balanceOf.selector, owner), + abi.encode(1337) + ); + vm.mockCall( + defaultData.referencePair.token1(), + abi.encodeWithSelector(IERC20.balanceOf.selector, owner), + abi.encode(1337) + ); + } + + // This function calls `verify` while filling all unused parameters with + // arbitrary data. + function verifyWrapper(address owner, ConstantProduct.Data memory staticInput, GPv2Order.Data memory order) + internal + view + { + constantProduct.verify( + owner, + addressFromString("sender"), + keccak256(bytes("order hash")), + keccak256(bytes("domain separator")), + keccak256(bytes("context")), + abi.encode(staticInput), + bytes("offchain input"), + order + ); + } + + function getDefaultData() internal view returns (ConstantProduct.Data memory) { + return ConstantProduct.Data(IUniswapV2Pair(DEFAULT_PAIR), DEFAULT_APPDATA); + } + + function getDefaultOrder() internal view returns (GPv2Order.Data memory) { + ConstantProduct.Data memory data = getDefaultData(); + + return GPv2Order.Data( + IERC20(USDC), // IERC20 sellToken; + IERC20(WETH), // IERC20 buyToken; + GPv2Order.RECEIVER_SAME_AS_OWNER, // address receiver; + 0, // uint256 sellAmount; + 0, // uint256 buyAmount; + uint32(block.timestamp) + constantProduct.MAX_ORDER_DURATION() / 2, // uint32 validTo; + data.appData, // bytes32 appData; + 0, // uint256 feeAmount; + GPv2Order.KIND_SELL, // bytes32 kind; + true, // bool partiallyFillable; + GPv2Order.BALANCE_ERC20, // bytes32 sellTokenBalance; + GPv2Order.BALANCE_ERC20 // bytes32 buyTokenBalance; + ); + } + + function addressFromString(string memory s) internal pure returns (address) { + return address(uint160(uint256(keccak256(bytes(s))))); + } +} diff --git a/test/ConstantProduct/VerifyTest.sol b/test/ConstantProduct/VerifyTest.sol new file mode 100644 index 0000000..361467c --- /dev/null +++ b/test/ConstantProduct/VerifyTest.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {ValidateOrderParametersTest} from "./verify/ValidateOrderParametersTest.sol"; +import {ValidateAmmMath} from "./verify/ValidateAmmMath.sol"; + +abstract contract VerifyTest is ValidateOrderParametersTest, ValidateAmmMath {} diff --git a/test/ConstantProduct/verify/ValidateAmmMath.sol b/test/ConstantProduct/verify/ValidateAmmMath.sol new file mode 100644 index 0000000..10ad30b --- /dev/null +++ b/test/ConstantProduct/verify/ValidateAmmMath.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {ConstantProductTestHarness} from "../ConstantProductTestHarness.sol"; +import {ConstantProduct, GPv2Order, IUniswapV2Pair, IERC20, IConditionalOrder} from "../../../src/ConstantProduct.sol"; + +abstract contract ValidateAmmMath is ConstantProductTestHarness { + IUniswapV2Pair pair = IUniswapV2Pair(addressFromString("pair for math verification")); + + function setUpAmmWithReserves(uint256 amountToken0, uint256 amountToken1) internal { + IERC20 token0 = IERC20(addressFromString("token0 for math verification")); + IERC20 token1 = IERC20(addressFromString("token1 for math verification")); + vm.mockCall(address(pair), abi.encodeWithSelector(IUniswapV2Pair.token0.selector), abi.encode(token0)); + vm.mockCall(address(pair), abi.encodeWithSelector(IUniswapV2Pair.token1.selector), abi.encode(token1)); + // Reverts for everything else + vm.mockCallRevert(address(pair), hex"", abi.encode("Called unexpected function on mock pair")); + require(pair.token0() != pair.token1(), "Pair setup failed: should use distinct tokens"); + + vm.mockCall( + address(token0), abi.encodeWithSelector(IERC20.balanceOf.selector, orderOwner), abi.encode(amountToken0) + ); + vm.mockCall( + address(token1), abi.encodeWithSelector(IERC20.balanceOf.selector, orderOwner), abi.encode(amountToken1) + ); + } + + function setUpOrderWithReserves(uint256 amountToken0, uint256 amountToken1) + internal + returns (ConstantProduct.Data memory data, GPv2Order.Data memory order) + { + setUpAmmWithReserves(amountToken0, amountToken1); + order = getDefaultOrder(); + order.sellToken = IERC20(pair.token0()); + order.buyToken = IERC20(pair.token1()); + order.sellAmount = 0; + order.buyAmount = 0; + + data = ConstantProduct.Data(pair, order.appData); + } + + // Note: if X is the reserve of the tokens that is taken from the AMM, and + // Y the reserve of the token that is deposited into the AMM, then given + // any in amount x you can compute the out amount as: + // Y * x + // y = --------- + // X - 2x + + function testExactAmountsInOut() public { + uint256 poolOut = 1000 ether; + uint256 poolIn = 10 ether; + (ConstantProduct.Data memory data, GPv2Order.Data memory order) = setUpOrderWithReserves(poolOut, poolIn); + + uint256 amountOut = 100 ether; + uint256 amountIn = poolIn * amountOut / (poolOut - 2 * amountOut); + order.sellAmount = amountOut; + order.buyAmount = amountIn; + + verifyWrapper(orderOwner, data, order); + + // The next line is there so that we can see at a glance that the out + // amount is reasonable given the in amount, since the math could be + // hiding the fact that the AMM leads to bad orders. + require(amountIn == 1.25 ether); + } + + function testOneTooMuchOut() public { + uint256 poolOut = 1000 ether; + uint256 poolIn = 10 ether; + (ConstantProduct.Data memory data, GPv2Order.Data memory order) = setUpOrderWithReserves(poolOut, poolIn); + + uint256 amountOut = 100 ether; + uint256 amountIn = poolIn * amountOut / (poolOut - 2 * amountOut); + order.sellAmount = amountOut + 1; + order.buyAmount = amountIn; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "received amount too low")); + verifyWrapper(orderOwner, data, order); + } + + function testOneTooLittleIn() public { + uint256 poolOut = 1000 ether; + uint256 poolIn = 10 ether; + (ConstantProduct.Data memory data, GPv2Order.Data memory order) = setUpOrderWithReserves(poolOut, poolIn); + + uint256 amountOut = 100 ether; + uint256 amountIn = poolIn * amountOut / (poolOut - 2 * amountOut); + order.sellAmount = amountOut; + order.buyAmount = amountIn - 1; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "received amount too low")); + verifyWrapper(orderOwner, data, order); + } + + function testInvertInOutToken() public { + uint256 poolOut = 1000 ether; + uint256 poolIn = 10 ether; + (ConstantProduct.Data memory data, GPv2Order.Data memory order) = setUpOrderWithReserves(poolIn, poolOut); + + uint256 amountOut = 100 ether; + uint256 amountIn = poolIn * amountOut / (poolOut - 2 * amountOut); + (order.sellToken, order.buyToken) = (order.buyToken, order.sellToken); + order.sellAmount = amountOut; + order.buyAmount = amountIn; + + verifyWrapper(orderOwner, data, order); + } + + function testInvertedTokenOneTooMuchOut() public { + uint256 poolOut = 1000 ether; + uint256 poolIn = 10 ether; + (ConstantProduct.Data memory data, GPv2Order.Data memory order) = setUpOrderWithReserves(poolIn, poolOut); + + uint256 amountOut = 100 ether; + uint256 amountIn = poolIn * amountOut / (poolOut - 2 * amountOut); + (order.sellToken, order.buyToken) = (order.buyToken, order.sellToken); + order.sellAmount = amountOut + 1; + order.buyAmount = amountIn; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "received amount too low")); + verifyWrapper(orderOwner, data, order); + } + + function testInvertedTokensOneTooLittleIn() public { + uint256 poolOut = 1000 ether; + uint256 poolIn = 10 ether; + (ConstantProduct.Data memory data, GPv2Order.Data memory order) = setUpOrderWithReserves(poolIn, poolOut); + + uint256 amountOut = 100 ether; + uint256 amountIn = poolIn * amountOut / (poolOut - 2 * amountOut); + (order.sellToken, order.buyToken) = (order.buyToken, order.sellToken); + order.sellAmount = amountOut; + order.buyAmount = amountIn - 1; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "received amount too low")); + verifyWrapper(orderOwner, data, order); + } +} diff --git a/test/ConstantProduct/verify/ValidateOrderParametersTest.sol b/test/ConstantProduct/verify/ValidateOrderParametersTest.sol new file mode 100644 index 0000000..338bf59 --- /dev/null +++ b/test/ConstantProduct/verify/ValidateOrderParametersTest.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {ConstantProductTestHarness} from "../ConstantProductTestHarness.sol"; +import {ConstantProduct, GPv2Order, IUniswapV2Pair, IERC20, IConditionalOrder} from "../../../src/ConstantProduct.sol"; + +abstract contract ValidateOrderParametersTest is ConstantProductTestHarness { + function testDefaultDoesNotRevert() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + function testCanInvertTokens() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + (defaultOrder.sellToken, defaultOrder.buyToken) = (defaultOrder.buyToken, defaultOrder.sellToken); + + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + function testRevertsIfInvalidTokenCombination() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + IERC20 badToken = IERC20(addressFromString("bad token")); + vm.mockCall(address(badToken), abi.encodeWithSelector(IERC20.balanceOf.selector, orderOwner), abi.encode(1337)); + IERC20 badTokenExtra = IERC20(addressFromString("extra bad token")); + vm.mockCall( + address(badTokenExtra), abi.encodeWithSelector(IERC20.balanceOf.selector, orderOwner), abi.encode(1337) + ); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + IERC20[2][4] memory sellTokenInvalidCombinations = [ + [badToken, badToken], + [badToken, defaultOrder.sellToken], + [badToken, defaultOrder.buyToken], + [badToken, badTokenExtra] + ]; + IERC20[2][4] memory buyTokenInvalidCombinations = [ + [defaultOrder.sellToken, defaultOrder.sellToken], + [defaultOrder.buyToken, defaultOrder.buyToken], + [defaultOrder.sellToken, badToken], + [defaultOrder.buyToken, badToken] + ]; + + for (uint256 i = 0; i < sellTokenInvalidCombinations.length; i += 1) { + defaultOrder.sellToken = sellTokenInvalidCombinations[i][0]; + defaultOrder.buyToken = sellTokenInvalidCombinations[i][1]; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid sell token")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + for (uint256 i = 0; i < buyTokenInvalidCombinations.length; i += 1) { + defaultOrder.sellToken = buyTokenInvalidCombinations[i][0]; + defaultOrder.buyToken = buyTokenInvalidCombinations[i][1]; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid buy token")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + } + + function testRevertsIfDifferentReceiver() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + defaultOrder.receiver = addressFromString("bad receiver"); + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid receiver")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + function testRevertsIfExpiresFarInTheFuture() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + defaultOrder.validTo = uint32(block.timestamp) + constantProduct.MAX_ORDER_DURATION() + 1; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid validTo")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + function testRevertsIfDifferentAppData() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + defaultOrder.appData = keccak256(bytes("bad app data")); + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid appData")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + function testRevertsIfNonzeroFee() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + defaultOrder.feeAmount = 1; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid feeAmount")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + function testRevertsIfSellTokenBalanceIsNotErc20() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + defaultOrder.sellTokenBalance = GPv2Order.BALANCE_EXTERNAL; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid sellTokenBalance")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } + + function testRevertsIfBuyTokenBalanceIsNotErc20() public { + ConstantProduct.Data memory defaultData = setUpDefaultData(); + setUpDefaultReserves(orderOwner); + + GPv2Order.Data memory defaultOrder = getDefaultOrder(); + defaultOrder.buyTokenBalance = GPv2Order.BALANCE_EXTERNAL; + + vm.expectRevert(abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, "invalid buyTokenBalance")); + verifyWrapper(orderOwner, defaultData, defaultOrder); + } +}