Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement verify function #1

Merged
merged 9 commits into from
Jan 31, 2024
Merged
131 changes: 129 additions & 2 deletions src/ConstantProduct.sol
Original file line number Diff line number Diff line change
@@ -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("receiver must be zero address");
}
// 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("validity too far in the future");
}
if (order.appData != data.appData) {
revert IConditionalOrder.OrderNotValid("invalid appData");
}
if (order.feeAmount != 0) {
revert IConditionalOrder.OrderNotValid("fee amount must be zero");
}
if (order.buyTokenBalance != GPv2Order.BALANCE_ERC20) {
revert IConditionalOrder.OrderNotValid("buyTokenBalance must be erc20");
}
if (order.sellTokenBalance != GPv2Order.BALANCE_ERC20) {
revert IConditionalOrder.OrderNotValid("sellTokenBalance must be erc20");
}
// 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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we put brackets around the subtraction (I never know what operation takes priority from just reading the code).

Also, I'm not sure I follow the formula. The FM-AMM has an implicit k (which is sellReserve * buyReserve). After the trade it will have a new k (which is at least (sellReserve - sellAmount) * (buyReserve + buyAmount)). The new k should be >= the old k.

Specifically:

sellReserve*buyReserve < (sellReserve-order.sellAmount)*(buyReserve+order.buyAmount)

Is this equivalent or am I missing something?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @fleupold : the check here is too strict. The formula as written checks that the solution maximizes the product of the solvers' liquidity reserves, under the assumption that there is a linear constraint: solvers can exchange any amount of tokens at a given price. But if the constraint is non-linear, then the check may fail even if solvers are optimizing correctly.

For this reason, I think the smart contract should simply check that it is trading at better terms that a constant product AMM. The right condition is
((sellReserve - order.sellAmount) * order.buyAmount < buyReserve * order.sellAmount)
and then trust the solvers' competition that the proposed traded amount actually maximizes the FM-AMM objective function

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised that we want to go for a weaker check onchain, when the cost of a stronger one is just an extra multiplication by 2.
The expressions you suggest (they are equivalent) means that the AMM is happy to trade as long as the trade is the same as coming from a Uniswap constant product curve.
My understanding instead is that we want to enforce the FM-AMM rules. They can be sidestep (this is discussed in "why batching trades is necessary" in the paper) but doing so has an extra gas cost and would be more visible to detect from the trades as a glance (because you need to split a trade in many smaller ones).
The difference in trade amount is very significant when the trade is large (in the example from my earlier writeup, for an ETH/DAI CoW AMM storing (25M DAI, 10k ETH), a trade of 2.5M can incur in an effective loss of 139 ETH).
The FM-AMM rules in the paper are enforced at a negligible extra cost with the formula that account for the FM-AMM rules.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand the discrepancy now (which isn't obvious IMO). The formula as stated right now asserts that the execution price exactly the new spot price after the trade rather than at least as good as the average price (what Uniswap does currently).

If I understand correctly, this will result in FM-AMM order executions most of the time contributing 0 surplus to the batch (as they will be matched exactly at their limit price). I think this is problematic in the current mechanism (as solvers trying to arb the AMM will have no advantage from an objective value perspective over solvers that don't arb).

Also, in the case of small price swings the limit price computed in getTradeableOrder may no longer be achievable and therefore no trade might happen (I'm not even sure partial fills make sense in that case as the acceptable price seems very closely related to the full order's size).

Therefore, I'd prefer the AMM expressing their minimum willingness to trade (as good as Uniswap) and have solvers compete for improving that price as much as possible via surplus.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @fleupold : to fit it within our current framework, the smart contract should check some minimal requirements (i.e., the limit price) and leave it to the solver competition to spit out the "correct" trade.

Copy link
Collaborator Author

@fedgiac fedgiac Jan 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Math has been updated in bebead7.

Copy link

@fleupold fleupold Jan 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is related to the other discussion, but I believe the math as it currently stands allows violating k (ie it is not the same as asserting .

sellReserve*buyReserve < (sellReserve-order.sellAmount)*(buyReserve+order.buyAmount)

Let's say sellReserve is 1 and buyReserve is 1000, suggested sell amount 0.25 and suggested buy amount 330. Then the condition as currently implemented holds since

(sellReserve - order.sellAmount) * order.buyAmount < buyReserve * order.sellAmount = 0.75 * 330 = 247.5 < 1000 * 0.25 = 250

However 0.75 * 1330 = 997.5 and thus k has decreased.

Can we use forge fuzz tests to suggest random orders and if they are accepted assert that k is not violated?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In your example k decreases. The check in the code computes 247.5 < 250 and since the condition is true then the function reverts, so everything should work as expected.

Note that we can derive the current condition on the code from the condition on the k:

sellReserve*buyReserve ≤ (sellReserve-order.sellAmount)*(buyReserve+order.buyAmount)

Expanding:

sellReserve*buyReserve ≤ sellReserve*buyReserve - order.sellAmount*buyReserve + sellReserve*order.buyAmount - order.sellAmount*order.buyAmount

Simplifying:

0 ≤ - order.sellAmount*buyReserve + sellReserve*order.buyAmount - order.sellAmount*order.buyAmount

Factoring:

0 ≤ - order.sellAmount*buyReserve + order.buyAmount*(sellReserve - order.sellAmount)

Rearranging:

order.sellAmount*buyReserve ≤ order.buyAmount*(sellReserve - order.sellAmount)

For the revert condition the inequality must be swapped:

(sellReserve - order.sellAmount) * order.buyAmount < buyReserve * order.sellAmount

I'll look into fuzzing, but this will add more things to do to my backlog and I don't think the code will be ready by the end of today.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, I keep missing that the if condition causes a revert (and thus needs to be negated mentally)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the meantime, I added a few tests to make sure the inequality is in the right direction (that is, try to use a very large buy amount or very low sell amount and see that the order works).

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;
}
}
6 changes: 4 additions & 2 deletions test/ConstantProduct.t.sol
Original file line number Diff line number Diff line change
@@ -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 {}
96 changes: 96 additions & 0 deletions test/ConstantProduct/ConstantProductTestHarness.sol
Original file line number Diff line number Diff line change
@@ -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) {
fedgiac marked this conversation as resolved.
Show resolved Hide resolved
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) {
fedgiac marked this conversation as resolved.
Show resolved Hide resolved
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)))));
}
}
7 changes: 7 additions & 0 deletions test/ConstantProduct/VerifyTest.sol
Original file line number Diff line number Diff line change
@@ -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 {}
137 changes: 137 additions & 0 deletions test/ConstantProduct/verify/ValidateAmmMath.sol
Original file line number Diff line number Diff line change
@@ -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
fedgiac marked this conversation as resolved.
Show resolved Hide resolved
// 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should this be extracted to the setUp function?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really want to move the amounts too far away from where the expected out amount is computed, since it's a key part of the test. In principle we may also want to add tests with different amounts.

uint256 amountIn = poolIn * amountOut / (poolOut - 2 * amountOut);
fedgiac marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
Loading