From 28c73aee62bae97b9c7c853bfd3ef43f3c99d62d Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari Date: Mon, 5 Aug 2024 10:16:58 -0300 Subject: [PATCH 1/4] wip: add Vesting context contract and order type with tests --- src/interfaces/IVestingEscrow.sol | 21 ++ src/types/twap/VestingTWAP.sol | 187 ++++++++++++++++++ src/types/twap/libraries/VestingContext.sol | 31 +++ .../twap/libraries/VestingContextEncoder.sol | 32 +++ test/libraries/VestingContext.t.sol | 92 +++++++++ test/libraries/VestingContextEncoder.t.sol | 102 ++++++++++ 6 files changed, 465 insertions(+) create mode 100644 src/interfaces/IVestingEscrow.sol create mode 100644 src/types/twap/VestingTWAP.sol create mode 100644 src/types/twap/libraries/VestingContext.sol create mode 100644 src/types/twap/libraries/VestingContextEncoder.sol create mode 100644 test/libraries/VestingContext.t.sol create mode 100644 test/libraries/VestingContextEncoder.t.sol diff --git a/src/interfaces/IVestingEscrow.sol b/src/interfaces/IVestingEscrow.sol new file mode 100644 index 0000000..688df31 --- /dev/null +++ b/src/interfaces/IVestingEscrow.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; +import {IERC20} from "../BaseConditionalOrder.sol"; + +/** + * @title TBD + * @dev TBD + */ +interface IVestingEscrow { + function token() external view returns (IERC20); + /* solhint-disable-next-line func-name-mixedcase*/ + function open_claim() external view returns (bool); + function unclaimed() external view returns (uint256); + function locked() external view returns (uint256); + function recipient() external view returns (address); + /* solhint-disable-next-line func-name-mixedcase*/ + function end_time() external view returns (uint256); + function startTime() external view returns (uint256); + function totalLocked() external view returns (uint256); + function cliffLength() external view returns (uint256); +} diff --git a/src/types/twap/VestingTWAP.sol b/src/types/twap/VestingTWAP.sol new file mode 100644 index 0000000..8f61643 --- /dev/null +++ b/src/types/twap/VestingTWAP.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {ComposableCoW} from "../../ComposableCoW.sol"; + +import {IConditionalOrder, IConditionalOrderGenerator, GPv2Order, BaseConditionalOrder, IERC20} from "../../BaseConditionalOrder.sol"; +import "./libraries/TWAPOrder.sol"; +import {IVestingEscrow} from "../../interfaces/IVestingEscrow.sol"; +import {VestingContextEncoder} from "./libraries/VestingContextEncoder.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +// --- error strings + +string constant NOT_WITHIN_SPAN = "not within span"; +string constant VESTING_END = "vesting end"; +string constant LOW_FIRST_BATCH = "low first batch"; +string constant INVALID_CLAIM_AMOUNT = "invalid claim amount"; + +contract VestingTWAP is BaseConditionalOrder, VestingContextEncoder { + using SafeCast for uint256; + + ComposableCoW public immutable composableCow; + + constructor(ComposableCoW _composableCow) { + composableCow = _composableCow; + } + + struct Data { + IERC20 buyToken; + address receiver; + IVestingEscrow vesting; + uint256 claimAmount; + bytes32 appDataTwap; // should containg claim hook + bytes32 appDataFirstOrder; // shouldn't contain claim hook + uint256 minPartLimit; + uint256 span; + uint256 minFirstPartLimit; + } + + function getTradeableOrder( + address owner, + address, + bytes32 ctx, + bytes calldata staticInput, + bytes calldata + ) public view override returns (GPv2Order.Data memory order) { + Data memory data = abi.decode(staticInput, (Data)); + + ( + uint256 orderCreationTime, + uint256 initialClaimAmount + ) = VestingContextEncoder.decode(composableCow.cabinet(owner, ctx)); + + uint256 endTime = data.vesting.end_time(); + + uint256 initialVestingLocked = _lockedAt( + block.timestamp, + data.vesting.startTime(), + endTime, + data.vesting.totalLocked(), + data.vesting.cliffLength() + ); + + uint256 period = _calculatePeriod( + data.claimAmount, + endTime, + orderCreationTime, + initialVestingLocked + ); + + uint256 firstBatchValidFrom = orderCreationTime + period; + + if (block.timestamp > firstBatchValidFrom) { + order = _twapOrder( + data, + firstBatchValidFrom, + period, + initialVestingLocked + ); + } else { + order = _firstOrder(data, firstBatchValidFrom, initialClaimAmount); + } + + if (!(block.timestamp <= order.validTo)) { + revert IConditionalOrder.OrderNotValid(NOT_WITHIN_SPAN); + } + } + + function _lockedAt( + uint256 time, + uint256 startTime, + uint256 endTime, + uint256 totalLocked, + uint256 cliffLength + ) internal pure returns (uint256) { + if (time <= startTime + cliffLength) { + return totalLocked; + } + if (time >= endTime) { + return 0; + } + return (totalLocked * (endTime - time)) / (endTime - startTime); + } + + function _calculatePeriod( + uint256 claimAmount, + uint256 endTime, + uint256 orderCreationTime, + uint256 initialVestingLocked + ) internal pure returns (uint256) { + return + (claimAmount * (endTime - orderCreationTime)) / + initialVestingLocked; + } + + function _calculateBatchLenght( + uint256 claimAmount, + uint256 initialVestingLocked + ) internal pure returns (uint256) { + return initialVestingLocked / claimAmount; + } + + function _twapOrder( + Data memory data, + uint256 firstBatchValidFrom, + uint256 period, + uint256 initialVestingLocked + ) internal view returns (GPv2Order.Data memory order) { + uint256 n = _calculateBatchLenght( + data.claimAmount, + initialVestingLocked + ); + + TWAPOrder.Data memory twap = TWAPOrder.Data({ + sellToken: data.vesting.token(), + buyToken: data.buyToken, + receiver: data.receiver, + partSellAmount: data.claimAmount, + minPartLimit: data.minPartLimit, + t0: firstBatchValidFrom, + n: n, + t: period, + span: data.span, + appData: data.appDataTwap + }); + + order = TWAPOrder.orderFor(twap); + } + + function _firstOrder( + Data memory data, + uint256 firstBatchValidFrom, + uint256 initialClaimAmount + ) internal view returns (GPv2Order.Data memory order) { + IERC20 sellToken = data.vesting.token(); + + if (data.buyToken == sellToken) { + revert IConditionalOrder.OrderNotValid(INVALID_SAME_TOKEN); + } + + if ( + !(address(data.buyToken) != address(0) && + address(data.buyToken) != address(0)) + ) { + revert IConditionalOrder.OrderNotValid(INVALID_TOKEN); + } + + if (firstBatchValidFrom > type(uint32).max) { + revert IConditionalOrder.OrderNotValid(INVALID_START_TIME); + } + + order = GPv2Order.Data({ + sellToken: sellToken, + buyToken: data.buyToken, + receiver: data.receiver, + sellAmount: initialClaimAmount, + buyAmount: data.minFirstPartLimit, + validTo: (firstBatchValidFrom + data.span).toUint32(), + appData: data.appDataFirstOrder, + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + } +} diff --git a/src/types/twap/libraries/VestingContext.sol b/src/types/twap/libraries/VestingContext.sol new file mode 100644 index 0000000..8dd25bd --- /dev/null +++ b/src/types/twap/libraries/VestingContext.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.0; + +import {IVestingEscrow} from "../../../interfaces/IVestingEscrow.sol"; +import {VestingContextEncoder} from "./VestingContextEncoder.sol"; + +contract VestingContext is VestingContextEncoder { + uint256 private constant _TIMESTAMP_BITS = 40; + uint256 private constant _VALUE_BITS = 104; + + uint256 private constant _TIMESTAMP_MASK = (1 << _TIMESTAMP_BITS) - 1; + uint256 private constant _VALUE_MASK = (1 << _VALUE_BITS) - 1; + + function getValue(bytes memory data) public view returns (bytes32) { + IVestingEscrow vestingContract = IVestingEscrow(bytesToAddress(data)); + return + VestingContextEncoder.encode( + block.timestamp, + vestingContract.unclaimed() + ); + } + + function bytesToAddress(bytes memory _bytes) public pure returns (address) { + require(_bytes.length == 20, "Invalid address length"); + address addr; + assembly { + addr := mload(add(_bytes, 20)) + } + return addr; + } +} diff --git a/src/types/twap/libraries/VestingContextEncoder.sol b/src/types/twap/libraries/VestingContextEncoder.sol new file mode 100644 index 0000000..96531f4 --- /dev/null +++ b/src/types/twap/libraries/VestingContextEncoder.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +string constant TIMESTAMP_TOO_LARGE = "Timestamp too large"; +string constant VALUE_TOO_LARGE = "Value too large"; + +contract VestingContextEncoder { + uint256 private constant _TIMESTAMP_BITS = 40; + uint256 private constant _VALUE_BITS = 216; // Increased to use all remaining bits + + uint256 private constant _TIMESTAMP_MASK = (1 << _TIMESTAMP_BITS) - 1; + uint256 private constant _VALUE_MASK = (1 << _VALUE_BITS) - 1; + + function encode( + uint256 timestamp, + uint256 value + ) public pure returns (bytes32) { + require(timestamp <= _TIMESTAMP_MASK, TIMESTAMP_TOO_LARGE); + require(value <= _VALUE_MASK, VALUE_TOO_LARGE); + + return bytes32((value << _TIMESTAMP_BITS) | timestamp); + } + + function decode( + bytes32 encoded + ) public pure returns (uint256 timestamp, uint256 value) { + uint256 decoded = uint256(encoded); + + timestamp = decoded & _TIMESTAMP_MASK; + value = (decoded >> _TIMESTAMP_BITS) & _VALUE_MASK; + } +} diff --git a/test/libraries/VestingContext.t.sol b/test/libraries/VestingContext.t.sol new file mode 100644 index 0000000..7fc475a --- /dev/null +++ b/test/libraries/VestingContext.t.sol @@ -0,0 +1,92 @@ +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/types/twap/libraries/VestingContext.sol"; +import "../../src/interfaces/IVestingEscrow.sol"; + +contract VestingContextTest is Test { + VestingContext public vestingContext; + IVestingEscrow public vestingEscrow; + + function setUp() public { + vestingContext = new VestingContext(); + } + + function test_BytesToAddress_concrete() public { + address testAddress = address( + 0x1234567890123456789012345678901234567890 + ); + bytes memory addressBytes = abi.encodePacked(testAddress); + address result = vestingContext.bytesToAddress(addressBytes); + assertEq(result, testAddress, "bytesToAddress conversion failed"); + } + + function test_GetValue_concrete() public { + uint256 unclaimedValue = 100 ether; + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.unclaimed.selector), + abi.encode(unclaimedValue) + ); + + bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); + bytes32 result = vestingContext.getValue(addressBytes); + + (uint256 timestampResult, uint256 unclaimedResult) = vestingContext + .decode(result); + + assertEq(timestampResult, block.timestamp, "Timestamp mismatch"); + assertEq(unclaimedValue, unclaimedResult, "Unclaimed value mismatch"); + } + + function test_RevertOnValueOverflow_concrete() public { + uint256 overflowValue = uint256(type(uint216).max) + 1; + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.unclaimed.selector), + abi.encode(overflowValue + 1) + ); + + bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); + vm.expectRevert("Value too large"); + vestingContext.getValue(addressBytes); + } + + function test_FuzzGetValue_fuzz( + uint40 timestampValue, + uint216 unclaimedValue + ) public { + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.unclaimed.selector), + abi.encode(unclaimedValue) + ); + vm.warp(timestampValue); + + bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); + bytes32 result = vestingContext.getValue(addressBytes); + + (uint256 timestampResult, uint256 unclaimedResult) = vestingContext + .decode(result); + + assertEq(timestampResult, timestampValue, "Fuxx: Timestamp mismatch"); + assertEq( + unclaimedResult, + unclaimedValue, + "Fuzz: Unclaimed value mismatch" + ); + } + + function test_RevertOnValueOverflow_fuzz(uint256 overflowValue) public { + vm.assume(overflowValue > type(uint216).max); + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.unclaimed.selector), + abi.encode(overflowValue) + ); + + bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); + vm.expectRevert("Value too large"); + vestingContext.getValue(addressBytes); + } +} diff --git a/test/libraries/VestingContextEncoder.t.sol b/test/libraries/VestingContextEncoder.t.sol new file mode 100644 index 0000000..f54cdb1 --- /dev/null +++ b/test/libraries/VestingContextEncoder.t.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/types/twap/libraries/VestingContextEncoder.sol"; +import "../../src/interfaces/IVestingEscrow.sol"; + +contract VestingContextEncoderTest is Test { + VestingContextEncoder public encoder; + + function setUp() public { + encoder = new VestingContextEncoder(); + } + + function test_Encode_concrete() public { + uint256 timestamp = 1234567890; // Example timestamp + uint256 value = 1 ether; // Example value + bytes32 encoded = encoder.encode(timestamp, value); + + assertEq( + uint256(encoded) & ((1 << 40) - 1), + timestamp, + "Timestamp encoding failed" + ); + assertEq(uint256(encoded) >> 40, value, "Value encoding failed"); + } + + function test_Decode_concrete() public { + uint256 timestamp = 1234567890; + uint256 value = 1 ether; + bytes32 encoded = encoder.encode(timestamp, value); + + (uint256 decodedTimestamp, uint256 decodedValue) = encoder.decode( + encoded + ); + + assertEq(decodedTimestamp, timestamp, "Timestamp decoding failed"); + assertEq(decodedValue, value, "Value decoding failed"); + } + + function test_EncodeMaxValue_concrete() public { + uint256 maxTimestamp = (1 << 40) - 1; + uint256 maxValue = (1 << 216) - 1; + bytes32 encoded = encoder.encode(maxTimestamp, maxValue); + + (uint256 decodedTimestamp, uint256 decodedValue) = encoder.decode( + encoded + ); + + assertEq( + decodedTimestamp, + maxTimestamp, + "Max timestamp encoding/decoding failed" + ); + assertEq(decodedValue, maxValue, "Max value encoding/decoding failed"); + } + + function test_FailOnEncodeTimestampTooLarge_concrete() public { + uint256 tooLargeTimestamp = 1 << 40; + uint256 value = 1 ether; + + vm.expectRevert("Timestamp too large"); + encoder.encode(tooLargeTimestamp, value); + } + + function test_FailOnEncodeValueTooLarge_concrete() public { + uint256 timestamp = 1234567890; + uint256 tooLargeValue = 1 << 216; + + vm.expectRevert("Value too large"); + encoder.encode(timestamp, tooLargeValue); + } + + function test_EncodeDecode_fuzz(uint40 timestamp, uint216 value) public { + bytes32 encoded = encoder.encode(timestamp, value); + (uint256 decodedTimestamp, uint256 decodedValue) = encoder.decode( + encoded + ); + assertEq( + decodedTimestamp, + timestamp, + "Fuzz: Timestamp encoding/decoding failed" + ); + assertEq(decodedValue, value, "Fuzz: Value encoding/decoding failed"); + } + + function test_EncodeTimestampTooLarge_fuzz(uint256 timestamp) public { + vm.assume(timestamp > ((1 << 40) - 1)); + uint256 value = 1 ether; + + vm.expectRevert("Timestamp too large"); + encoder.encode(timestamp, value); + } + + function test_EncodeValueTooLarge_fuzz(uint256 value) public { + vm.assume(value > ((1 << 216) - 1)); + uint256 timestamp = 1234567890; + + vm.expectRevert("Value too large"); + encoder.encode(timestamp, value); + } +} From 7585228b22397ef4aacde9d5a33d67b3c4f98aad Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari Date: Mon, 5 Aug 2024 16:58:59 -0300 Subject: [PATCH 2/4] finish order handler tests --- src/types/twap/VestingTWAP.sol | 61 ++- src/types/twap/libraries/VestingMathLib.sol | 57 +++ test/ComposableCow.vestingtwap.t.sol | 393 ++++++++++++++++++++ 3 files changed, 474 insertions(+), 37 deletions(-) create mode 100644 src/types/twap/libraries/VestingMathLib.sol create mode 100644 test/ComposableCow.vestingtwap.t.sol diff --git a/src/types/twap/VestingTWAP.sol b/src/types/twap/VestingTWAP.sol index 8f61643..bf42c1e 100644 --- a/src/types/twap/VestingTWAP.sol +++ b/src/types/twap/VestingTWAP.sol @@ -8,6 +8,7 @@ import "./libraries/TWAPOrder.sol"; import {IVestingEscrow} from "../../interfaces/IVestingEscrow.sol"; import {VestingContextEncoder} from "./libraries/VestingContextEncoder.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {VestingMathLib} from "./libraries/VestingMathLib.sol"; // --- error strings @@ -53,7 +54,11 @@ contract VestingTWAP is BaseConditionalOrder, VestingContextEncoder { uint256 endTime = data.vesting.end_time(); - uint256 initialVestingLocked = _lockedAt( + if (orderCreationTime > endTime) { + revert IConditionalOrder.OrderNotValid(VESTING_END); + } + + uint256 initialVestingLocked = VestingMathLib.lockedAt( block.timestamp, data.vesting.startTime(), endTime, @@ -61,7 +66,16 @@ contract VestingTWAP is BaseConditionalOrder, VestingContextEncoder { data.vesting.cliffLength() ); - uint256 period = _calculatePeriod( + if ( + !VestingMathLib.verifyClaimAmount( + data.claimAmount, + initialVestingLocked + ) + ) { + revert IConditionalOrder.OrderNotValid(INVALID_CLAIM_AMOUNT); + } + + uint256 period = VestingMathLib.calculatePeriod( data.claimAmount, endTime, orderCreationTime, @@ -86,47 +100,13 @@ contract VestingTWAP is BaseConditionalOrder, VestingContextEncoder { } } - function _lockedAt( - uint256 time, - uint256 startTime, - uint256 endTime, - uint256 totalLocked, - uint256 cliffLength - ) internal pure returns (uint256) { - if (time <= startTime + cliffLength) { - return totalLocked; - } - if (time >= endTime) { - return 0; - } - return (totalLocked * (endTime - time)) / (endTime - startTime); - } - - function _calculatePeriod( - uint256 claimAmount, - uint256 endTime, - uint256 orderCreationTime, - uint256 initialVestingLocked - ) internal pure returns (uint256) { - return - (claimAmount * (endTime - orderCreationTime)) / - initialVestingLocked; - } - - function _calculateBatchLenght( - uint256 claimAmount, - uint256 initialVestingLocked - ) internal pure returns (uint256) { - return initialVestingLocked / claimAmount; - } - function _twapOrder( Data memory data, uint256 firstBatchValidFrom, uint256 period, uint256 initialVestingLocked ) internal view returns (GPv2Order.Data memory order) { - uint256 n = _calculateBatchLenght( + uint256 n = VestingMathLib.calculateBatchLenght( data.claimAmount, initialVestingLocked ); @@ -169,6 +149,13 @@ contract VestingTWAP is BaseConditionalOrder, VestingContextEncoder { revert IConditionalOrder.OrderNotValid(INVALID_START_TIME); } + if (initialClaimAmount < data.claimAmount) { + revert IConditionalOrder.PollTryAtEpoch( + firstBatchValidFrom, + LOW_FIRST_BATCH + ); + } + order = GPv2Order.Data({ sellToken: sellToken, buyToken: data.buyToken, diff --git a/src/types/twap/libraries/VestingMathLib.sol b/src/types/twap/libraries/VestingMathLib.sol new file mode 100644 index 0000000..24c78b8 --- /dev/null +++ b/src/types/twap/libraries/VestingMathLib.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeMath} from "@openzeppelin/contracts/utils/math/SafeMath.sol"; + +library VestingMathLib { + using Math for uint256; + using SafeMath for uint256; + + function lockedAt( + uint256 time, + uint256 startTime, + uint256 endTime, + uint256 totalLocked, + uint256 cliffLength + ) public pure returns (uint256) { + if (time <= startTime + cliffLength) { + return totalLocked; + } + if (time >= endTime) { + return 0; + } + + return totalLocked.mulDiv(endTime.sub(time), endTime.sub(startTime)); + } + + function calculatePeriod( + uint256 claimAmount, + uint256 endTime, + uint256 orderCreationTime, + uint256 initialVestingLocked + ) public pure returns (uint256) { + if (claimAmount > initialVestingLocked) { + return 0; + } + return + claimAmount.mulDiv( + endTime.sub(orderCreationTime), + initialVestingLocked + ); + } + + function calculateBatchLenght( + uint256 claimAmount, + uint256 initialVestingLocked + ) public pure returns (uint256) { + return initialVestingLocked.div(claimAmount); + } + + function verifyClaimAmount( + uint256 claimAmount, + uint256 initialVestingLocked + ) public pure returns (bool) { + return claimAmount > 0 && claimAmount.div(2) < initialVestingLocked; + } +} diff --git a/test/ComposableCow.vestingtwap.t.sol b/test/ComposableCow.vestingtwap.t.sol new file mode 100644 index 0000000..318498c --- /dev/null +++ b/test/ComposableCow.vestingtwap.t.sol @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {Test} from "forge-std/Test.sol"; +import "../src/types/twap/VestingTWAP.sol"; +import {VestingContextEncoder} from "../src/types/twap/libraries/VestingContextEncoder.sol"; + +contract ComposableCowVestingTWAPTest is Test, VestingContextEncoder { + using SafeCast for uint256; + + ComposableCoW composableCow; + VestingTWAP vestingTWAP; + IVestingEscrow vestingEscrow; + address owner; + IERC20 sellToken; + IERC20 buyToken; + bytes32 appDataFirstOrder; + bytes32 appDataTwap; + bytes32 ctx; + + function setUp() public { + vestingTWAP = new VestingTWAP(composableCow); + sellToken = IERC20(address(0x01)); + buyToken = IERC20(address(0x02)); + owner = address(0x03); + appDataFirstOrder = bytes32(abi.encodePacked(address(0x4))); + appDataTwap = bytes32(abi.encodePacked(address(0x5))); + ctx = keccak256("twapvesting"); + } + + function mockTwapVestingData( + uint256 claimAmount, + uint256 minPartLimit, + uint256 minFirstPartLimit, + uint256 span + ) public returns (VestingTWAP.Data memory) { + return + VestingTWAP.Data({ + buyToken: buyToken, + receiver: owner, + vesting: vestingEscrow, + claimAmount: claimAmount, + appDataTwap: appDataTwap, + appDataFirstOrder: appDataFirstOrder, + minPartLimit: minPartLimit, + span: span, + minFirstPartLimit: minFirstPartLimit + }); + } + + function mockContext( + uint256 orderCreationTime, + uint256 totalVested, + uint256 startTime, + uint256 endTime + ) public { + uint256 initialLockedAt = VestingMathLib.lockedAt( + block.timestamp, + startTime, + endTime, + totalVested, + 0 + ); + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.totalLocked.selector), + abi.encode(totalVested) + ); + vm.mockCall( + address(composableCow), + abi.encodeWithSignature("cabinet(address,bytes32)", owner, ctx), + abi.encode( + VestingContextEncoder.encode( + orderCreationTime, + totalVested - initialLockedAt // assuming that the vesting contract was never claimed to simplify the tests + ) + ) + ); + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.startTime.selector), + abi.encode(startTime) + ); + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.end_time.selector), + abi.encode(endTime) + ); + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.cliffLength.selector), + abi.encode(0) + ); + vm.mockCall( + address(vestingEscrow), + abi.encodeWithSelector(IVestingEscrow.token.selector), + abi.encode(sellToken) + ); + } + + function test_VestingMathLib_lockedAt() public { + uint256 startTime = block.timestamp; + uint256 endTime = startTime + 100 days; + uint256 totalLocked = 1e18; + uint256 cliffLength = 10 days; + + assertEq( + VestingMathLib.lockedAt( + startTime, + startTime, + endTime, + totalLocked, + cliffLength + ), + totalLocked + ); + + assertEq( + VestingMathLib.lockedAt( + startTime + cliffLength, + startTime, + endTime, + totalLocked, + cliffLength + ), + totalLocked + ); + + assertEq( + VestingMathLib.lockedAt( + startTime + cliffLength + 1 days, + startTime, + endTime, + totalLocked, + cliffLength + ), + 8.9e17 + ); + + assertEq( + VestingMathLib.lockedAt( + endTime, + startTime, + endTime, + totalLocked, + cliffLength + ), + 0 + ); + + assertEq( + VestingMathLib.lockedAt( + endTime + 1, + startTime, + endTime, + totalLocked, + cliffLength + ), + 0 + ); + } + + function test_VestingMathLib_calculatePeriod() public { + uint256 initialVestingLocked = 1e18; + uint256 endTime = block.timestamp + 100 days; + uint256 orderCreationTime = block.timestamp; + + assertEq( + VestingMathLib.calculatePeriod( + 1e17, + endTime, + orderCreationTime, + initialVestingLocked + ), + 10 days + ); + assertEq( + VestingMathLib.calculatePeriod( + 1e19, + endTime, + orderCreationTime, + initialVestingLocked + ), + 0 + ); + } + + function test_VestingMathLib_calculateBatchLength() public { + uint256 initialVestingLocked = 1e18; + + assertEq( + VestingMathLib.calculateBatchLenght(1e17, initialVestingLocked), + 10 + ); + assertEq( + VestingMathLib.calculateBatchLenght(1e17 + 1, initialVestingLocked), + 9 + ); + assertEq( + VestingMathLib.calculateBatchLenght(1e19, initialVestingLocked), + 0 + ); + } + + function test_getTradeableOrder_concrete() public { + vm.warp(1722878593); + uint256 totalVested = 2e20; + uint256 minFirstPartLimit = 1e7; + uint256 span = 0; + uint256 claimAmount = 1e19; + uint256 minPartLimit = 1e6; + VestingTWAP.Data memory data = mockTwapVestingData( + claimAmount, + minPartLimit, + minFirstPartLimit, + span + ); + bytes memory staticInput = abi.encode(data); + + mockContext( + block.timestamp, + totalVested, + block.timestamp - 10 days, + block.timestamp + 10 days + ); + + // check first order + GPv2Order.Data memory order = vestingTWAP.getTradeableOrder( + owner, + address(0), + ctx, + staticInput, + "" + ); + + assertEq(address(order.buyToken), address(buyToken)); + assertEq(address(order.sellToken), address(sellToken)); + assertEq(order.receiver, data.receiver); + assertEq(order.sellAmount, 1e20); + assertEq(order.buyAmount, data.minFirstPartLimit); + assertGt(order.validTo, block.timestamp); + assertEq(order.appData, data.appDataFirstOrder); + assertEq(uint256(order.feeAmount), 0); + assertEq(uint256(order.kind), uint256(GPv2Order.KIND_SELL)); + assertFalse(order.partiallyFillable); + assertEq( + uint256(order.sellTokenBalance), + uint256(GPv2Order.BALANCE_ERC20) + ); + assertEq( + uint256(order.buyTokenBalance), + uint256(GPv2Order.BALANCE_ERC20) + ); + + // test first twap order + vm.warp(block.timestamp + 2 days); + + // check first order + order = vestingTWAP.getTradeableOrder( + owner, + address(0), + ctx, + staticInput, + "" + ); + + assertEq(address(order.buyToken), address(buyToken)); + assertEq(address(order.sellToken), address(sellToken)); + assertEq(order.receiver, data.receiver); + assertEq(order.sellAmount, claimAmount); + assertEq(order.buyAmount, data.minPartLimit); + assertGt(order.validTo, block.timestamp); + assertEq(order.appData, data.appDataTwap); + assertEq(uint256(order.feeAmount), 0); + assertEq(uint256(order.kind), uint256(GPv2Order.KIND_SELL)); + assertFalse(order.partiallyFillable); + assertEq( + uint256(order.sellTokenBalance), + uint256(GPv2Order.BALANCE_ERC20) + ); + assertEq( + uint256(order.buyTokenBalance), + uint256(GPv2Order.BALANCE_ERC20) + ); + } + + function test_RevertOnVestingEnd_fuzz( + uint256 orderCreationTime, + uint256 vestingStartTime, + uint256 span, + uint256 vestingEndTime + ) public { + vm.assume(orderCreationTime > vestingEndTime); + + // guard against overflow + vm.assume(orderCreationTime < type(uint32).max); + vm.assume(vestingStartTime < type(uint32).max); + vm.assume(vestingEndTime < type(uint32).max); + vm.assume(span < type(uint32).max); + + VestingTWAP.Data memory data = mockTwapVestingData( + 1e16, + 1e16, + 1e16, + span + ); + bytes memory staticInput = abi.encode(data); + + mockContext(orderCreationTime, 1e20, vestingStartTime, vestingEndTime); + + vm.warp(vestingEndTime + span + 1); + + vm.expectRevert( + abi.encodeWithSelector( + IConditionalOrder.OrderNotValid.selector, + VESTING_END + ) + ); + + vestingTWAP.getTradeableOrder(owner, address(0), ctx, staticInput, ""); + } + + function test_RevertLowFirstBatch_concrete() public { + vm.warp(1722878593); + + uint256 totalVested = 1e18; + uint256 claimAmount = 1e17; + uint256 vestingPeriod = 10 days; + VestingTWAP.Data memory data = mockTwapVestingData( + claimAmount, + 0, + 0, + 0 + ); + + bytes memory staticInput = abi.encode(data); + + uint256 twapPeriod = VestingMathLib.calculatePeriod( + claimAmount, + block.timestamp + vestingPeriod, + block.timestamp, + totalVested + ); + + mockContext( + block.timestamp, + totalVested, + block.timestamp, + block.timestamp + vestingPeriod + ); + + vm.expectRevert( + abi.encodeWithSelector( + IConditionalOrder.PollTryAtEpoch.selector, + twapPeriod + block.timestamp, + LOW_FIRST_BATCH + ) + ); + vestingTWAP.getTradeableOrder(owner, address(0), ctx, staticInput, ""); + } + + function test_RevertInvalidClaimAmount_fuzz( + uint256 claimAmount, + uint256 totalVested + ) public { + vm.warp(1722878593); + + vm.assume(claimAmount < 1e40); + vm.assume(totalVested < 1e40); + + vm.assume(claimAmount * 2 > totalVested || claimAmount == 0); + + uint256 startTime = block.timestamp - 10 days; + VestingTWAP.Data memory data = mockTwapVestingData( + claimAmount, + 0, + 0, + 0 + ); + + bytes memory staticInput = abi.encode(data); + + mockContext(block.timestamp, totalVested, startTime, block.timestamp); + + vm.expectRevert( + abi.encodeWithSelector( + IConditionalOrder.OrderNotValid.selector, + INVALID_CLAIM_AMOUNT + ) + ); + vestingTWAP.getTradeableOrder(owner, address(0), ctx, staticInput, ""); + } +} From 12d732dddb1e4295a9ba8205ce31f2eda0dc2e42 Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari Date: Tue, 6 Aug 2024 09:47:33 -0300 Subject: [PATCH 3/4] change folder on vesting context factory contract --- src/types/twap/VestingTWAP.sol | 2 +- .../twap/libraries/VestingContextEncoder.sol | 2 +- .../VestingContextFactory.sol} | 6 +-- test/ComposableCow.vestingtwap.t.sol | 2 +- test/libraries/VestingContextEncoder.t.sol | 37 +++++++------------ .../VestingContextFactory.t.sol} | 30 ++++++++------- 6 files changed, 37 insertions(+), 42 deletions(-) rename src/{types/twap/libraries/VestingContext.sol => value_factories/VestingContextFactory.sol} (82%) rename test/{libraries/VestingContext.t.sol => values_factories/VestingContextFactory.t.sol} (75%) diff --git a/src/types/twap/VestingTWAP.sol b/src/types/twap/VestingTWAP.sol index bf42c1e..7ef1fa2 100644 --- a/src/types/twap/VestingTWAP.sol +++ b/src/types/twap/VestingTWAP.sol @@ -17,7 +17,7 @@ string constant VESTING_END = "vesting end"; string constant LOW_FIRST_BATCH = "low first batch"; string constant INVALID_CLAIM_AMOUNT = "invalid claim amount"; -contract VestingTWAP is BaseConditionalOrder, VestingContextEncoder { +contract VestingTWAP is BaseConditionalOrder { using SafeCast for uint256; ComposableCoW public immutable composableCow; diff --git a/src/types/twap/libraries/VestingContextEncoder.sol b/src/types/twap/libraries/VestingContextEncoder.sol index 96531f4..ea77b05 100644 --- a/src/types/twap/libraries/VestingContextEncoder.sol +++ b/src/types/twap/libraries/VestingContextEncoder.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; string constant TIMESTAMP_TOO_LARGE = "Timestamp too large"; string constant VALUE_TOO_LARGE = "Value too large"; -contract VestingContextEncoder { +library VestingContextEncoder { uint256 private constant _TIMESTAMP_BITS = 40; uint256 private constant _VALUE_BITS = 216; // Increased to use all remaining bits diff --git a/src/types/twap/libraries/VestingContext.sol b/src/value_factories/VestingContextFactory.sol similarity index 82% rename from src/types/twap/libraries/VestingContext.sol rename to src/value_factories/VestingContextFactory.sol index 8dd25bd..4d3764c 100644 --- a/src/types/twap/libraries/VestingContext.sol +++ b/src/value_factories/VestingContextFactory.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.0; -import {IVestingEscrow} from "../../../interfaces/IVestingEscrow.sol"; -import {VestingContextEncoder} from "./VestingContextEncoder.sol"; +import {IVestingEscrow} from "../interfaces/IVestingEscrow.sol"; +import {VestingContextEncoder} from "../types/twap/libraries/VestingContextEncoder.sol"; -contract VestingContext is VestingContextEncoder { +contract VestingContextFactory { uint256 private constant _TIMESTAMP_BITS = 40; uint256 private constant _VALUE_BITS = 104; diff --git a/test/ComposableCow.vestingtwap.t.sol b/test/ComposableCow.vestingtwap.t.sol index 318498c..e279d21 100644 --- a/test/ComposableCow.vestingtwap.t.sol +++ b/test/ComposableCow.vestingtwap.t.sol @@ -5,7 +5,7 @@ import {Test} from "forge-std/Test.sol"; import "../src/types/twap/VestingTWAP.sol"; import {VestingContextEncoder} from "../src/types/twap/libraries/VestingContextEncoder.sol"; -contract ComposableCowVestingTWAPTest is Test, VestingContextEncoder { +contract ComposableCowVestingTWAPTest is Test { using SafeCast for uint256; ComposableCoW composableCow; diff --git a/test/libraries/VestingContextEncoder.t.sol b/test/libraries/VestingContextEncoder.t.sol index f54cdb1..fe4b5a4 100644 --- a/test/libraries/VestingContextEncoder.t.sol +++ b/test/libraries/VestingContextEncoder.t.sol @@ -6,16 +6,10 @@ import "../../src/types/twap/libraries/VestingContextEncoder.sol"; import "../../src/interfaces/IVestingEscrow.sol"; contract VestingContextEncoderTest is Test { - VestingContextEncoder public encoder; - - function setUp() public { - encoder = new VestingContextEncoder(); - } - function test_Encode_concrete() public { uint256 timestamp = 1234567890; // Example timestamp uint256 value = 1 ether; // Example value - bytes32 encoded = encoder.encode(timestamp, value); + bytes32 encoded = VestingContextEncoder.encode(timestamp, value); assertEq( uint256(encoded) & ((1 << 40) - 1), @@ -28,11 +22,10 @@ contract VestingContextEncoderTest is Test { function test_Decode_concrete() public { uint256 timestamp = 1234567890; uint256 value = 1 ether; - bytes32 encoded = encoder.encode(timestamp, value); + bytes32 encoded = VestingContextEncoder.encode(timestamp, value); - (uint256 decodedTimestamp, uint256 decodedValue) = encoder.decode( - encoded - ); + (uint256 decodedTimestamp, uint256 decodedValue) = VestingContextEncoder + .decode(encoded); assertEq(decodedTimestamp, timestamp, "Timestamp decoding failed"); assertEq(decodedValue, value, "Value decoding failed"); @@ -41,11 +34,10 @@ contract VestingContextEncoderTest is Test { function test_EncodeMaxValue_concrete() public { uint256 maxTimestamp = (1 << 40) - 1; uint256 maxValue = (1 << 216) - 1; - bytes32 encoded = encoder.encode(maxTimestamp, maxValue); + bytes32 encoded = VestingContextEncoder.encode(maxTimestamp, maxValue); - (uint256 decodedTimestamp, uint256 decodedValue) = encoder.decode( - encoded - ); + (uint256 decodedTimestamp, uint256 decodedValue) = VestingContextEncoder + .decode(encoded); assertEq( decodedTimestamp, @@ -60,7 +52,7 @@ contract VestingContextEncoderTest is Test { uint256 value = 1 ether; vm.expectRevert("Timestamp too large"); - encoder.encode(tooLargeTimestamp, value); + VestingContextEncoder.encode(tooLargeTimestamp, value); } function test_FailOnEncodeValueTooLarge_concrete() public { @@ -68,14 +60,13 @@ contract VestingContextEncoderTest is Test { uint256 tooLargeValue = 1 << 216; vm.expectRevert("Value too large"); - encoder.encode(timestamp, tooLargeValue); + VestingContextEncoder.encode(timestamp, tooLargeValue); } function test_EncodeDecode_fuzz(uint40 timestamp, uint216 value) public { - bytes32 encoded = encoder.encode(timestamp, value); - (uint256 decodedTimestamp, uint256 decodedValue) = encoder.decode( - encoded - ); + bytes32 encoded = VestingContextEncoder.encode(timestamp, value); + (uint256 decodedTimestamp, uint256 decodedValue) = VestingContextEncoder + .decode(encoded); assertEq( decodedTimestamp, timestamp, @@ -89,7 +80,7 @@ contract VestingContextEncoderTest is Test { uint256 value = 1 ether; vm.expectRevert("Timestamp too large"); - encoder.encode(timestamp, value); + VestingContextEncoder.encode(timestamp, value); } function test_EncodeValueTooLarge_fuzz(uint256 value) public { @@ -97,6 +88,6 @@ contract VestingContextEncoderTest is Test { uint256 timestamp = 1234567890; vm.expectRevert("Value too large"); - encoder.encode(timestamp, value); + VestingContextEncoder.encode(timestamp, value); } } diff --git a/test/libraries/VestingContext.t.sol b/test/values_factories/VestingContextFactory.t.sol similarity index 75% rename from test/libraries/VestingContext.t.sol rename to test/values_factories/VestingContextFactory.t.sol index 7fc475a..96ce87a 100644 --- a/test/libraries/VestingContext.t.sol +++ b/test/values_factories/VestingContextFactory.t.sol @@ -1,15 +1,15 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../../src/types/twap/libraries/VestingContext.sol"; +import "../../src/value_factories/VestingContextFactory.sol"; import "../../src/interfaces/IVestingEscrow.sol"; -contract VestingContextTest is Test { - VestingContext public vestingContext; +contract VestingContexFactoryTest is Test { + VestingContextFactory public vestingContextFactory; IVestingEscrow public vestingEscrow; function setUp() public { - vestingContext = new VestingContext(); + vestingContextFactory = new VestingContextFactory(); } function test_BytesToAddress_concrete() public { @@ -17,7 +17,7 @@ contract VestingContextTest is Test { 0x1234567890123456789012345678901234567890 ); bytes memory addressBytes = abi.encodePacked(testAddress); - address result = vestingContext.bytesToAddress(addressBytes); + address result = vestingContextFactory.bytesToAddress(addressBytes); assertEq(result, testAddress, "bytesToAddress conversion failed"); } @@ -30,10 +30,12 @@ contract VestingContextTest is Test { ); bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); - bytes32 result = vestingContext.getValue(addressBytes); + bytes32 result = vestingContextFactory.getValue(addressBytes); - (uint256 timestampResult, uint256 unclaimedResult) = vestingContext - .decode(result); + ( + uint256 timestampResult, + uint256 unclaimedResult + ) = VestingContextEncoder.decode(result); assertEq(timestampResult, block.timestamp, "Timestamp mismatch"); assertEq(unclaimedValue, unclaimedResult, "Unclaimed value mismatch"); @@ -49,7 +51,7 @@ contract VestingContextTest is Test { bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); vm.expectRevert("Value too large"); - vestingContext.getValue(addressBytes); + vestingContextFactory.getValue(addressBytes); } function test_FuzzGetValue_fuzz( @@ -64,10 +66,12 @@ contract VestingContextTest is Test { vm.warp(timestampValue); bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); - bytes32 result = vestingContext.getValue(addressBytes); + bytes32 result = vestingContextFactory.getValue(addressBytes); - (uint256 timestampResult, uint256 unclaimedResult) = vestingContext - .decode(result); + ( + uint256 timestampResult, + uint256 unclaimedResult + ) = VestingContextEncoder.decode(result); assertEq(timestampResult, timestampValue, "Fuxx: Timestamp mismatch"); assertEq( @@ -87,6 +91,6 @@ contract VestingContextTest is Test { bytes memory addressBytes = abi.encodePacked(address(vestingEscrow)); vm.expectRevert("Value too large"); - vestingContext.getValue(addressBytes); + vestingContextFactory.getValue(addressBytes); } } From 82ee388be0ca19cb91f4c0eb47467add54b8afea Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari Date: Tue, 6 Aug 2024 17:19:20 -0300 Subject: [PATCH 4/4] add deployment script and fix contract errors --- script/deploy_VestingTwap.s.sol | 22 ++++++++++++++++++++++ src/interfaces/IVestingEscrow.sol | 6 ++---- src/types/twap/VestingTWAP.sol | 4 ++-- test/ComposableCow.vestingtwap.t.sol | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 script/deploy_VestingTwap.s.sol diff --git a/script/deploy_VestingTwap.s.sol b/script/deploy_VestingTwap.s.sol new file mode 100644 index 0000000..3b8f671 --- /dev/null +++ b/script/deploy_VestingTwap.s.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {Script} from "forge-std/Script.sol"; + +import {ComposableCoW} from "../src/ComposableCoW.sol"; + +import {VestingTWAP} from "../src/types/twap/VestingTWAP.sol"; +import {VestingContextFactory} from "../src/value_factories/VestingContextFactory.sol"; + +contract DeployVestingTwap is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + address composableCow = vm.envAddress("COMPOSABLE_COW"); + vm.startBroadcast(deployerPrivateKey); + + new VestingTWAP(ComposableCoW(composableCow)); + new VestingContextFactory(); + + vm.stopBroadcast(); + } +} diff --git a/src/interfaces/IVestingEscrow.sol b/src/interfaces/IVestingEscrow.sol index 688df31..3317c55 100644 --- a/src/interfaces/IVestingEscrow.sol +++ b/src/interfaces/IVestingEscrow.sol @@ -8,13 +8,11 @@ import {IERC20} from "../BaseConditionalOrder.sol"; */ interface IVestingEscrow { function token() external view returns (IERC20); - /* solhint-disable-next-line func-name-mixedcase*/ - function open_claim() external view returns (bool); + function openClaim() external view returns (bool); function unclaimed() external view returns (uint256); function locked() external view returns (uint256); function recipient() external view returns (address); - /* solhint-disable-next-line func-name-mixedcase*/ - function end_time() external view returns (uint256); + function endTime() external view returns (uint256); function startTime() external view returns (uint256); function totalLocked() external view returns (uint256); function cliffLength() external view returns (uint256); diff --git a/src/types/twap/VestingTWAP.sol b/src/types/twap/VestingTWAP.sol index 7ef1fa2..f65ff55 100644 --- a/src/types/twap/VestingTWAP.sol +++ b/src/types/twap/VestingTWAP.sol @@ -52,14 +52,14 @@ contract VestingTWAP is BaseConditionalOrder { uint256 initialClaimAmount ) = VestingContextEncoder.decode(composableCow.cabinet(owner, ctx)); - uint256 endTime = data.vesting.end_time(); + uint256 endTime = data.vesting.endTime(); if (orderCreationTime > endTime) { revert IConditionalOrder.OrderNotValid(VESTING_END); } uint256 initialVestingLocked = VestingMathLib.lockedAt( - block.timestamp, + orderCreationTime, data.vesting.startTime(), endTime, data.vesting.totalLocked(), diff --git a/test/ComposableCow.vestingtwap.t.sol b/test/ComposableCow.vestingtwap.t.sol index e279d21..cdcf093 100644 --- a/test/ComposableCow.vestingtwap.t.sol +++ b/test/ComposableCow.vestingtwap.t.sol @@ -83,7 +83,7 @@ contract ComposableCowVestingTWAPTest is Test { ); vm.mockCall( address(vestingEscrow), - abi.encodeWithSelector(IVestingEscrow.end_time.selector), + abi.encodeWithSelector(IVestingEscrow.endTime.selector), abi.encode(endTime) ); vm.mockCall(