From 9666d9d6e369b66822667d894951931753f786d6 Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari <55461956+yvesfracari@users.noreply.github.com> Date: Tue, 6 Aug 2024 05:22:03 -0300 Subject: [PATCH] Refactor stop-loss create just 1 discrete order (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description - Avoid multiple discrete order creations for one stop-loss order creation. # Changes - [x] Replace the `validityBucketTime` parameter of stop loss data with constant `validTo`. ## How to test 1. Run the modified local test. 2. Try to create multiple discrete orders from the same stop-loss order using the deployed contract on sepolia: `0xe6CDbC068654C506424F7747357F51d0e7caB00e` --------- Co-authored-by: José Ribeiro Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- .vscode/settings.json | 3 +- src/types/StopLoss.sol | 13 +++- test/ComposableCoW.stoploss.t.sol | 110 +++++++++++++++++------------- 3 files changed, 76 insertions(+), 50 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4f3500a..a586df0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "solidity.packageDefaultDependenciesContractsDirectory": "src", - "solidity.packageDefaultDependenciesDirectory": "lib" + "solidity.packageDefaultDependenciesDirectory": "lib", + "solidity.compileUsingRemoteVersion": "v0.8.26+commit.8a97fa7a" } \ No newline at end of file diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index d3a4d66..ad85a23 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -13,6 +13,8 @@ string constant ORACLE_INVALID_PRICE = "oracle invalid price"; string constant ORACLE_STALE_PRICE = "oracle stale price"; /// @dev The strike price has not been reached string constant STRIKE_NOT_REACHED = "strike not reached"; +/// @dev The order is not valid anymore +string constant ORDER_EXPIRED = "order expired"; /** * @title StopLoss conditional order @@ -34,7 +36,7 @@ contract StopLoss is BaseConditionalOrder { * @param receiver: The account that should receive the proceeds of the trade * @param isSellOrder: Whether this is a sell or buy order * @param isPartiallyFillable: Whether solvers are allowed to only fill a fraction of the order (useful if exact sell or buy amount isn't know at time of placement) - * @param validityBucketSeconds: How long the order will be valid. E.g. if the validityBucket is set to 15 minutes and the order is placed at 00:08, it will be valid until 00:15 + * @param validTo: The UNIX timestamp before which this order is valid * @param sellTokenPriceOracle: A chainlink-like oracle returning the current sell token price in a given numeraire * @param buyTokenPriceOracle: A chainlink-like oracle returning the current buy token price in the same numeraire * @param strike: The exchange rate (denominated in sellToken/buyToken) which triggers the StopLoss order if the oracle price falls below. Specified in base / quote with 18 decimals. @@ -49,7 +51,7 @@ contract StopLoss is BaseConditionalOrder { address receiver; bool isSellOrder; bool isPartiallyFillable; - uint32 validityBucketSeconds; + uint32 validTo; IAggregatorV3Interface sellTokenPriceOracle; IAggregatorV3Interface buyTokenPriceOracle; int256 strike; @@ -65,6 +67,11 @@ contract StopLoss is BaseConditionalOrder { Data memory data = abi.decode(staticInput, (Data)); // scope variables to avoid stack too deep error { + /// @dev Guard against expired orders + if (data.validTo < block.timestamp) { + revert IConditionalOrder.OrderNotValid(ORDER_EXPIRED); + } + (, int256 basePrice,, uint256 sellUpdatedAt,) = data.sellTokenPriceOracle.latestRoundData(); (, int256 quotePrice,, uint256 buyUpdatedAt,) = data.buyTokenPriceOracle.latestRoundData(); @@ -100,7 +107,7 @@ contract StopLoss is BaseConditionalOrder { data.receiver, data.sellAmount, data.buyAmount, - Utils.validToBucket(data.validityBucketSeconds), + data.validTo, data.appData, 0, // use zero fee for limit orders data.isSellOrder ? GPv2Order.KIND_SELL : GPv2Order.KIND_BUY, diff --git a/test/ComposableCoW.stoploss.t.sol b/test/ComposableCoW.stoploss.t.sol index 5780eb5..f2fb784 100644 --- a/test/ComposableCoW.stoploss.t.sol +++ b/test/ComposableCoW.stoploss.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.0 <0.9.0; import {IERC20, GPv2Order, IConditionalOrder, BaseComposableCoWTest} from "./ComposableCoW.base.t.sol"; import {IAggregatorV3Interface} from "../src/interfaces/IAggregatorV3Interface.sol"; -import {StopLoss, STRIKE_NOT_REACHED, ORACLE_STALE_PRICE, ORACLE_INVALID_PRICE} from "../src/types/StopLoss.sol"; +import {StopLoss, STRIKE_NOT_REACHED, ORACLE_STALE_PRICE, ORACLE_INVALID_PRICE, ORDER_EXPIRED} from "../src/types/StopLoss.sol"; contract ComposableCoWStopLossTest is BaseComposableCoWTest { IERC20 immutable SELL_TOKEN = IERC20(address(0x1)); @@ -57,7 +57,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { receiver: address(0x0), isSellOrder: false, isPartiallyFillable: false, - validityBucketSeconds: 15 minutes, + validTo: type(uint32).max, maxTimeSinceLastOracleUpdate: 15 minutes }); @@ -85,6 +85,9 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { vm.assume(sellTokenOraclePrice * int256(10 ** 18) / buyTokenOraclePrice > strike); vm.assume(currentTime > staleTime); + // guard against overflow + vm.assume(currentTime < type(uint32).max); + vm.warp(currentTime); StopLoss.Data memory data = StopLoss.Data({ @@ -99,7 +102,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { receiver: address(0x0), isSellOrder: false, isPartiallyFillable: false, - validityBucketSeconds: 15 minutes, + validTo: type(uint32).max, maxTimeSinceLastOracleUpdate: staleTime }); @@ -153,7 +156,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { receiver: address(0x0), isSellOrder: true, isPartiallyFillable: false, - validityBucketSeconds: 15 minutes, + validTo: type(uint32).max, maxTimeSinceLastOracleUpdate: 15 minutes }); @@ -164,7 +167,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { assertEq(order.sellAmount, 1 ether); assertEq(order.buyAmount, 1); assertEq(order.receiver, address(0x0)); - assertEq(order.validTo, 1687718700); + assertEq(order.validTo, type(uint32).max); assertEq(order.appData, APP_DATA); assertEq(order.feeAmount, 0); assertEq(order.kind, GPv2Order.KIND_SELL); @@ -189,7 +192,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { receiver: address(0x0), isSellOrder: true, isPartiallyFillable: false, - validityBucketSeconds: 15 minutes, + validTo: type(uint32).max, maxTimeSinceLastOracleUpdate: 15 minutes }); @@ -200,7 +203,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { assertEq(order.sellAmount, 1 ether); assertEq(order.buyAmount, 1); assertEq(order.receiver, address(0x0)); - assertEq(order.validTo, 1687718700); + assertEq(order.validTo, type(uint32).max); assertEq(order.appData, APP_DATA); assertEq(order.feeAmount, 0); assertEq(order.kind, GPv2Order.KIND_SELL); @@ -234,7 +237,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { receiver: address(0x0), isSellOrder: false, isPartiallyFillable: false, - validityBucketSeconds: 15 minutes, + validTo: type(uint32).max, maxTimeSinceLastOracleUpdate: maxTimeSinceLastOracleUpdate }); @@ -268,7 +271,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { receiver: address(0x0), isSellOrder: false, isPartiallyFillable: false, - validityBucketSeconds: 15 minutes, + validTo: type(uint32).max, maxTimeSinceLastOracleUpdate: 15 minutes }); @@ -284,15 +287,61 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); } - function test_strikePriceMet_fuzz(int256 sellTokenOraclePrice, int256 buyTokenOraclePrice, int256 strike) public { + function test_OracleRevertOnExpiredOrder_fuzz( + uint32 currentTime, + uint32 validTo + ) public { + // enforce expired order + vm.assume(currentTime > validTo); + + vm.warp(currentTime); + + StopLoss.Data memory data = StopLoss.Data({ + sellToken: mockToken(SELL_TOKEN, DEFAULT_DECIMALS), + buyToken: mockToken(BUY_TOKEN, DEFAULT_DECIMALS), + sellTokenPriceOracle: mockOracle(SELL_ORACLE, 100 ether, block.timestamp, DEFAULT_DECIMALS), + buyTokenPriceOracle: mockOracle(BUY_ORACLE, 100 ether, block.timestamp, DEFAULT_DECIMALS), + strike: 1, + sellAmount: 1 ether, + buyAmount: 1 ether, + appData: APP_DATA, + receiver: address(0x0), + isSellOrder: false, + isPartiallyFillable: false, + validTo: validTo, + maxTimeSinceLastOracleUpdate: 15 minutes + }); + + vm.expectRevert( + abi.encodeWithSelector( + IConditionalOrder.OrderNotValid.selector, + ORDER_EXPIRED + ) + ); + stopLoss.getTradeableOrder( + safe, + address(0), + bytes32(0), + abi.encode(data), + bytes("") + ); + } + + function test_strikePriceMet_fuzz( + int256 sellTokenOraclePrice, + int256 buyTokenOraclePrice, + int256 strike, + uint32 validTo + ) public { + // 25 June 2023 18:40:51 + vm.warp(1687718451); + + vm.assume(validTo >= block.timestamp); vm.assume(buyTokenOraclePrice > 0); vm.assume(sellTokenOraclePrice > 0 && sellTokenOraclePrice <= type(int256).max / 10 ** 18); vm.assume(strike > 0); vm.assume(sellTokenOraclePrice * int256(10 ** 18) / buyTokenOraclePrice <= strike); - // 25 June 2023 18:40:51 - vm.warp(1687718451); - StopLoss.Data memory data = StopLoss.Data({ sellToken: mockToken(SELL_TOKEN, DEFAULT_DECIMALS), buyToken: mockToken(BUY_TOKEN, DEFAULT_DECIMALS), @@ -305,7 +354,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { receiver: address(0x0), isSellOrder: false, isPartiallyFillable: false, - validityBucketSeconds: 15 minutes, + validTo: validTo, maxTimeSinceLastOracleUpdate: 15 minutes }); @@ -316,7 +365,7 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { assertEq(order.sellAmount, 1 ether); assertEq(order.buyAmount, 1 ether); assertEq(order.receiver, address(0x0)); - assertEq(order.validTo, 1687718700); + assertEq(order.validTo, validTo); assertEq(order.appData, APP_DATA); assertEq(order.feeAmount, 0); assertEq(order.kind, GPv2Order.KIND_BUY); @@ -324,35 +373,4 @@ contract ComposableCoWStopLossTest is BaseComposableCoWTest { assertEq(order.sellTokenBalance, GPv2Order.BALANCE_ERC20); assertEq(order.buyTokenBalance, GPv2Order.BALANCE_ERC20); } - - function test_validTo() public { - uint256 BLOCK_TIMESTAMP = 1687712399; - - StopLoss.Data memory data = StopLoss.Data({ - sellToken: mockToken(SELL_TOKEN, 18), - buyToken: mockToken(BUY_TOKEN, 18), - sellTokenPriceOracle: mockOracle(SELL_ORACLE, 99 ether, BLOCK_TIMESTAMP, DEFAULT_DECIMALS), - buyTokenPriceOracle: mockOracle(BUY_ORACLE, 100 ether, BLOCK_TIMESTAMP, DEFAULT_DECIMALS), - strike: 1e18, // required as the strike price has 18 decimals - sellAmount: 1 ether, - buyAmount: 1 ether, - appData: APP_DATA, - receiver: address(0x0), - isSellOrder: false, - isPartiallyFillable: false, - validityBucketSeconds: 1 hours, - maxTimeSinceLastOracleUpdate: 15 minutes - }); - - // 25 June 2023 18:59:59 - vm.warp(BLOCK_TIMESTAMP); - GPv2Order.Data memory order = - stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); - assertEq(order.validTo, BLOCK_TIMESTAMP + 1); // 25 June 2023 19:00:00 - - // 25 June 2023 19:00:00 - vm.warp(BLOCK_TIMESTAMP + 1); - order = stopLoss.getTradeableOrder(safe, address(0), bytes32(0), abi.encode(data), bytes("")); - assertEq(order.validTo, BLOCK_TIMESTAMP + 1 + 1 hours); // 25 June 2023 20:00:00 - } }