From ef2856c799fd0b42f0b3f2826ce0b061c6618848 Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 17 Dec 2025 11:24:20 -0800 Subject: [PATCH 01/15] feat: grantee recipient preference and controller overrides --- README.md | 2 +- remappings.txt | 2 + src/BaseAllocation.sol | 46 +++- src/MetaVesTController.sol | 254 +++++++++++++++-------- src/MetaVesTFactory.sol | 22 +- src/RestrictedTokenAllocation.sol | 11 +- src/RestrictedTokenFactory.sol | 5 +- src/TokenOptionAllocation.sol | 14 +- src/TokenOptionFactory.sol | 5 +- src/VestingAllocation.sol | 30 ++- src/VestingAllocationFactory.sol | 5 +- src/interfaces/IAllocationFactory.sol | 3 +- src/interfaces/IBaseAllocation.sol | 2 +- src/interfaces/IMetaVesTController.sol | 6 + src/interfaces/IPriceAllocation.sol | 2 +- src/interfaces/IRestrictedTokenAward.sol | 2 +- test/MetaVesTFactory.t.sol | 6 +- test/amendement.t.sol | 1 + test/controller.t.sol | 1 + test/mocks/MockCondition.sol | 2 +- 20 files changed, 290 insertions(+), 131 deletions(-) create mode 100644 remappings.txt create mode 100644 src/interfaces/IMetaVesTController.sol diff --git a/README.md b/README.md index b773038..7f1ab80 100644 --- a/README.md +++ b/README.md @@ -177,5 +177,5 @@ To set up the project locally, follow these steps: 3. **Compile Contracts** ```base - forge build --optimize --optimizer-runs 200 --use solc:0.8.20 + forge build --optimize --optimizer-runs 200 --use solc:0.8.28 --sizes ``` diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..a6a22a9 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +openzeppelin-contracts=lib/openzeppelin-contracts/contracts +openzeppelin-contracts-upgradeable=lib/openzeppelin-contracts-upgradeable/contracts diff --git a/src/BaseAllocation.sol b/src/BaseAllocation.sol index 161ca4a..45dff92 100644 --- a/src/BaseAllocation.sol +++ b/src/BaseAllocation.sol @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; + +import {IMetaVesTController} from "./interfaces/IMetaVesTController.sol"; /// @notice interface to a MetaLeX condition contract /// @dev see https://github.com/MetaLex-Tech/BORG-CORE/tree/main/src/libs/conditions @@ -134,9 +136,10 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ event MetaVesT_TransferabilityUpdated(address indexed grantee, bool isTransferable); event MetaVest_TransferRightsPending(address indexed grantee, address indexed pendingGrantee); event MetaVesT_TransferredRights(address indexed grantee, address transferee); + event MetaVesT_DesiredRecipientUpdated(address indexed grantee, address newDesiredRecipient); event MetaVesT_UnlockRateUpdated(address indexed grantee, uint208 unlockRate); event MetaVesT_VestingRateUpdated(address indexed grantee, uint208 vestingRate); - event MetaVesT_Withdrawn(address indexed grantee, address indexed tokenAddress, uint256 amount); + event MetaVesT_Withdrawn(address indexed grantee, address indexed recipient, address indexed tokenAddress, uint256 amount); event MetaVesT_PriceUpdated(address indexed grantee, uint256 exercisePrice); event MetaVesT_RepurchaseAndWithdrawal(address indexed grantee, address indexed tokenAddress, uint256 withdrawalAmount, uint256 repurchaseAmount); event MetaVesT_Terminated(address indexed grantee, uint256 tokensRecovered); @@ -158,6 +161,7 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ address public grantee; // grantee of the tokens address public pendingGrantee; // address of the pending grantee + address public desiredRecipient; // recipient of the tokens (if not overridden by the controller) bool transferable; // whether grantee can transfer their MetaVesT in whole Milestone[] public milestones; // array of Milestone structs Allocation public allocation; // struct containing vesting and unlocking details @@ -171,12 +175,15 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ /// @notice BaseAllocation constructor /// @param _grantee: address of the grantee, cannot be a zero address /// @param _controller: address of the MetaVesTController contract - constructor(address _grantee, address _controller) { + constructor(address _grantee, address _desiredRecipient, address _controller) { // Controller can be 0 for an immuatable version, but grantee cannot if (_grantee == address(0)) revert MetaVesT_ZeroAddress(); + grantee = _grantee; controller = _controller; govType = GovType.vested; + + _updateDesiredRecipient(_desiredRecipient); } function getVestingType() external view virtual returns (uint256); @@ -300,6 +307,18 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ pendingGrantee = address(0); } + /// @notice update the recipient to a new address + /// @dev onlyGrantee -- must be called by the grantee + /// @param _newDesiredRecipient - the address of the new recipient preference + function updateDesiredRecipient(address _newDesiredRecipient) external onlyGrantee { + _updateDesiredRecipient(_newDesiredRecipient); + } + + function _updateDesiredRecipient(address _newDesiredRecipient) internal { + emit MetaVesT_DesiredRecipientUpdated(grantee, _newDesiredRecipient); + desiredRecipient = _newDesiredRecipient; + } + /// @notice withdraws tokens from the VestingAllocation /// @dev onlyGrantee -- must be called by the grantee /// @param _amount - the amount of tokens to withdraw @@ -307,8 +326,25 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ if (_amount == 0) revert MetaVesT_ZeroAmount(); if (_amount > getAmountWithdrawable() || _amount > IERC20M(allocation.tokenContract).balanceOf(address(this))) revert MetaVesT_MoreThanAvailable(); tokensWithdrawn += _amount; - safeTransfer(allocation.tokenContract, msg.sender, _amount); - emit MetaVesT_Withdrawn(msg.sender, allocation.tokenContract, _amount); + address recipient = getRecipient(); + safeTransfer(allocation.tokenContract, recipient, _amount); + emit MetaVesT_Withdrawn(grantee, recipient, allocation.tokenContract, _amount); + } + + /// @notice Determine the recipient address based on grantee settings and controller overrides. + /// Priority: + /// - `controller.recipientOverride` + /// - `desiredRecipient` + /// - `grantee` + /// @dev `address(0)` means null/no preference + function getRecipient() public view returns (address) { + if (controller != address(0) && IMetaVesTController(controller).recipientOverride() != address(0)) { + return IMetaVesTController(controller).recipientOverride(); + } else if (desiredRecipient != address(0)) { + return desiredRecipient; + } else { + return grantee; + } } /// @notice gets the details of the vest diff --git a/src/MetaVesTController.sol b/src/MetaVesTController.sol index 77d5f6f..fac76fa 100644 --- a/src/MetaVesTController.sol +++ b/src/MetaVesTController.sol @@ -6,7 +6,7 @@ ************************************* */ -pragma solidity 0.8.20; +pragma solidity ^0.8.20; //import "./MetaVesT.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -36,6 +36,7 @@ contract metavestController is SafeTransferLib { address public authority; address public dao; + address public recipientOverride; // TODO: unaudited beta feature address public vestingFactory; address public tokenOptionFactory; address public restrictedTokenFactory; @@ -90,6 +91,7 @@ contract metavestController is SafeTransferLib { event MetaVesTController_SetRemoved(string indexed set); event MetaVesTController_AddressAddedToSet(string set, address indexed grantee); event MetaVesTController_AddressRemovedFromSet(string set, address indexed grantee); + event MetaVesTController_RecipientOverrideUpdated(address indexed newRecipientOverride); /// /// ERRORS @@ -173,13 +175,23 @@ contract metavestController is SafeTransferLib { /// @param _authority address of the authority who can call the functions in this contract and update each MetaVesT in '_metavest', such as a BORG /// @param _dao DAO governance contract address which exercises control over ability of 'authority' to call certain functions via imposing /// conditions through 'updateFunctionCondition'. - constructor(address _authority, address _dao, address _vestingFactory, address _tokenOptionFactory, address _restrictedTokenFactory) { + /// @param _recipientOverride override individual metavest vault's recipient addresses so grantee cannot specify their own recipient addresses + constructor( + address _authority, + address _dao, + address _recipientOverride, + address _vestingFactory, + address _tokenOptionFactory, + address _restrictedTokenFactory + ) { if (_authority == address(0)) revert MetaVesTController_ZeroAddress(); authority = _authority; vestingFactory = _vestingFactory; tokenOptionFactory = _tokenOptionFactory; restrictedTokenFactory = _restrictedTokenFactory; dao = _dao; + + _updateRecipientOverride(_recipientOverride); } /// @notice for a grantee to consent to an update to one of their metavestDetails by 'authority' corresponding to the applicable function in this controller @@ -222,20 +234,54 @@ contract metavestController is SafeTransferLib { emit MetaVesTController_ConditionUpdated(_condition, _functionSig); } - function createMetavest(metavestType _type, address _grantee, BaseAllocation.Allocation calldata _allocation, BaseAllocation.Milestone[] calldata _milestones, uint256 _exercisePrice, address _paymentToken, uint256 _shortStopDuration, uint256 _longStopDate) external onlyAuthority conditionCheck returns (address) - { + /// @notice For backward compatibility (default recipient) + /// @dev Should always call the internal createMetavest() because the latter performs the necessary ACL + function createMetavest( + metavestType _type, + address _grantee, + BaseAllocation.Allocation calldata _allocation, + BaseAllocation.Milestone[] calldata _milestones, + uint256 _exercisePrice, + address _paymentToken, + uint256 _shortStopDuration, + uint256 _longStopDate + ) external returns (address) { + return createMetavest( + _type, + _grantee, + address(0), // no preference + _allocation, + _milestones, + _exercisePrice, + _paymentToken, + _shortStopDuration, + _longStopDate + ); + } + + function createMetavest( + metavestType _type, + address _grantee, + address _desiredRecipient, + BaseAllocation.Allocation calldata _allocation, + BaseAllocation.Milestone[] calldata _milestones, + uint256 _exercisePrice, + address _paymentToken, + uint256 _shortStopDuration, + uint256 _longStopDate + ) public onlyAuthority conditionCheck returns (address) { address newMetavest; if(_type == metavestType.Vesting) { - newMetavest = createVestingAllocation(_grantee, _allocation, _milestones); + newMetavest = createVestingAllocation(_grantee, _desiredRecipient, _allocation, _milestones); } else if(_type == metavestType.TokenOption) { - newMetavest = createTokenOptionAllocation(_grantee, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); + newMetavest = createTokenOptionAllocation(_grantee, _desiredRecipient, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); } else if(_type == metavestType.RestrictedTokenAward) { - newMetavest = createRestrictedTokenAward(_grantee, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); + newMetavest = createRestrictedTokenAward(_grantee, _desiredRecipient, _exercisePrice, _paymentToken, _shortStopDuration, _allocation, _milestones); } else { @@ -243,10 +289,10 @@ contract metavestController is SafeTransferLib { } return newMetavest; } - function validateInputParameters( address _grantee, + address _desiredRecipient, address _paymentToken, uint256 _exercisePrice, VestingAllocation.Allocation calldata _allocation @@ -283,50 +329,58 @@ contract metavestController is SafeTransferLib { ) revert MetaVesTController_AmountNotApprovedForTransferFrom(); } - function createAndInitializeTokenOptionAllocation( - address _grantee, - address _paymentToken, - uint256 _exercisePrice, - uint256 _shortStopDuration, - VestingAllocation.Allocation calldata _allocation, - VestingAllocation.Milestone[] calldata _milestones - ) internal returns (address) { - return IAllocationFactory(tokenOptionFactory).createAllocation( - IAllocationFactory.AllocationType.TokenOption, - _grantee, - address(this), - _allocation, - _milestones, - _paymentToken, - _exercisePrice, - _shortStopDuration - ); - } - - function createAndInitializeRestrictedTokenAward( - address _grantee, - address _paymentToken, - uint256 _repurchasePrice, - uint256 _shortStopDuration, - VestingAllocation.Allocation calldata _allocation, - VestingAllocation.Milestone[] calldata _milestones - ) internal returns (address) { - return IAllocationFactory(restrictedTokenFactory).createAllocation( - IAllocationFactory.AllocationType.RestrictedToken, - _grantee, - address(this), - _allocation, - _milestones, - _paymentToken, - _repurchasePrice, - _shortStopDuration - ); - } + function createAndInitializeTokenOptionAllocation( + address _grantee, + address _desiredRecipient, + address _paymentToken, + uint256 _exercisePrice, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal returns (address) { + return IAllocationFactory(tokenOptionFactory).createAllocation( + IAllocationFactory.AllocationType.TokenOption, + _grantee, + _desiredRecipient, + address(this), + _allocation, + _milestones, + _paymentToken, + _exercisePrice, + _shortStopDuration + ); + } + function createAndInitializeRestrictedTokenAward( + address _grantee, + address _desiredRecipient, + address _paymentToken, + uint256 _repurchasePrice, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal returns (address) { + return IAllocationFactory(restrictedTokenFactory).createAllocation( + IAllocationFactory.AllocationType.RestrictedToken, + _grantee, + _desiredRecipient, + address(this), + _allocation, + _milestones, + _paymentToken, + _repurchasePrice, + _shortStopDuration + ); + } - function createVestingAllocation(address _grantee, VestingAllocation.Allocation calldata _allocation, VestingAllocation.Milestone[] calldata _milestones) internal returns (address){ + function createVestingAllocation( + address _grantee, + address _desiredRecipient, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal returns (address){ //hard code values not to trigger the failure for the 2 parameters that don't matter for this type of allocation - validateInputParameters(_grantee, address(this), 1, _allocation); + validateInputParameters(_grantee, _desiredRecipient, address(this), 1, _allocation); validateAllocation(_allocation); uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); @@ -337,6 +391,7 @@ contract metavestController is SafeTransferLib { address vestingAllocation = IAllocationFactory(vestingFactory).createAllocation( IAllocationFactory.AllocationType.Vesting, _grantee, + _desiredRecipient, address(this), _allocation, _milestones, @@ -349,50 +404,70 @@ contract metavestController is SafeTransferLib { return vestingAllocation; } - function createTokenOptionAllocation(address _grantee, uint256 _exercisePrice, address _paymentToken, uint256 _shortStopDuration, VestingAllocation.Allocation calldata _allocation, VestingAllocation.Milestone[] calldata _milestones) internal conditionCheck returns (address) { - - validateInputParameters(_grantee, _paymentToken, _exercisePrice, _allocation); + function createTokenOptionAllocation( + address _grantee, + address _desiredRecipient, + uint256 _exercisePrice, + address _paymentToken, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal conditionCheck returns (address) { + + validateInputParameters(_grantee, _desiredRecipient, _paymentToken, _exercisePrice, _allocation); validateAllocation(_allocation); uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); uint256 _total = _allocation.tokenStreamTotal + _milestoneTotal; if (_total == 0) revert MetaVesTController_ZeroAmount(); validateTokenApprovalAndBalance(_allocation.tokenContract, _total); - + + // This is for fixing stack-too-deep errors address tokenOptionAllocation = createAndInitializeTokenOptionAllocation( - _grantee, - _paymentToken, - _exercisePrice, - _shortStopDuration, - _allocation, - _milestones - ); + _grantee, + _desiredRecipient, + _paymentToken, + _exercisePrice, + _shortStopDuration, + _allocation, + _milestones + ); - safeTransferFrom(_allocation.tokenContract, authority, tokenOptionAllocation, _total); - return tokenOptionAllocation; - } + safeTransferFrom(_allocation.tokenContract, authority, tokenOptionAllocation, _total); + return tokenOptionAllocation; + } - function createRestrictedTokenAward(address _grantee, uint256 _repurchasePrice, address _paymentToken, uint256 _shortStopDuration, VestingAllocation.Allocation calldata _allocation, VestingAllocation.Milestone[] calldata _milestones) internal conditionCheck returns (address){ - validateInputParameters(_grantee, _paymentToken, _repurchasePrice, _allocation); - validateAllocation(_allocation); - uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); - - uint256 _total = _allocation.tokenStreamTotal + _milestoneTotal; - if (_total == 0) revert MetaVesTController_ZeroAmount(); - validateTokenApprovalAndBalance(_allocation.tokenContract, _total); - - address restrictedTokenAward = createAndInitializeRestrictedTokenAward( - _grantee, - _paymentToken, - _repurchasePrice, - _shortStopDuration, - _allocation, - _milestones - ); + function createRestrictedTokenAward( + address _grantee, + address _desiredRecipient, + uint256 _repurchasePrice, + address _paymentToken, + uint256 _shortStopDuration, + VestingAllocation.Allocation calldata _allocation, + VestingAllocation.Milestone[] calldata _milestones + ) internal conditionCheck returns (address){ + validateInputParameters(_grantee, _desiredRecipient, _paymentToken, _repurchasePrice, _allocation); + validateAllocation(_allocation); + uint256 _milestoneTotal = validateAndCalculateMilestones(_milestones); - safeTransferFrom(_allocation.tokenContract, authority, restrictedTokenAward, _total); - return restrictedTokenAward; - } + uint256 _total = _allocation.tokenStreamTotal + _milestoneTotal; + if (_total == 0) revert MetaVesTController_ZeroAmount(); + validateTokenApprovalAndBalance(_allocation.tokenContract, _total); + + // This is for fixing stack-too-deep errors + address restrictedTokenAward = createAndInitializeRestrictedTokenAward( + _grantee, + _desiredRecipient, + _paymentToken, + _repurchasePrice, + _shortStopDuration, + _allocation, + _milestones + ); + + safeTransferFrom(_allocation.tokenContract, authority, restrictedTokenAward, _total); + return restrictedTokenAward; + } function getMetaVestType(address _grant) public view returns (uint256) { return BaseAllocation(_grant).getVestingType(); @@ -747,4 +822,17 @@ contract metavestController is SafeTransferLib { emit MetaVesTController_AddressRemovedFromSet(_name, _metaVest); } + /// @notice Override individual metavest vault recipient to a new address. Individual metavest grantees may + /// set their own desired recipients, but this overrides them if set. + /// @dev `address(0)` means null/no overrides + /// @dev onlyAuthority -- must be called by the authority + /// @param _newRecipientOverride - the address of the new overriding recipient + function updateRecipientOverride(address _newRecipientOverride) external onlyAuthority { + _updateRecipientOverride(_newRecipientOverride); + } + + function _updateRecipientOverride(address _newRecipientOverride) internal { + emit MetaVesTController_RecipientOverrideUpdated(_newRecipientOverride); + recipientOverride = _newRecipientOverride; + } } diff --git a/src/MetaVesTFactory.sol b/src/MetaVesTFactory.sol index 0c200b6..efbe4b0 100644 --- a/src/MetaVesTFactory.sol +++ b/src/MetaVesTFactory.sol @@ -1,6 +1,6 @@ //SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; /* ************************************ @@ -8,10 +8,7 @@ pragma solidity 0.8.20; ************************************ */ -import "./MetaVesTController.sol"; -interface IMetaVesTController { - function metavest() external view returns (address); -} +import {metavestController} from "./MetaVesTController.sol"; /** * @title MetaVesT Factory @@ -25,6 +22,7 @@ contract MetaVesTFactory { address authority, address controller, address dao, + address recipientOverride, address vestingAllocationFactory, address tokenOptionFactory, address restrictedTokenFactory @@ -39,12 +37,18 @@ contract MetaVesTFactory { /// @dev conditionals are contained in the deployed MetaVesT, which is deployed in the MetaVesTController's constructor(); the MetaVesT within the MetaVesTController is immutable, but the 'authority' which has access control within the controller may replace itself /// @param _authority: address which initiates and may update each MetaVesT, such as a BORG or DAO /// @param _dao: contract address which token may be staked and used for voting, typically a DAO pool, governor, staking address. Submit address(0) for no such functionality. - function deployMetavestAndController(address _authority, address _dao, address _vestingAllocationFactory, address _tokenOptionFactory, address _restrictedTokenFactory ) external returns(address) { + function deployMetavestAndController( + address _authority, + address _dao, + address _recipientOverride, + address _vestingAllocationFactory, + address _tokenOptionFactory, + address _restrictedTokenFactory + ) external returns(address) { if(_vestingAllocationFactory == address(0) || _tokenOptionFactory == address(0) || _restrictedTokenFactory == address(0)) revert MetaVesTFactory_ZeroAddress(); - metavestController _controller = new metavestController(_authority, _dao, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); - emit MetaVesT_Deployment(address(0), _authority, address(_controller), _dao, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); + metavestController _controller = new metavestController(_authority, _dao, _recipientOverride, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); + emit MetaVesT_Deployment(address(0), _authority, address(_controller), _dao, _recipientOverride, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); return address(_controller); } - } diff --git a/src/RestrictedTokenAllocation.sol b/src/RestrictedTokenAllocation.sol index b0ccece..e8b3b26 100644 --- a/src/RestrictedTokenAllocation.sol +++ b/src/RestrictedTokenAllocation.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import "./BaseAllocation.sol"; -pragma solidity 0.8.20; +pragma solidity ^0.8.20; contract RestrictedTokenAward is BaseAllocation { @@ -15,6 +15,7 @@ contract RestrictedTokenAward is BaseAllocation { /// @notice Constructor to deploy a new RestrictedTokenAward /// @param _grantee - address of the grantee + /// @param _desiredRecipient address of the fund recipient /// @param _controller - address of the controller /// @param _paymentToken - address of the payment token /// @param _repurchasePrice - price at which the restricted tokens can be repurchased in vesting token decimals but only up to payment decimal precision @@ -23,6 +24,7 @@ contract RestrictedTokenAward is BaseAllocation { /// @param _milestones - milestones with their conditions and awards constructor ( address _grantee, + address _desiredRecipient, address _controller, address _paymentToken, uint256 _repurchasePrice, @@ -31,6 +33,7 @@ contract RestrictedTokenAward is BaseAllocation { Milestone[] memory _milestones ) BaseAllocation( _grantee, + _desiredRecipient, _controller ) { //perform input validation @@ -152,9 +155,11 @@ contract RestrictedTokenAward is BaseAllocation { function claimRepurchasedTokens() external onlyGrantee nonReentrant { if(IERC20M(paymentToken).balanceOf(address(this)) == 0) revert MetaVesT_MoreThanAvailable(); uint256 _amount = IERC20M(paymentToken).balanceOf(address(this)); - safeTransfer(paymentToken, msg.sender, _amount); + + address recipient = getRecipient(); + safeTransfer(paymentToken, recipient, _amount); tokensRepurchasedWithdrawn += _amount; - emit MetaVesT_Withdrawn(msg.sender, paymentToken, _amount); + emit MetaVesT_Withdrawn(grantee, recipient, paymentToken, _amount); } /// @notice Allows the controller to terminate the RestrictedTokenAward diff --git a/src/RestrictedTokenFactory.sol b/src/RestrictedTokenFactory.sol index c9e43a3..a993e26 100644 --- a/src/RestrictedTokenFactory.sol +++ b/src/RestrictedTokenFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./RestrictedTokenAllocation.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -9,6 +9,7 @@ contract RestrictedTokenFactory is IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, RestrictedTokenAward.Allocation memory _allocation, RestrictedTokenAward.Milestone[] memory _milestones, @@ -17,7 +18,7 @@ contract RestrictedTokenFactory is IAllocationFactory { uint256 _shortStopDuration ) external returns (address) { if (_allocationType == AllocationType.RestrictedToken) { - return address(new RestrictedTokenAward(_grantee, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); + return address(new RestrictedTokenAward(_grantee, _desiredRecipient, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); } else { revert("AllocationFactory: invalid allocation type"); } diff --git a/src/TokenOptionAllocation.sol b/src/TokenOptionAllocation.sol index c99fb65..34e8758 100644 --- a/src/TokenOptionAllocation.sol +++ b/src/TokenOptionAllocation.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import "./BaseAllocation.sol"; -pragma solidity 0.8.20; +pragma solidity ^0.8.20; contract TokenOptionAllocation is BaseAllocation { @@ -12,10 +12,11 @@ contract TokenOptionAllocation is BaseAllocation { uint256 public shortStopDuration; uint256 public shortStopTime; - event MetaVesT_TokenOptionExercised(address indexed _grantee, uint256 _tokensToExercise, uint256 _paymentAmount); + event MetaVesT_TokenOptionExercised(address indexed _grantee, address indexed _recipient, uint256 _tokensToExercise, uint256 _paymentAmount); /// @notice Constructor to create a TokenOptionAllocation /// @param _grantee - address of the grantee + /// @param _desiredRecipient address of the fund recipient /// @param _controller - address of the controller /// @param _paymentToken - address of the payment token /// @param _exercisePrice - price of the token option exercise in vesting token decimals but only up to payment decimal precision @@ -24,6 +25,7 @@ contract TokenOptionAllocation is BaseAllocation { /// @param _milestones - milestones with conditions and awards constructor ( address _grantee, + address _desiredRecipient, address _controller, address _paymentToken, uint256 _exercisePrice, @@ -32,6 +34,7 @@ contract TokenOptionAllocation is BaseAllocation { Milestone[] memory _milestones ) BaseAllocation( _grantee, + _desiredRecipient, _controller ) { //perform input validation @@ -139,10 +142,11 @@ contract TokenOptionAllocation is BaseAllocation { // Calculate paymentAmount uint256 paymentAmount = getPaymentAmount(_tokensToExercise); if(paymentAmount == 0) revert MetaVesT_TooSmallAmount(); - - safeTransferFrom(paymentToken, msg.sender, getAuthority(), paymentAmount); + + address recipient = getRecipient(); + safeTransferFrom(paymentToken, recipient, getAuthority(), paymentAmount); tokensExercised += _tokensToExercise; - emit MetaVesT_TokenOptionExercised(msg.sender, _tokensToExercise, paymentAmount); + emit MetaVesT_TokenOptionExercised(grantee, recipient, _tokensToExercise, paymentAmount); } /// @notice Allows the controller to terminate the TokenOptionAllocation diff --git a/src/TokenOptionFactory.sol b/src/TokenOptionFactory.sol index ed90780..7c38303 100644 --- a/src/TokenOptionFactory.sol +++ b/src/TokenOptionFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./TokenOptionAllocation.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -9,6 +9,7 @@ contract TokenOptionFactory is IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, TokenOptionAllocation.Allocation memory _allocation, TokenOptionAllocation.Milestone[] memory _milestones, @@ -17,7 +18,7 @@ contract TokenOptionFactory is IAllocationFactory { uint256 _shortStopDuration ) external returns (address) { if (_allocationType == AllocationType.TokenOption) { - return address(new TokenOptionAllocation(_grantee, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); + return address(new TokenOptionAllocation(_grantee, _desiredRecipient, _controller, _paymentToken, _exercisePrice, _shortStopDuration, _allocation, _milestones)); } else { revert("AllocationFactory: invalid allocation type"); } diff --git a/src/VestingAllocation.sol b/src/VestingAllocation.sol index 57801fe..614673e 100644 --- a/src/VestingAllocation.sol +++ b/src/VestingAllocation.sol @@ -1,23 +1,26 @@ // SPDX-License-Identifier: AGPL-3.0-only import "./BaseAllocation.sol"; -pragma solidity 0.8.20; +pragma solidity ^0.8.20; contract VestingAllocation is BaseAllocation { /// @notice constructor for VestingAllocation /// @param _grantee address of the grantee + /// @param _desiredRecipient address of the fund recipient /// @param _controller address of the controller /// @param _allocation Allocation struct containing token contract /// @param _milestones array of Milestone structs with conditions and awards constructor ( address _grantee, + address _desiredRecipient, address _controller, Allocation memory _allocation, Milestone[] memory _milestones ) BaseAllocation( - _grantee, - _controller + _grantee, + _desiredRecipient, + _controller ) { //perform input validation if (_allocation.tokenContract == address(0)) revert MetaVesT_ZeroAddress(); @@ -49,19 +52,24 @@ contract VestingAllocation is BaseAllocation { /// @notice returns the governing power of the VestingAllocation /// @return governingPower - the governing power of the VestingAllocation based on the governance setting function getGoverningPower() external view override returns (uint256 governingPower) { - if(govType==GovType.all) - { + // TODO WIP: revise needed. There are some changes since the last audit. Do we need to apply those changes to the other two allocation types? + if(govType==GovType.all) { uint256 totalMilestoneAward = 0; - for(uint256 i; i < milestones.length; ++i) - { + for(uint256 i; i < milestones.length; ++i) { totalMilestoneAward += milestones[i].milestoneAward; } governingPower = (allocation.tokenStreamTotal + totalMilestoneAward) - tokensWithdrawn; + } else if(govType==GovType.vested) { + uint256 amount = getVestedTokenAmount(); + governingPower = (amount > tokensWithdrawn) + ? amount - tokensWithdrawn + : 0; + } else { + uint256 amount = _min(getVestedTokenAmount(), getUnlockedTokenAmount()); + governingPower = (amount > tokensWithdrawn) + ? amount - tokensWithdrawn + : 0; } - else if(govType==GovType.vested) - governingPower = getVestedTokenAmount() - tokensWithdrawn; - else - governingPower = _min(getVestedTokenAmount(), getUnlockedTokenAmount()) - tokensWithdrawn; return governingPower; } diff --git a/src/VestingAllocationFactory.sol b/src/VestingAllocationFactory.sol index a27e220..34f6824 100644 --- a/src/VestingAllocationFactory.sol +++ b/src/VestingAllocationFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./VestingAllocation.sol"; import "./interfaces/IAllocationFactory.sol"; @@ -9,6 +9,7 @@ contract VestingAllocationFactory is IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, VestingAllocation.Allocation memory _allocation, VestingAllocation.Milestone[] memory _milestones, @@ -17,7 +18,7 @@ contract VestingAllocationFactory is IAllocationFactory { uint256 _shortStopDuration ) external returns (address) { if (_allocationType == AllocationType.Vesting) { - return address(new VestingAllocation(_grantee, _controller, _allocation, _milestones)); + return address(new VestingAllocation(_grantee, _desiredRecipient, _controller, _allocation, _milestones)); } else { revert("AllocationFactory: invalid allocation type"); } diff --git a/src/interfaces/IAllocationFactory.sol b/src/interfaces/IAllocationFactory.sol index 5d85402..e9392e2 100644 --- a/src/interfaces/IAllocationFactory.sol +++ b/src/interfaces/IAllocationFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "../VestingAllocation.sol"; @@ -14,6 +14,7 @@ interface IAllocationFactory { function createAllocation( AllocationType _allocationType, address _grantee, + address _desiredRecipient, address _controller, VestingAllocation.Allocation memory _allocation, VestingAllocation.Milestone[] memory _milestones, diff --git a/src/interfaces/IBaseAllocation.sol b/src/interfaces/IBaseAllocation.sol index 9618f1a..72e6231 100644 --- a/src/interfaces/IBaseAllocation.sol +++ b/src/interfaces/IBaseAllocation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; interface IBaseAllocation { function getVestingType() external view returns (uint256); diff --git a/src/interfaces/IMetaVesTController.sol b/src/interfaces/IMetaVesTController.sol new file mode 100644 index 0000000..7bf03fe --- /dev/null +++ b/src/interfaces/IMetaVesTController.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +interface IMetaVesTController { + function recipientOverride() external view returns (address); +} diff --git a/src/interfaces/IPriceAllocation.sol b/src/interfaces/IPriceAllocation.sol index 0bad4b8..c39e3ed 100644 --- a/src/interfaces/IPriceAllocation.sol +++ b/src/interfaces/IPriceAllocation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; interface IPriceAllocation { function getVestingType() external view returns (uint256); diff --git a/src/interfaces/IRestrictedTokenAward.sol b/src/interfaces/IRestrictedTokenAward.sol index aac6dd1..27dcb72 100644 --- a/src/interfaces/IRestrictedTokenAward.sol +++ b/src/interfaces/IRestrictedTokenAward.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "./IBaseAllocation.sol"; diff --git a/test/MetaVesTFactory.t.sol b/test/MetaVesTFactory.t.sol index 63d1108..a2d71ba 100644 --- a/test/MetaVesTFactory.t.sol +++ b/test/MetaVesTFactory.t.sol @@ -47,7 +47,7 @@ contract MetaVesTFactoryTest is Test { address _dao = address(0xB); address _paymentToken = address(0xC); - address _controller = factory.deployMetavestAndController(_authority, _dao, address(_factory), address(_factory2), address(_factory3)); + address _controller = factory.deployMetavestAndController(_authority, _dao, address(0), address(_factory), address(_factory2), address(_factory3)); } function testDeployMetavestAndController() public { @@ -56,7 +56,7 @@ contract MetaVesTFactoryTest is Test { address _paymentToken = address(0xC); - address _controller = factory.deployMetavestAndController(_authority, _dao, address(_factory), address(_factory2), address(_factory3)); + address _controller = factory.deployMetavestAndController(_authority, _dao, address(0), address(_factory), address(_factory2), address(_factory3)); metavestController controller = metavestController(_controller); } @@ -64,7 +64,7 @@ contract MetaVesTFactoryTest is Test { address _authority = address(0); address _dao = address(0); address _paymentToken = address(0); - factory.deployMetavestAndController(_authority, _dao, address(0), address(0), address(0)); + factory.deployMetavestAndController(_authority, _dao, address(0), address(0), address(0), address(0)); } diff --git a/test/amendement.t.sol b/test/amendement.t.sol index 205c60f..954a7bd 100644 --- a/test/amendement.t.sol +++ b/test/amendement.t.sol @@ -572,6 +572,7 @@ contract MetaVestControllerTest is Test { controller = new metavestController( authority, dao, + address(0), address(factory), address(tokenFactory), address(restrictedTokenFactory) diff --git a/test/controller.t.sol b/test/controller.t.sol index 809c079..9f8c0fe 100644 --- a/test/controller.t.sol +++ b/test/controller.t.sol @@ -1117,6 +1117,7 @@ contract MetaVestControllerTest is Test { controller = new metavestController( authority, dao, + address(0), address(factory), address(tokenFactory), address(restrictedTokenFactory) diff --git a/test/mocks/MockCondition.sol b/test/mocks/MockCondition.sol index aed853e..d6e2897 100644 --- a/test/mocks/MockCondition.sol +++ b/test/mocks/MockCondition.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity 0.8.20; +pragma solidity ^0.8.20; import "../../src/BaseAllocation.sol"; From 1a59570363657b324b0ff77e20fb53a77011a867 Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 17 Dec 2025 14:08:49 -0800 Subject: [PATCH 02/15] test: grantee recipient preference and controller overrides --- test/BaseAllocation.recipient.t.sol | 175 ++++++++++++++++++++++++ test/MetaVesTController.recipient.t.sol | 85 ++++++++++++ test/mocks/MockERC20.sol | 20 +++ 3 files changed, 280 insertions(+) create mode 100644 test/BaseAllocation.recipient.t.sol create mode 100644 test/MetaVesTController.recipient.t.sol create mode 100644 test/mocks/MockERC20.sol diff --git a/test/BaseAllocation.recipient.t.sol b/test/BaseAllocation.recipient.t.sol new file mode 100644 index 0000000..2425b7b --- /dev/null +++ b/test/BaseAllocation.recipient.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {MetaVesTFactory} from "../src/MetaVesTFactory.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract BaseAllocationRecipientTest is Test { + string saltStr = "BaseAllocationRecipientTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + MetaVesTFactory controllerFactory; + metavestController controller; + + function setUp() public { + vm.startPrank(deployer); + + controllerFactory = new MetaVesTFactory{salt: salt}(); + + controller = metavestController(controllerFactory.deployMetavestAndController( + authority, + authority, + address(0), + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + )); + + // Prepare funds + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_DesiredRecipientUpdatedOnConstruct() public { + bytes32 salt = keccak256(bytes("test_DesiredRecipientUpdatedOnConstruct")); + + vm.startPrank(authority); + vm.expectEmit(true, true, true, true); + emit BaseAllocation.MetaVesT_DesiredRecipientUpdated(alice, bob); + RestrictedTokenAward vault = _createTestVault(bob); // grantee specify bob as the desired recipient + vm.stopPrank(); + } + + /// @notice Should be able to create a metavest vault without recipient overrides nor grantee preference + function test_createMetavestDefaultRecipient() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + vm.stopPrank(); + + assertEq(vault.desiredRecipient(), address(0), "should have no grantee preference"); + assertEq(vault.getRecipient(), alice, "should use grantee as the recipient"); + + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_Withdrawn( + alice, // grantee + alice, // recipient + address(vestingToken), // tokenAddress + 1000e6 // amount + ); + vm.prank(alice); + vault.withdraw(1000e6); + assertEq(vestingToken.balanceOf(alice), 1000e6, "alice should be able to withdraw cliff to her desired wallet"); + } + + /// @notice Should be able to create a metavest vault without recipient overrides but with grantee preference + function test_createMetavestGranteePreference() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(bob); // grantee specify bob as the desired recipient + vm.stopPrank(); + + assertEq(vault.desiredRecipient(), bob, "grantee preference should be set"); + assertEq(vault.getRecipient(), bob, "should use the grantee preference as the recipient"); + + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_Withdrawn( + alice, // grantee + bob, // recipient + address(vestingToken), // tokenAddress + 1000e6 // amount + ); + vm.prank(alice); + vault.withdraw(1000e6); + assertEq(vestingToken.balanceOf(bob), 1000e6, "alice should be able to withdraw cliff to her desired wallet"); + } + + /// @notice Should be able to create a metavest vault with recipient overrides and no grantee preference + function test_createMetavestRecipientOverridden() public { + vm.startPrank(authority); + // Override recipient address + controller.updateRecipientOverride(chad); // controller overrides recipient address to chad's + RestrictedTokenAward vault = _createTestVault(bob); // grantee specify bob as the desired recipient, but it would be overridden and have no effects + vm.stopPrank(); + + assertEq(vault.desiredRecipient(), bob, "grantee preference should be set"); + assertEq(vault.getRecipient(), chad, "should use controller override as the recipient"); + + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_Withdrawn( + alice, // grantee + chad, // recipient + address(vestingToken), // tokenAddress + 1000e6 // amount + ); + vm.prank(alice); + vault.withdraw(1000e6); + assertEq(vestingToken.balanceOf(chad), 1000e6, "alice should be able to withdraw cliff to controller-overridden wallet"); + } + + function test_updateDesiredRecipient() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + vm.stopPrank(); + + vm.prank(alice); + vm.expectEmit(true, true, true, true, address(vault)); + emit BaseAllocation.MetaVesT_DesiredRecipientUpdated(alice, bob); + vault.updateDesiredRecipient(bob); + assertEq(vault.desiredRecipient(), bob, "unexpected desiredRecipient"); + } + + function test_RevertIf_updateDesiredRecipientNotGrantee() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + vm.stopPrank(); + + vm.expectRevert(BaseAllocation.MetaVesT_OnlyGrantee.selector); + vault.updateDesiredRecipient(chad); + } + + function _createTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + return RestrictedTokenAward(controller.createMetavest( + metavestController.metavestType.RestrictedTokenAward, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: uint48(block.timestamp), + unlockRate: 100e6, + unlockStartTime: uint48(block.timestamp) + }), + new BaseAllocation.Milestone[](0), + 1e6, // no-op: exercisePrice + address(paymentToken), + block.timestamp, // no-op: _shortStopDuration + 0 // no-op: _longStopDate + )); + } +} diff --git a/test/MetaVesTController.recipient.t.sol b/test/MetaVesTController.recipient.t.sol new file mode 100644 index 0000000..a1d2927 --- /dev/null +++ b/test/MetaVesTController.recipient.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {MetaVesTFactory} from "../src/MetaVesTFactory.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract MetaVestControllerRecipientTest is Test { + string saltStr = "MetaVestControllerRecipientTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + MetaVesTFactory controllerFactory; + metavestController controller; + + function setUp() public { + vm.startPrank(deployer); + + controllerFactory = new MetaVesTFactory{salt: salt}(); + + controller = metavestController(controllerFactory.deployMetavestAndController( + authority, + authority, + address(0), + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + )); + + // Prepare funds + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_RecipientOverrideUpdatedOnConstruct() public { + bytes32 salt = keccak256(bytes("test_RecipientOverrideUpdatedOnConstruct")); + + vm.expectEmit(true, true, true, true); + emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); + metavestController testController = metavestController(controllerFactory.deployMetavestAndController( + authority, + authority, + chad, + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + )); + assertEq(testController.recipientOverride(), chad, "unexpected recipientOverride"); + } + + function test_updateRecipientOverride() public { + vm.prank(authority); + vm.expectEmit(true, true, true, true, address(controller)); + emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); + controller.updateRecipientOverride(chad); + assertEq(controller.recipientOverride(), chad, "unexpected recipientOverride"); + } + + function test_RevertIf_updateRecipientOverrideNotAuthority() public { + vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); + controller.updateRecipientOverride(chad); + } +} diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol new file mode 100644 index 0000000..2060eeb --- /dev/null +++ b/test/mocks/MockERC20.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + uint8 internal _decimals; + + constructor(string memory _name, string memory _symbol, uint8 __decimals) ERC20(_name, _symbol) { + _decimals = __decimals; + } + + function decimals() public view override returns (uint8) { + return _decimals; + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } +} From f0b2fca2daa06d2cd0b99f17b8862353f4432739 Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 17 Dec 2025 14:51:35 -0800 Subject: [PATCH 03/15] test: RestrictedTokenAward with recipients --- .../RestrictedTokenAllocation.recipient.t.sol | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 test/RestrictedTokenAllocation.recipient.t.sol diff --git a/test/RestrictedTokenAllocation.recipient.t.sol b/test/RestrictedTokenAllocation.recipient.t.sol new file mode 100644 index 0000000..57e6d49 --- /dev/null +++ b/test/RestrictedTokenAllocation.recipient.t.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {MetaVesTFactory} from "../src/MetaVesTFactory.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract RestrictedTokenAwardRecipientTest is Test { + string saltStr = "RestrictedTokenAwardTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + MetaVesTFactory controllerFactory; + metavestController controller; + RestrictedTokenAward vault; + + function setUp() public { + vm.startPrank(deployer); + + controllerFactory = new MetaVesTFactory{salt: salt}(); + + controller = metavestController(controllerFactory.deployMetavestAndController( + authority, + authority, + address(0), // _recipientOverride + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + )); + + // Prepare funds + + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + paymentToken.mint(authority, 100000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_SanityCheck() public { + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + assertEq(vault.desiredRecipient(), address(0), "test vault should have no recipient preference set"); + } + + /// @notice Payment should go to the default recipient (grantee) + function test_claimRepurchasedTokensDefaultRecipient() public { + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + + RestrictedTokenAward vault = _createAndTerminateTestVault(address(0)); // no grantee preference + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(alice) - alicePaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_claimRepurchasedTokensGranteePreference() public { + uint256 bobPaymentTokenBalanceBefore = paymentToken.balanceOf(bob); + + RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(bob) - bobPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_claimRepurchasedTokensControllerOverride() public { + RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient, but it would be overridden and have no effects + + // Override recipient address + vm.prank(authority); + controller.updateRecipientOverride(chad); + + uint256 chadPaymentTokenBalanceBefore = paymentToken.balanceOf(chad); + + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(chad) - chadPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_RevertIf_repurchaseTokensShortStopTimeNotReached() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Not enough wait for shortStopDuration + vm.warp(block.timestamp + 9); + + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVesT_ShortStopTimeNotReached.selector); + vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation + + vm.stopPrank(); + } + + function test_RevertIf_repurchaseTokensMoreThanAvailable() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Wait for shortStopDuration + vm.warp(block.timestamp + 10); + + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVesT_MoreThanAvailable.selector); + vault.repurchaseTokens(8801e6); // 10000 - (1000 + 100 * 2) at the time of creation, plus one + + vm.stopPrank(); + } + + function _createTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + return RestrictedTokenAward(controller.createMetavest( + metavestController.metavestType.RestrictedTokenAward, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: uint48(block.timestamp), + unlockRate: 100e6, + unlockStartTime: uint48(block.timestamp) + }), + new BaseAllocation.Milestone[](0), + 10e6, + address(paymentToken), + 10, // shortStopDuration + 0 // no-op: _longStopDate + )); + } + + function _createAndTerminateTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + vm.startPrank(authority); + + RestrictedTokenAward vault = _createTestVault(desiredRecipient); + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Wait for shortStopDuration + vm.warp(block.timestamp + 10); + + uint256 authorityPaymentTokenBalanceBefore = paymentToken.balanceOf(authority); + uint256 authorityVestingTokenBalanceBefore = vestingToken.balanceOf(authority); + + paymentToken.approve(address(vault), 100000e6); + vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation + + assertEq(authorityPaymentTokenBalanceBefore - paymentToken.balanceOf(authority), 88000e6, "unexpected paid payment"); + assertEq(vestingToken.balanceOf(authority) - authorityVestingTokenBalanceBefore, 8800e6, "unexpected repurchased token amount"); + + vm.stopPrank(); + + return vault; + } +} From ac2774753300571f6285dc027b6349c279a509a0 Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 17 Dec 2025 15:06:52 -0800 Subject: [PATCH 04/15] chore: update comments --- src/BaseAllocation.sol | 6 +++++- src/MetaVesTController.sol | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/BaseAllocation.sol b/src/BaseAllocation.sol index 45dff92..baa0551 100644 --- a/src/BaseAllocation.sol +++ b/src/BaseAllocation.sol @@ -161,7 +161,6 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ address public grantee; // grantee of the tokens address public pendingGrantee; // address of the pending grantee - address public desiredRecipient; // recipient of the tokens (if not overridden by the controller) bool transferable; // whether grantee can transfer their MetaVesT in whole Milestone[] public milestones; // array of Milestone structs Allocation public allocation; // struct containing vesting and unlocking details @@ -172,6 +171,11 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ bool public terminated; uint256 public terminationTime; + // TODO: unaudited beta feature + // Specifies desired recipient of the tokens. Set by grantee only (address(0) = unset) + // Note this is a preference and it could be overridden by the controller. + address public desiredRecipient; + /// @notice BaseAllocation constructor /// @param _grantee: address of the grantee, cannot be a zero address /// @param _controller: address of the MetaVesTController contract diff --git a/src/MetaVesTController.sol b/src/MetaVesTController.sol index fac76fa..03be61d 100644 --- a/src/MetaVesTController.sol +++ b/src/MetaVesTController.sol @@ -36,10 +36,15 @@ contract metavestController is SafeTransferLib { address public authority; address public dao; - address public recipientOverride; // TODO: unaudited beta feature address public vestingFactory; address public tokenOptionFactory; address public restrictedTokenFactory; + + // TODO: unaudited beta feature + // Overrides all downstream metavest vaults's recipient addresses. Set by authority only (address(0) = unset). + // Individual metavest grantees may have their own desired recipients set, but this value would override them if set. + address public recipientOverride; + address internal _pendingAuthority; address internal _pendingDao; From d3e07d76550889e4f33cdcfe3090b7e86f76ccfe Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 17 Dec 2025 16:44:07 -0800 Subject: [PATCH 05/15] feat: update start times --- src/BaseAllocation.sol | 43 ++++++++++++++++++++++++++++++++++---- src/MetaVesTController.sol | 24 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/BaseAllocation.sol b/src/BaseAllocation.sol index baa0551..fb6d2a6 100644 --- a/src/BaseAllocation.sol +++ b/src/BaseAllocation.sol @@ -118,6 +118,7 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ error MetaVesT_NotTerminated(); error MetaVesT_MilestoneIndexCompletedOrDoesNotExist(); error MetaVesT_ConditionNotSatisfied(); + error MetaVesT_AlreadyStarted(); error MetaVesT_AlreadyTerminated(); error MetaVesT_MoreThanAvailable(); error MetaVesT_VestNotTransferable(); @@ -137,8 +138,10 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ event MetaVest_TransferRightsPending(address indexed grantee, address indexed pendingGrantee); event MetaVesT_TransferredRights(address indexed grantee, address transferee); event MetaVesT_DesiredRecipientUpdated(address indexed grantee, address newDesiredRecipient); - event MetaVesT_UnlockRateUpdated(address indexed grantee, uint208 unlockRate); - event MetaVesT_VestingRateUpdated(address indexed grantee, uint208 vestingRate); + event MetaVesT_UnlockRateUpdated(address indexed grantee, uint160 unlockRate); + event MetaVesT_VestingRateUpdated(address indexed grantee, uint160 vestingRate); + event MetaVesT_UnlockStartTimeUpdated(address indexed grantee, uint48 unlockStartTime); + event MetaVesT_VestingStartTimeUpdated(address indexed grantee, uint48 vestingStartTime); event MetaVesT_Withdrawn(address indexed grantee, address indexed recipient, address indexed tokenAddress, uint256 amount); event MetaVesT_PriceUpdated(address indexed grantee, uint256 exercisePrice); event MetaVesT_RepurchaseAndWithdrawal(address indexed grantee, address indexed tokenAddress, uint256 withdrawalAmount, uint256 repurchaseAmount); @@ -218,7 +221,7 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ emit MetaVesT_TransferabilityUpdated(grantee, _transferable); } - /// @notice updates the vesting rate of the VestingAllocation + /// @notice updates the vesting rate of the Allocation /// @dev onlyController -- must be called from the metavest controller /// @param _newVestingRate - the updated vesting rate in tokens per second in the vesting token decimal function updateVestingRate(uint160 _newVestingRate) external onlyController { @@ -227,7 +230,22 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ emit MetaVesT_VestingRateUpdated(grantee, _newVestingRate); } - /// @notice updates the unlock rate of the VestingAllocation + /// @notice updates the vesting start time of the Allocation + /// @dev onlyController -- must be called from the metavest controller + /// @param _newVestingStartTime - the updated vesting start time + function updateVestingStartTime(uint48 _newVestingStartTime) external onlyController { + if (terminated) revert MetaVesT_AlreadyTerminated(); + + // It is not allowed to update start time if it has already passed, because + // the contract treats vested tokens as permanent and therefore would not allow any change to the start time, + // which would change the number of vested token at that very moment. + if (allocation.vestingStartTime <= block.timestamp || _newVestingStartTime <= block.timestamp) revert MetaVesT_AlreadyStarted(); + + allocation.vestingStartTime = _newVestingStartTime; + emit MetaVesT_VestingStartTimeUpdated(grantee, _newVestingStartTime); + } + + /// @notice updates the unlock rate of the Allocation /// @dev onlyController -- must be called from the metavest controller /// @param _newUnlockRate - the updated unlock rate in tokens per second in the vesting token decimal function updateUnlockRate(uint160 _newUnlockRate) external onlyController { @@ -235,6 +253,23 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ emit MetaVesT_UnlockRateUpdated(grantee, _newUnlockRate); } + /// @notice updates the unlock start time of the Allocation + /// @dev onlyController -- must be called from the metavest controller + /// @param _newUnlockStartTime - the updated unlock start time + function updateUnlockStartTime(uint48 _newUnlockStartTime) external onlyController { + if (terminated) revert MetaVesT_AlreadyTerminated(); + + // It is not allowed to update start time if it has already passed, because + // the contract treats unlock tokens as permanent and therefore would not allow any change to the start time, + // which would change the number of unlock token at that very moment. + if (allocation.unlockStartTime <= block.timestamp || _newUnlockStartTime <= block.timestamp) revert MetaVesT_AlreadyStarted(); + + allocation.unlockStartTime = _newUnlockStartTime; + emit MetaVesT_UnlockStartTimeUpdated(grantee, _newUnlockStartTime); + } + + // TODO WIP: updateUnlockStartTime() + /// @notice Sets the governing power type for the MetaVesT /// @param _govType: the type of governing power to be used function setGovVariables(GovType _govType) external onlyController { diff --git a/src/MetaVesTController.sol b/src/MetaVesTController.sol index 03be61d..b2e0e6c 100644 --- a/src/MetaVesTController.sol +++ b/src/MetaVesTController.sol @@ -569,6 +569,18 @@ contract metavestController is SafeTransferLib { BaseAllocation(_grant).updateUnlockRate(_unlockRate); } + /// @notice for 'authority' to update a MetaVesT's unlockStartTime (including any transferees) + /// @dev '_unlockStartTime' is subject to the rules as defined in `BaseAllocation.updateUnlockStartTime()` + /// @param _grant address of grantee whose MetaVesT is being updated + /// @param _unlockStartTime token unlock rate in tokens per second + function updateMetavestUnlockStartTime( + address _grant, + uint48 _unlockStartTime + ) external onlyAuthority conditionCheck consentCheck(_grant, msg.data) { + _resetAmendmentParams(_grant, msg.sig); + BaseAllocation(_grant).updateUnlockStartTime(_unlockStartTime); + } + /// @notice for 'authority' to update a MetaVesT's vestingRate (including any transferees) /// @dev a '_vestingRate' of 0 is permissible to enable temporary freezes of allocation vestings by authority, but to permanently terminate vesting, call 'terminateMetavestVesting' /// @param _grant address of grantee whose MetaVesT is being updated @@ -581,6 +593,18 @@ contract metavestController is SafeTransferLib { BaseAllocation(_grant).updateVestingRate(_vestingRate); } + /// @notice for 'authority' to update a MetaVesT's vestingStartTime (including any transferees) + /// @dev '_vestingStartTime' is subject to the rules as defined in `BaseAllocation.updateVestingStartTime()` + /// @param _grant address of grantee whose MetaVesT is being updated + /// @param _vestingStartTime token vesting rate in tokens per second + function updateMetavestVestingStartTime( + address _grant, + uint48 _vestingStartTime + ) external onlyAuthority conditionCheck consentCheck(_grant, msg.data) { + _resetAmendmentParams(_grant, msg.sig); + BaseAllocation(_grant).updateVestingStartTime(_vestingStartTime); + } + /// @notice for authority to update a MetaVesT's stopTime and/or shortStopTime, as applicable (including any transferees) /// @dev if '_shortStopTime' has already occurred, it will be ignored in MetaVest.sol. Allows stop times before block.timestamp to enable accelerated schedules. /// @param _grant address of grantee whose MetaVesT is being updated From e29256ae23e2180ac90294661e0f979f7809da6a Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 17 Dec 2025 16:54:59 -0800 Subject: [PATCH 06/15] fix: temporarily remove MetaVesTFactory due to contract size limits --- src/MetaVesTFactory.sol | 54 -------------- test/BaseAllocation.recipient.t.sol | 8 +-- test/MetaVesTController.recipient.t.sol | 12 ++-- test/MetaVesTFactory.t.sol | 71 ------------------- .../RestrictedTokenAllocation.recipient.t.sol | 8 +-- 5 files changed, 8 insertions(+), 145 deletions(-) delete mode 100644 src/MetaVesTFactory.sol delete mode 100644 test/MetaVesTFactory.t.sol diff --git a/src/MetaVesTFactory.sol b/src/MetaVesTFactory.sol deleted file mode 100644 index efbe4b0..0000000 --- a/src/MetaVesTFactory.sol +++ /dev/null @@ -1,54 +0,0 @@ -//SPDX-License-Identifier: AGPL-3.0-only - -pragma solidity ^0.8.20; - -/* -************************************ - MetaVesTFactory - ************************************ - */ - -import {metavestController} from "./MetaVesTController.sol"; - -/** - * @title MetaVesT Factory - * - * @notice Deploy a new instance of MetaVesTController, which in turn deploys a new MetaVesT it controls - * - **/ -contract MetaVesTFactory { - event MetaVesT_Deployment( - address newMetaVesT, - address authority, - address controller, - address dao, - address recipientOverride, - address vestingAllocationFactory, - address tokenOptionFactory, - address restrictedTokenFactory - ); - - error MetaVesTFactory_ZeroAddress(); - - constructor() { } - - /// @notice constructs a MetaVesT framework specifying authority address, DAO staking/voting contract address - /// each individual grantee's MetaVesT will be initiated in the newly deployed MetaVesT contract, and deployed MetaVesTs are amendable by 'authority' via the controller contract - /// @dev conditionals are contained in the deployed MetaVesT, which is deployed in the MetaVesTController's constructor(); the MetaVesT within the MetaVesTController is immutable, but the 'authority' which has access control within the controller may replace itself - /// @param _authority: address which initiates and may update each MetaVesT, such as a BORG or DAO - /// @param _dao: contract address which token may be staked and used for voting, typically a DAO pool, governor, staking address. Submit address(0) for no such functionality. - function deployMetavestAndController( - address _authority, - address _dao, - address _recipientOverride, - address _vestingAllocationFactory, - address _tokenOptionFactory, - address _restrictedTokenFactory - ) external returns(address) { - if(_vestingAllocationFactory == address(0) || _tokenOptionFactory == address(0) || _restrictedTokenFactory == address(0)) - revert MetaVesTFactory_ZeroAddress(); - metavestController _controller = new metavestController(_authority, _dao, _recipientOverride, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); - emit MetaVesT_Deployment(address(0), _authority, address(_controller), _dao, _recipientOverride, _vestingAllocationFactory, _tokenOptionFactory, _restrictedTokenFactory); - return address(_controller); - } -} diff --git a/test/BaseAllocation.recipient.t.sol b/test/BaseAllocation.recipient.t.sol index 2425b7b..b4d13fd 100644 --- a/test/BaseAllocation.recipient.t.sol +++ b/test/BaseAllocation.recipient.t.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.20; import {BaseAllocation} from "../src/BaseAllocation.sol"; -import {MetaVesTFactory} from "../src/MetaVesTFactory.sol"; import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; import {Test} from "forge-std/Test.sol"; @@ -24,22 +23,19 @@ contract BaseAllocationRecipientTest is Test { MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); - MetaVesTFactory controllerFactory; metavestController controller; function setUp() public { vm.startPrank(deployer); - controllerFactory = new MetaVesTFactory{salt: salt}(); - - controller = metavestController(controllerFactory.deployMetavestAndController( + controller = new metavestController{salt: salt}( authority, authority, address(0), address(new VestingAllocationFactory{salt: salt}()), address(new TokenOptionFactory{salt: salt}()), address(new RestrictedTokenFactory{salt: salt}()) - )); + ); // Prepare funds vestingToken = new MockERC20("Vesting Token", "VEST", 6); diff --git a/test/MetaVesTController.recipient.t.sol b/test/MetaVesTController.recipient.t.sol index a1d2927..70b51e0 100644 --- a/test/MetaVesTController.recipient.t.sol +++ b/test/MetaVesTController.recipient.t.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.20; import {BaseAllocation} from "../src/BaseAllocation.sol"; -import {MetaVesTFactory} from "../src/MetaVesTFactory.sol"; import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; import {Test} from "forge-std/Test.sol"; @@ -24,22 +23,19 @@ contract MetaVestControllerRecipientTest is Test { MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); - MetaVesTFactory controllerFactory; metavestController controller; function setUp() public { vm.startPrank(deployer); - controllerFactory = new MetaVesTFactory{salt: salt}(); - - controller = metavestController(controllerFactory.deployMetavestAndController( + controller = new metavestController{salt: salt}( authority, authority, address(0), address(new VestingAllocationFactory{salt: salt}()), address(new TokenOptionFactory{salt: salt}()), address(new RestrictedTokenFactory{salt: salt}()) - )); + ); // Prepare funds vestingToken = new MockERC20("Vesting Token", "VEST", 6); @@ -59,14 +55,14 @@ contract MetaVestControllerRecipientTest is Test { vm.expectEmit(true, true, true, true); emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); - metavestController testController = metavestController(controllerFactory.deployMetavestAndController( + metavestController testController = new metavestController{salt: salt}( authority, authority, chad, address(new VestingAllocationFactory{salt: salt}()), address(new TokenOptionFactory{salt: salt}()), address(new RestrictedTokenFactory{salt: salt}()) - )); + ); assertEq(testController.recipientOverride(), chad, "unexpected recipientOverride"); } diff --git a/test/MetaVesTFactory.t.sol b/test/MetaVesTFactory.t.sol deleted file mode 100644 index a2d71ba..0000000 --- a/test/MetaVesTFactory.t.sol +++ /dev/null @@ -1,71 +0,0 @@ -//SPDX-License-Identifier: AGPL-3.0-only - -pragma solidity ^0.8.18; - -import "forge-std/Test.sol"; -import "../src/MetaVesTFactory.sol"; -import "../src/MetaVesTController.sol"; -import "../src/VestingAllocationFactory.sol"; -import "../src/TokenOptionFactory.sol"; -import "../src/RestrictedTokenFactory.sol"; - -interface IERC20 { - function transfer(address recipient, uint256 amount) external returns (bool); - function balanceOf(address account) external view returns (uint256); - function approve(address spender, uint256 amount) external returns (bool); - -} - -/// @dev foundry framework testing of MetaVesTFactory.sol -/// forge t --via-ir - -/// @notice test contract for MetaVesTFactory using Foundry -contract MetaVesTFactoryTest is Test { - MetaVesTFactory internal factory; - address factoryAddr; - address dai_addr = 0x3e622317f8C93f7328350cF0B56d9eD4C620C5d6; - VestingAllocationFactory _factory;// = new VestingAllocationFactory(); - RestrictedTokenFactory _factory2;// = new RestrictedTokenFactory(); - TokenOptionFactory _factory3;// = new TokenOptionFactory(); - - event MetaVesT_Deployment( - address newMetaVesT, - address authority, - address controller, - address dao, - address paymentToken - ); - - function setUp() public { - _factory = new VestingAllocationFactory(); - _factory2 = new RestrictedTokenFactory(); - _factory3 = new TokenOptionFactory(); - factory = new MetaVesTFactory(); - factoryAddr = address(factory); - address _authority = address(0xa); - - address _dao = address(0xB); - address _paymentToken = address(0xC); - - address _controller = factory.deployMetavestAndController(_authority, _dao, address(0), address(_factory), address(_factory2), address(_factory3)); - } - - function testDeployMetavestAndController() public { - address _authority = address(0xa); - address _dao = address(0xB); - address _paymentToken = address(0xC); - - - address _controller = factory.deployMetavestAndController(_authority, _dao, address(0), address(_factory), address(_factory2), address(_factory3)); - metavestController controller = metavestController(_controller); - } - - function testFailControllerZeroAddress() public { - address _authority = address(0); - address _dao = address(0); - address _paymentToken = address(0); - factory.deployMetavestAndController(_authority, _dao, address(0), address(0), address(0), address(0)); - } - - -} diff --git a/test/RestrictedTokenAllocation.recipient.t.sol b/test/RestrictedTokenAllocation.recipient.t.sol index 57e6d49..a218496 100644 --- a/test/RestrictedTokenAllocation.recipient.t.sol +++ b/test/RestrictedTokenAllocation.recipient.t.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.20; import {BaseAllocation} from "../src/BaseAllocation.sol"; -import {MetaVesTFactory} from "../src/MetaVesTFactory.sol"; import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; import {Test, console2} from "forge-std/Test.sol"; @@ -24,23 +23,20 @@ contract RestrictedTokenAwardRecipientTest is Test { MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); - MetaVesTFactory controllerFactory; metavestController controller; RestrictedTokenAward vault; function setUp() public { vm.startPrank(deployer); - controllerFactory = new MetaVesTFactory{salt: salt}(); - - controller = metavestController(controllerFactory.deployMetavestAndController( + controller = new metavestController{salt: salt}( authority, authority, address(0), // _recipientOverride address(new VestingAllocationFactory{salt: salt}()), address(new TokenOptionFactory{salt: salt}()), address(new RestrictedTokenFactory{salt: salt}()) - )); + ); // Prepare funds From 9bee73f3a1b4e4069a99c57079f853064e35af9f Mon Sep 17 00:00:00 2001 From: detoo Date: Wed, 17 Dec 2025 22:17:11 -0800 Subject: [PATCH 07/15] test: update start times --- src/BaseAllocation.sol | 2 - ...sol => BaseAllocation.abstract-beta.t.sol} | 6 +- test/MetaVesTController.abstract-beta.t.sol | 249 +++++++++++ test/MetaVesTController.recipient.t.sol | 81 ---- ...trictedTokenAllocation.abstract-beta.t.sol | 405 ++++++++++++++++++ .../RestrictedTokenAllocation.recipient.t.sol | 185 -------- test/mocks/MockCondition.sol | 7 + 7 files changed, 664 insertions(+), 271 deletions(-) rename test/{BaseAllocation.recipient.t.sol => BaseAllocation.abstract-beta.t.sol} (97%) create mode 100644 test/MetaVesTController.abstract-beta.t.sol delete mode 100644 test/MetaVesTController.recipient.t.sol create mode 100644 test/RestrictedTokenAllocation.abstract-beta.t.sol delete mode 100644 test/RestrictedTokenAllocation.recipient.t.sol diff --git a/src/BaseAllocation.sol b/src/BaseAllocation.sol index fb6d2a6..fff9f27 100644 --- a/src/BaseAllocation.sol +++ b/src/BaseAllocation.sol @@ -268,8 +268,6 @@ abstract contract BaseAllocation is ReentrancyGuard, SafeTransferLib{ emit MetaVesT_UnlockStartTimeUpdated(grantee, _newUnlockStartTime); } - // TODO WIP: updateUnlockStartTime() - /// @notice Sets the governing power type for the MetaVesT /// @param _govType: the type of governing power to be used function setGovVariables(GovType _govType) external onlyController { diff --git a/test/BaseAllocation.recipient.t.sol b/test/BaseAllocation.abstract-beta.t.sol similarity index 97% rename from test/BaseAllocation.recipient.t.sol rename to test/BaseAllocation.abstract-beta.t.sol index b4d13fd..d1bf2ff 100644 --- a/test/BaseAllocation.recipient.t.sol +++ b/test/BaseAllocation.abstract-beta.t.sol @@ -10,8 +10,8 @@ import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; import {metavestController} from "../src/MetaVesTController.sol"; import {MockERC20} from "./mocks/MockERC20.sol"; -contract BaseAllocationRecipientTest is Test { - string saltStr = "BaseAllocationRecipientTest"; +contract BaseAllocationAbstractBetaTest is Test { + string saltStr = "BaseAllocationAbstractBetaTest"; bytes32 salt = keccak256(bytes(saltStr)); address deployer = makeAddr("deployer"); @@ -164,7 +164,7 @@ contract BaseAllocationRecipientTest is Test { new BaseAllocation.Milestone[](0), 1e6, // no-op: exercisePrice address(paymentToken), - block.timestamp, // no-op: _shortStopDuration + 0, // no-op: _shortStopDuration 0 // no-op: _longStopDate )); } diff --git a/test/MetaVesTController.abstract-beta.t.sol b/test/MetaVesTController.abstract-beta.t.sol new file mode 100644 index 0000000..e09d361 --- /dev/null +++ b/test/MetaVesTController.abstract-beta.t.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {FalseCondition} from "./mocks/MockCondition.sol"; + +contract MetaVestControllerAbstractBetaTest is Test { + string saltStr = "MetaVestControllerAbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + metavestController controller; + + function setUp() public { + vm.startPrank(deployer); + + controller = new metavestController{salt: salt}( + authority, + authority, + address(0), + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + + // Prepare funds + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_RecipientOverrideUpdatedOnConstruct() public { + bytes32 salt = keccak256(bytes("test_RecipientOverrideUpdatedOnConstruct")); + + vm.expectEmit(true, true, true, true); + emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); + metavestController testController = new metavestController{salt: salt}( + authority, + authority, + chad, + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + assertEq(testController.recipientOverride(), chad, "unexpected recipientOverride"); + } + + function test_updateRecipientOverride() public { + vm.prank(authority); + vm.expectEmit(true, true, true, true, address(controller)); + emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); + controller.updateRecipientOverride(chad); + assertEq(controller.recipientOverride(), chad, "unexpected recipientOverride"); + } + + function test_RevertIf_updateRecipientOverrideNotAuthority() public { + vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); + controller.updateRecipientOverride(chad); + } + + function test_updateMetavestVestingStartTime() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, block.timestamp + 10, "unexpected vestingStartTime before update"); + } + + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), uint48(block.timestamp + 30)) + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(address(vault), metavestController.updateMetavestVestingStartTime.selector, true); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, block.timestamp + 30, "unexpected vestingStartTime after update"); + } + } + + function test_RevertIf_updateMetavestVestingStartTimeNotAuthority() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + } + + function test_RevertIf_updateMetavestVestingStartTimeConditionNotMet() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + // Add mock condition + address falseCondition = address(new FalseCondition()); + vm.prank(authority); + controller.updateFunctionCondition( + falseCondition, + metavestController.updateMetavestVestingStartTime.selector + ); + + vm.expectRevert(abi.encodeWithSelector(metavestController.MetaVesTController_ConditionNotSatisfied.selector, falseCondition)); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + } + + function test_RevertIf_updateMetavestVestingStartTimeNoConsent() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_AmendmentNeitherMutualNorMajorityConsented.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(block.timestamp + 30)); + } + + function test_updateMetavestUnlockStartTime() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, block.timestamp + 20, "unexpected unlockStartTime before update"); + } + + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), uint48(block.timestamp + 40)) + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(address(vault), metavestController.updateMetavestUnlockStartTime.selector, true); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 40)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, block.timestamp + 40, "unexpected unlockStartTime after update"); + } + } + + function test_RevertIf_updateMetavestUnlockStartTimeNotAuthority() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 40)); + } + + function test_RevertIf_updateMetavestUnlockStartTimeConditionNotMet() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + // Add mock condition + address falseCondition = address(new FalseCondition()); + vm.prank(authority); + controller.updateFunctionCondition( + falseCondition, + metavestController.updateMetavestUnlockStartTime.selector + ); + + vm.expectRevert(abi.encodeWithSelector(metavestController.MetaVesTController_ConditionNotSatisfied.selector, falseCondition)); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 30)); + } + + + + function test_RevertIf_updateMetavestUnlockStartTimeNoConsent() public { + // Create vault + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault(address(0)); + + vm.expectRevert(metavestController.MetaVesTController_AmendmentNeitherMutualNorMajorityConsented.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(block.timestamp + 40)); + } + + function _createTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + return RestrictedTokenAward(controller.createMetavest( + metavestController.metavestType.RestrictedTokenAward, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: uint48(block.timestamp + 10), + unlockRate: 100e6, + unlockStartTime: uint48(block.timestamp + 20) + }), + new BaseAllocation.Milestone[](0), + 1e6, // no-op: exercisePrice + address(paymentToken), + block.timestamp, // no-op: _shortStopDuration + 0 // no-op: _longStopDate + )); + } +} diff --git a/test/MetaVesTController.recipient.t.sol b/test/MetaVesTController.recipient.t.sol deleted file mode 100644 index 70b51e0..0000000 --- a/test/MetaVesTController.recipient.t.sol +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.20; - -import {BaseAllocation} from "../src/BaseAllocation.sol"; -import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; -import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; -import {Test} from "forge-std/Test.sol"; -import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; -import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; -import {metavestController} from "../src/MetaVesTController.sol"; -import {MockERC20} from "./mocks/MockERC20.sol"; - -contract MetaVestControllerRecipientTest is Test { - string saltStr = "MetaVestControllerRecipientTest"; - bytes32 salt = keccak256(bytes(saltStr)); - - address deployer = makeAddr("deployer"); - address authority = makeAddr("authority"); - address alice = makeAddr("alice"); - address bob = makeAddr("bob"); - address chad = makeAddr("chad"); - - MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); - MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); - - metavestController controller; - - function setUp() public { - vm.startPrank(deployer); - - controller = new metavestController{salt: salt}( - authority, - authority, - address(0), - address(new VestingAllocationFactory{salt: salt}()), - address(new TokenOptionFactory{salt: salt}()), - address(new RestrictedTokenFactory{salt: salt}()) - ); - - // Prepare funds - vestingToken = new MockERC20("Vesting Token", "VEST", 6); - paymentToken = new MockERC20("Payment Token", "PAY", 6); - - vestingToken.mint(authority, 10000e6); - - vm.stopPrank(); - - vm.startPrank(authority); - vestingToken.approve(address(controller), 10000e6); - vm.stopPrank(); - } - - function test_RecipientOverrideUpdatedOnConstruct() public { - bytes32 salt = keccak256(bytes("test_RecipientOverrideUpdatedOnConstruct")); - - vm.expectEmit(true, true, true, true); - emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); - metavestController testController = new metavestController{salt: salt}( - authority, - authority, - chad, - address(new VestingAllocationFactory{salt: salt}()), - address(new TokenOptionFactory{salt: salt}()), - address(new RestrictedTokenFactory{salt: salt}()) - ); - assertEq(testController.recipientOverride(), chad, "unexpected recipientOverride"); - } - - function test_updateRecipientOverride() public { - vm.prank(authority); - vm.expectEmit(true, true, true, true, address(controller)); - emit metavestController.MetaVesTController_RecipientOverrideUpdated(chad); - controller.updateRecipientOverride(chad); - assertEq(controller.recipientOverride(), chad, "unexpected recipientOverride"); - } - - function test_RevertIf_updateRecipientOverrideNotAuthority() public { - vm.expectRevert(metavestController.MetaVesTController_OnlyAuthority.selector); - controller.updateRecipientOverride(chad); - } -} diff --git a/test/RestrictedTokenAllocation.abstract-beta.t.sol b/test/RestrictedTokenAllocation.abstract-beta.t.sol new file mode 100644 index 0000000..ec8bb01 --- /dev/null +++ b/test/RestrictedTokenAllocation.abstract-beta.t.sol @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract RestrictedTokenAwardAbstractBetaTest is Test { + string saltStr = "RestrictedTokenAwardAbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + metavestController controller; + RestrictedTokenAward vault; + + function setUp() public { + vm.startPrank(deployer); + + controller = new metavestController{salt: salt}( + authority, + authority, + address(0), // _recipientOverride + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + + // Prepare funds + + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + paymentToken.mint(authority, 100000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_SanityCheck() public { + vm.prank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + assertEq(vault.desiredRecipient(), address(0), "test vault should have no recipient preference set"); + } + + /// @notice Payment should go to the default recipient (grantee) + function test_claimRepurchasedTokensDefaultRecipient() public { + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + + RestrictedTokenAward vault = _createAndTerminateTestVault(address(0)); // no grantee preference + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(alice) - alicePaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_claimRepurchasedTokensGranteePreference() public { + uint256 bobPaymentTokenBalanceBefore = paymentToken.balanceOf(bob); + + RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(bob) - bobPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_claimRepurchasedTokensControllerOverride() public { + RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient, but it would be overridden and have no effects + + // Override recipient address + vm.prank(authority); + controller.updateRecipientOverride(chad); + + uint256 chadPaymentTokenBalanceBefore = paymentToken.balanceOf(chad); + + vm.prank(alice); + vault.claimRepurchasedTokens(); + + assertEq(paymentToken.balanceOf(chad) - chadPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); + } + + function test_RevertIf_repurchaseTokensShortStopTimeNotReached() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Not enough wait for shortStopDuration + vm.warp(block.timestamp + 9); + + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVesT_ShortStopTimeNotReached.selector); + vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation + + vm.stopPrank(); + } + + function test_RevertIf_repurchaseTokensMoreThanAvailable() public { + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Wait for shortStopDuration + vm.warp(block.timestamp + 10); + + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVesT_MoreThanAvailable.selector); + vault.repurchaseTokens(8801e6); // 10000 - (1000 + 100 * 2) at the time of creation, plus one + + vm.stopPrank(); + } + + function test_updateVestingStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 10, "unexpected vestingStartTime before update"); + assertEq(vault.getVestedTokenAmount(), 0, "unexpected getVestedTokenAmount() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(now + 30)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 30, "unexpected vestingStartTime after update"); + + vm.warp(now + 29); + assertEq(vault.getVestedTokenAmount(), 0, "unexpected getVestedTokenAmount() after update & before new start time"); + } + + vm.warp(now + 30 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getVestedTokenAmount(), 1200e6, "unexpected getVestedTokenAmount() after update & after new start time"); + } + + function test_RevertIf_updateVestingStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Old vestingStartTime has passed + vm.warp(now + 10); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 30); + } + + function test_RevertIf_updateVestingStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 5) + ); + + // Old vestingStartTime has not passed, but new vestingStartTime has + vm.warp(now + 5); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 5); + } + + function test_updateUnlockStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 20, "unexpected unlockStartTime before update"); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(now + 40)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 40, "unexpected unlockStartTime after update"); + + vm.warp(now + 39); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() after update & before new start time"); + } + + vm.warp(now + 40 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getUnlockedTokenAmount(), 1200e6, "unexpected getUnlockedTokenAmount() after update & after new start time"); + } + + function test_RevertIf_updateUnlockStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Old unlockStartTime has passed + vm.warp(now + 20); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 40); + } + + function test_RevertIf_updateUnlockStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + RestrictedTokenAward vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 15) + ); + + // Old unlockStartTime has not passed, but new unlockStartTime has + vm.warp(now + 15); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 15); + } + + function _createTestVault( + address desiredRecipient, + uint48 vestingStartTime, + uint48 unlockStartTime + ) internal returns (RestrictedTokenAward) { + return RestrictedTokenAward(controller.createMetavest( + metavestController.metavestType.RestrictedTokenAward, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: vestingStartTime, + unlockRate: 100e6, + unlockStartTime: unlockStartTime + }), + new BaseAllocation.Milestone[](0), + 10e6, + address(paymentToken), + 10, // shortStopDuration + 0 // no-op: _longStopDate + )); + } + + function _createAndTerminateTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { + vm.startPrank(authority); + + RestrictedTokenAward vault = _createTestVault( + desiredRecipient, + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // 2% vested & unlocked + vm.warp(block.timestamp + 2); + + controller.terminateMetavestVesting(address(vault)); + + // Wait for shortStopDuration + vm.warp(block.timestamp + 10); + + uint256 authorityPaymentTokenBalanceBefore = paymentToken.balanceOf(authority); + uint256 authorityVestingTokenBalanceBefore = vestingToken.balanceOf(authority); + + paymentToken.approve(address(vault), 100000e6); + vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation + + assertEq(authorityPaymentTokenBalanceBefore - paymentToken.balanceOf(authority), 88000e6, "unexpected paid payment"); + assertEq(vestingToken.balanceOf(authority) - authorityVestingTokenBalanceBefore, 8800e6, "unexpected repurchased token amount"); + + vm.stopPrank(); + + return vault; + } + + function _consentStartTime(address vaultAddr, bytes4 msgSig, bytes memory data) internal { + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + vaultAddr, + msgSig, + data + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(vaultAddr, msgSig, true); + } +} diff --git a/test/RestrictedTokenAllocation.recipient.t.sol b/test/RestrictedTokenAllocation.recipient.t.sol deleted file mode 100644 index a218496..0000000 --- a/test/RestrictedTokenAllocation.recipient.t.sol +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.20; - -import {BaseAllocation} from "../src/BaseAllocation.sol"; -import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; -import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; -import {Test, console2} from "forge-std/Test.sol"; -import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; -import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; -import {metavestController} from "../src/MetaVesTController.sol"; -import {MockERC20} from "./mocks/MockERC20.sol"; - -contract RestrictedTokenAwardRecipientTest is Test { - string saltStr = "RestrictedTokenAwardTest"; - bytes32 salt = keccak256(bytes(saltStr)); - - address deployer = makeAddr("deployer"); - address authority = makeAddr("authority"); - address alice = makeAddr("alice"); - address bob = makeAddr("bob"); - address chad = makeAddr("chad"); - - MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); - MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); - - metavestController controller; - RestrictedTokenAward vault; - - function setUp() public { - vm.startPrank(deployer); - - controller = new metavestController{salt: salt}( - authority, - authority, - address(0), // _recipientOverride - address(new VestingAllocationFactory{salt: salt}()), - address(new TokenOptionFactory{salt: salt}()), - address(new RestrictedTokenFactory{salt: salt}()) - ); - - // Prepare funds - - vestingToken = new MockERC20("Vesting Token", "VEST", 6); - paymentToken = new MockERC20("Payment Token", "PAY", 6); - - vestingToken.mint(authority, 10000e6); - paymentToken.mint(authority, 100000e6); - - vm.stopPrank(); - - vm.startPrank(authority); - vestingToken.approve(address(controller), 10000e6); - vm.stopPrank(); - } - - function test_SanityCheck() public { - vm.prank(authority); - RestrictedTokenAward vault = _createTestVault(address(0)); - assertEq(vault.desiredRecipient(), address(0), "test vault should have no recipient preference set"); - } - - /// @notice Payment should go to the default recipient (grantee) - function test_claimRepurchasedTokensDefaultRecipient() public { - uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); - - RestrictedTokenAward vault = _createAndTerminateTestVault(address(0)); // no grantee preference - vm.prank(alice); - vault.claimRepurchasedTokens(); - - assertEq(paymentToken.balanceOf(alice) - alicePaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); - } - - function test_claimRepurchasedTokensGranteePreference() public { - uint256 bobPaymentTokenBalanceBefore = paymentToken.balanceOf(bob); - - RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient - vm.prank(alice); - vault.claimRepurchasedTokens(); - - assertEq(paymentToken.balanceOf(bob) - bobPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); - } - - function test_claimRepurchasedTokensControllerOverride() public { - RestrictedTokenAward vault = _createAndTerminateTestVault(bob); // set bob as the desired recipient, but it would be overridden and have no effects - - // Override recipient address - vm.prank(authority); - controller.updateRecipientOverride(chad); - - uint256 chadPaymentTokenBalanceBefore = paymentToken.balanceOf(chad); - - vm.prank(alice); - vault.claimRepurchasedTokens(); - - assertEq(paymentToken.balanceOf(chad) - chadPaymentTokenBalanceBefore, 88000e6, "unexpected received payment"); - } - - function test_RevertIf_repurchaseTokensShortStopTimeNotReached() public { - vm.startPrank(authority); - RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference - - // 2% vested & unlocked - vm.warp(block.timestamp + 2); - - controller.terminateMetavestVesting(address(vault)); - - // Not enough wait for shortStopDuration - vm.warp(block.timestamp + 9); - - paymentToken.approve(address(vault), 100000e6); - vm.expectRevert(BaseAllocation.MetaVesT_ShortStopTimeNotReached.selector); - vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation - - vm.stopPrank(); - } - - function test_RevertIf_repurchaseTokensMoreThanAvailable() public { - vm.startPrank(authority); - RestrictedTokenAward vault = _createTestVault(address(0)); // no grantee preference - - // 2% vested & unlocked - vm.warp(block.timestamp + 2); - - controller.terminateMetavestVesting(address(vault)); - - // Wait for shortStopDuration - vm.warp(block.timestamp + 10); - - paymentToken.approve(address(vault), 100000e6); - vm.expectRevert(BaseAllocation.MetaVesT_MoreThanAvailable.selector); - vault.repurchaseTokens(8801e6); // 10000 - (1000 + 100 * 2) at the time of creation, plus one - - vm.stopPrank(); - } - - function _createTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { - return RestrictedTokenAward(controller.createMetavest( - metavestController.metavestType.RestrictedTokenAward, - alice, - desiredRecipient, - BaseAllocation.Allocation({ - tokenContract: address(vestingToken), - tokenStreamTotal: 10000e6, - vestingCliffCredit: 1000e6, - unlockingCliffCredit: 1000e6, - vestingRate: 100e6, - vestingStartTime: uint48(block.timestamp), - unlockRate: 100e6, - unlockStartTime: uint48(block.timestamp) - }), - new BaseAllocation.Milestone[](0), - 10e6, - address(paymentToken), - 10, // shortStopDuration - 0 // no-op: _longStopDate - )); - } - - function _createAndTerminateTestVault(address desiredRecipient) internal returns (RestrictedTokenAward) { - vm.startPrank(authority); - - RestrictedTokenAward vault = _createTestVault(desiredRecipient); - - // 2% vested & unlocked - vm.warp(block.timestamp + 2); - - controller.terminateMetavestVesting(address(vault)); - - // Wait for shortStopDuration - vm.warp(block.timestamp + 10); - - uint256 authorityPaymentTokenBalanceBefore = paymentToken.balanceOf(authority); - uint256 authorityVestingTokenBalanceBefore = vestingToken.balanceOf(authority); - - paymentToken.approve(address(vault), 100000e6); - vault.repurchaseTokens(8800e6); // 10000 - (1000 + 100 * 2) at the time of creation - - assertEq(authorityPaymentTokenBalanceBefore - paymentToken.balanceOf(authority), 88000e6, "unexpected paid payment"); - assertEq(vestingToken.balanceOf(authority) - authorityVestingTokenBalanceBefore, 8800e6, "unexpected repurchased token amount"); - - vm.stopPrank(); - - return vault; - } -} diff --git a/test/mocks/MockCondition.sol b/test/mocks/MockCondition.sol index d6e2897..f9659b7 100644 --- a/test/mocks/MockCondition.sol +++ b/test/mocks/MockCondition.sol @@ -95,3 +95,10 @@ contract SignatureCondition is BaseCondition { } else return false; // Default case, should not reach here } } + +/// @title FalseCondition - A condition that always fails +contract FalseCondition is BaseCondition { + function checkCondition(address _contract, bytes4 _functionSignature, bytes memory data) public view override returns (bool) { + return false; + } +} From c2b87d7326bbae4ffe4650aad6dd3d4dba16d063 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 18 Dec 2025 10:11:28 -0800 Subject: [PATCH 08/15] fix: TokenOptionAllocation payment should come from grantee instead of recipient --- src/TokenOptionAllocation.sol | 7 +- .../TokenOptionAllocation.abstract-beta.t.sol | 424 ++++++++++++++++++ 2 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 test/TokenOptionAllocation.abstract-beta.t.sol diff --git a/src/TokenOptionAllocation.sol b/src/TokenOptionAllocation.sol index 34e8758..9a886e6 100644 --- a/src/TokenOptionAllocation.sol +++ b/src/TokenOptionAllocation.sol @@ -12,7 +12,7 @@ contract TokenOptionAllocation is BaseAllocation { uint256 public shortStopDuration; uint256 public shortStopTime; - event MetaVesT_TokenOptionExercised(address indexed _grantee, address indexed _recipient, uint256 _tokensToExercise, uint256 _paymentAmount); + event MetaVesT_TokenOptionExercised(address indexed _grantee, uint256 _tokensToExercise, uint256 _paymentAmount); /// @notice Constructor to create a TokenOptionAllocation /// @param _grantee - address of the grantee @@ -143,10 +143,9 @@ contract TokenOptionAllocation is BaseAllocation { uint256 paymentAmount = getPaymentAmount(_tokensToExercise); if(paymentAmount == 0) revert MetaVesT_TooSmallAmount(); - address recipient = getRecipient(); - safeTransferFrom(paymentToken, recipient, getAuthority(), paymentAmount); + safeTransferFrom(paymentToken, grantee, getAuthority(), paymentAmount); tokensExercised += _tokensToExercise; - emit MetaVesT_TokenOptionExercised(grantee, recipient, _tokensToExercise, paymentAmount); + emit MetaVesT_TokenOptionExercised(grantee, _tokensToExercise, paymentAmount); } /// @notice Allows the controller to terminate the TokenOptionAllocation diff --git a/test/TokenOptionAllocation.abstract-beta.t.sol b/test/TokenOptionAllocation.abstract-beta.t.sol new file mode 100644 index 0000000..0ad214e --- /dev/null +++ b/test/TokenOptionAllocation.abstract-beta.t.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {TokenOptionAllocation} from "../src/TokenOptionAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; + +contract TokenOptionAllocationAbstractBetaTest is Test { + string saltStr = "TokenOptionAllocationAbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + address deployer = makeAddr("deployer"); + address authority = makeAddr("authority"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address chad = makeAddr("chad"); + + MockERC20 vestingToken = new MockERC20("Vesting Token", "VEST", 6); + MockERC20 paymentToken = new MockERC20("Payment Token", "PAY", 6); + + metavestController controller; + TokenOptionAllocation vault; + + function setUp() public { + vm.startPrank(deployer); + + controller = new metavestController{salt: salt}( + authority, + authority, + address(0), // _recipientOverride + address(new VestingAllocationFactory{salt: salt}()), + address(new TokenOptionFactory{salt: salt}()), + address(new RestrictedTokenFactory{salt: salt}()) + ); + + // Prepare funds + + vestingToken = new MockERC20("Vesting Token", "VEST", 6); + paymentToken = new MockERC20("Payment Token", "PAY", 6); + + vestingToken.mint(authority, 10000e6); + paymentToken.mint(alice, 100000e6); + + vm.stopPrank(); + + vm.startPrank(authority); + vestingToken.approve(address(controller), 10000e6); + vm.stopPrank(); + } + + function test_SanityCheck() public { + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + assertEq(vault.desiredRecipient(), address(0), "test vault should have no recipient preference set"); + } + + /// @notice Vesting token should go to the default recipient (grantee) + function test_exerciseTokenOptionDefaultRecipient() public { + uint48 now = uint48(block.timestamp); + + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + uint256 aliceVestingTokenBalanceBefore = vestingToken.balanceOf(alice); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 12000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectEmit(true, true, true, true, address(vault)); + emit TokenOptionAllocation.MetaVesT_TokenOptionExercised(alice, 1200e6, 12000e6); + vault.exerciseTokenOption(1200e6); + vault.withdraw(1200e6); + vm.stopPrank(); + + // Payment is always coming from grantee + assertEq(alicePaymentTokenBalanceBefore - paymentToken.balanceOf(alice), 12000e6, "unexpected payment"); + assertEq(vestingToken.balanceOf(alice) - aliceVestingTokenBalanceBefore, 1200e6, "unexpected received token amount"); + } + + /// @notice Vesting token should go to the recipient address set by grantee + function test_exerciseTokenOptionGranteePreference() public { + uint48 now = uint48(block.timestamp); + + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + uint256 bobVestingTokenBalanceBefore = vestingToken.balanceOf(bob); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + bob, // set bob as the desired recipient + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 12000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectEmit(true, true, true, true, address(vault)); + emit TokenOptionAllocation.MetaVesT_TokenOptionExercised(alice, 1200e6, 12000e6); + vault.exerciseTokenOption(1200e6); + vault.withdraw(1200e6); + vm.stopPrank(); + + // Payment is always coming from grantee + assertEq(alicePaymentTokenBalanceBefore - paymentToken.balanceOf(alice), 12000e6, "unexpected payment"); + assertEq(vestingToken.balanceOf(bob) - bobVestingTokenBalanceBefore, 1200e6, "unexpected received token amount"); + } + + /// @notice Vesting token should go to the recipient address set by the controller + function test_exerciseTokenOptionControllerOverride() public { + uint48 now = uint48(block.timestamp); + + uint256 alicePaymentTokenBalanceBefore = paymentToken.balanceOf(alice); + uint256 chadVestingTokenBalanceBefore = vestingToken.balanceOf(chad); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + bob, // set bob as the desired recipient, but it would be overridden and have no effects + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // Override recipient address + vm.prank(authority); + controller.updateRecipientOverride(chad); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 12000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectEmit(true, true, true, true, address(vault)); + emit TokenOptionAllocation.MetaVesT_TokenOptionExercised(alice, 1200e6, 12000e6); + vault.exerciseTokenOption(1200e6); + vault.withdraw(1200e6); + vm.stopPrank(); + + // Payment is always coming from grantee + assertEq(alicePaymentTokenBalanceBefore - paymentToken.balanceOf(alice), 12000e6, "unexpected payment"); + assertEq(vestingToken.balanceOf(chad) - chadVestingTokenBalanceBefore, 1200e6, "unexpected received token amount"); + } + + function test_RevertIf_exerciseTokenOptionShortStopDatePassed() public { + uint48 now = uint48(block.timestamp); + + vm.prank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + // contract terminated and shortStop date has passed + vm.prank(authority); + controller.terminateMetavestVesting(address(vault)); + vm.warp(now + 11); + + vm.startPrank(alice); + paymentToken.approve(address(vault), 100000e6); + vm.expectRevert(BaseAllocation.MetaVest_ShortStopDatePassed.selector); + vault.exerciseTokenOption(1e6); + vm.stopPrank(); + } + + function test_RevertIf_exerciseTokenOptionMoreThanAvailable() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(block.timestamp), // vestingStartTime + uint48(block.timestamp) // unlockStartTime + ); + + vm.warp(now + 2); // 2 secs. into vesting & unlocking + vm.startPrank(alice); + paymentToken.approve(address(vault), 100000e6); + // (1000 + 100 * 2) * 10 = 12000 + vm.expectRevert(BaseAllocation.MetaVesT_MoreThanAvailable.selector); + vault.exerciseTokenOption(1201e6); + vm.stopPrank(); + } + + function test_updateVestingStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 10, "unexpected vestingStartTime before update"); + assertEq(vault.getAmountExercisable(), 0, "unexpected getAmountExercisable() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), uint48(now + 30)); + + { + (,,,, uint48 vestingStartTime,,,) = vault.allocation(); + assertEq(vestingStartTime, now + 30, "unexpected vestingStartTime after update"); + + vm.warp(now + 29); + assertEq(vault.getAmountExercisable(), 0, "unexpected getAmountExercisable() after update & before new start time"); + } + + vm.warp(now + 30 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getAmountExercisable(), 1200e6, "unexpected getAmountExercisable() after update & after new start time"); + } + + function test_RevertIf_updateVestingStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 30) + ); + + // Old vestingStartTime has passed + vm.warp(now + 10); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 30); + } + + function test_RevertIf_updateVestingStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestVestingStartTime.selector, address(vault), now + 5) + ); + + // Old vestingStartTime has not passed, but new vestingStartTime has + vm.warp(now + 5); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestVestingStartTime(address(vault), now + 5); + } + + function test_updateUnlockStartTime() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 20, "unexpected unlockStartTime before update"); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() before update"); + } + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Perform amendment + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), uint48(now + 40)); + + { + (,,,,,, uint48 unlockStartTime,) = vault.allocation(); + assertEq(unlockStartTime, now + 40, "unexpected unlockStartTime after update"); + + vm.warp(now + 39); + assertEq(vault.getUnlockedTokenAmount(), 0, "unexpected getUnlockedTokenAmount() after update & before new start time"); + } + + vm.warp(now + 40 + 2); + // 1000 + 100 * 2 = 1200 + assertEq(vault.getUnlockedTokenAmount(), 1200e6, "unexpected getUnlockedTokenAmount() after update & after new start time"); + } + + function test_RevertIf_updateUnlockStartTimeOldTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 40) + ); + + // Old unlockStartTime has passed + vm.warp(now + 20); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 40); + } + + function test_RevertIf_updateUnlockStartTimeNewTimeAlreadyStarted() public { + uint48 now = uint48(block.timestamp); + + vm.startPrank(authority); + TokenOptionAllocation vault = _createTestVault( + address(0), // no grantee preference + uint48(now + 10), // vestingStartTime + uint48(now + 20) // unlockStartTime + ); + vm.stopPrank(); + + _consentStartTime( + address(vault), + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector(metavestController.updateMetavestUnlockStartTime.selector, address(vault), now + 15) + ); + + // Old unlockStartTime has not passed, but new unlockStartTime has + vm.warp(now + 15); + + // Perform amendment + vm.expectRevert(BaseAllocation.MetaVesT_AlreadyStarted.selector); + vm.prank(authority); + controller.updateMetavestUnlockStartTime(address(vault), now + 15); + } + + function _createTestVault( + address desiredRecipient, + uint48 vestingStartTime, + uint48 unlockStartTime + ) internal returns (TokenOptionAllocation) { + return TokenOptionAllocation(controller.createMetavest( + metavestController.metavestType.TokenOption, + alice, + desiredRecipient, + BaseAllocation.Allocation({ + tokenContract: address(vestingToken), + tokenStreamTotal: 10000e6, + vestingCliffCredit: 1000e6, + unlockingCliffCredit: 1000e6, + vestingRate: 100e6, + vestingStartTime: vestingStartTime, + unlockRate: 100e6, + unlockStartTime: unlockStartTime + }), + new BaseAllocation.Milestone[](0), + 10e6, + address(paymentToken), + 10, // shortStopDuration + 0 // no-op: _longStopDate + )); + } + + function _consentStartTime(address vaultAddr, bytes4 msgSig, bytes memory data) internal { + // Propose amendment + vm.prank(authority); + controller.proposeMetavestAmendment( + vaultAddr, + msgSig, + data + ); + + vm.stopPrank(); + + // Approve amendment + vm.prank(alice); + controller.consentToMetavestAmendment(vaultAddr, msgSig, true); + } +} From a79ef28e0f85a32255ed86a3a11ffeb798705be4 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 18 Dec 2025 14:55:53 -0800 Subject: [PATCH 09/15] chore: deploy scripts --- scripts/deploy.abstract-beta.s.sol | 135 ++++++++++++++++++++++++++++ scripts/deploy.mock-erc20.s.sol | 35 ++++++++ scripts/lib/AbstractBeta.sol | 114 +++++++++++++++++++++++ scripts/lib/AbstractBetaSepolia.sol | 43 +++++++++ scripts/lib/safe.sol | 69 ++++++++++++++ test/AbstractBeta.t.sol | 94 +++++++++++++++++++ 6 files changed, 490 insertions(+) create mode 100644 scripts/deploy.abstract-beta.s.sol create mode 100644 scripts/deploy.mock-erc20.s.sol create mode 100644 scripts/lib/AbstractBeta.sol create mode 100644 scripts/lib/AbstractBetaSepolia.sol create mode 100644 scripts/lib/safe.sol create mode 100644 test/AbstractBeta.t.sol diff --git a/scripts/deploy.abstract-beta.s.sol b/scripts/deploy.abstract-beta.s.sol new file mode 100644 index 0000000..63c45b4 --- /dev/null +++ b/scripts/deploy.abstract-beta.s.sol @@ -0,0 +1,135 @@ +import {AbstractBetaSepolia} from "./lib/AbstractBetaSepolia.sol"; +import {AbstractBeta} from "./lib/AbstractBeta.sol"; +import {MockERC20} from "../test/mocks/MockERC20.sol"; +import {GnosisTransaction} from "./lib/safe.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {Script, console2} from "forge-std/Script.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {BaseAllocation} from "../src/BaseAllocation.sol"; + +contract DeployAbstractBetaScript is Script { + function run() public { + runWithArgs( + "MetaLexMetaVest.Abstract.v0.1.0", + vm.envUint("DEPLOYER_PRIVATE_KEY"), + AbstractBetaSepolia.getDefault() + ); + } + + function runWithArgs( + string memory saltStr, + uint256 deployerPrivateKey, + AbstractBeta.Config memory config + ) public returns ( + metavestController controllerWithoutOverride, + metavestController controllerWithOverride, + AbstractBeta.GrantInfo[] memory, + GnosisTransaction[] memory + ) { + bytes32 salt = keccak256(bytes(saltStr)); + address deployer = vm.addr(deployerPrivateKey); + + console2.log(""); + console2.log("=== DeployAbstractBetaControllersScript ==="); + console2.log("saltStr: %s", saltStr); + console2.log("deployer: %s", deployer); + console2.log(""); + + AbstractBeta.GrantInfo[] memory grants = AbstractBeta.loadGrants(); + + vm.startBroadcast(deployerPrivateKey); + + // (1) Deploy factories and controllers + + VestingAllocationFactory vestingAllocationFactory = new VestingAllocationFactory{salt: salt}(); + TokenOptionFactory tokenOptionFactory = new TokenOptionFactory{salt: salt}(); + RestrictedTokenFactory restrictedTokenFactory = new RestrictedTokenFactory{salt: salt}(); + + config.controllerWithoutOverride = new metavestController{salt: bytes32(uint256(salt) + 0)}( + config.authority, // _authority + config.dao, // _dao + address(0), // _recipientOverride + address(vestingAllocationFactory), + address(tokenOptionFactory), + address(restrictedTokenFactory) + ); + + config.controllerWithOverride = new metavestController{salt: bytes32(uint256(salt) + 1)}( + config.authority, // _authority + config.dao, // _dao + config.escrowMultisig, // _recipientOverride + address(vestingAllocationFactory), + address(tokenOptionFactory), + address(restrictedTokenFactory) + ); + + console2.log("Deployed controllers:"); + console2.log(" controllerWithoutOverride: ", address(config.controllerWithoutOverride)); + console2.log(" controllerWithOverride: ", address(config.controllerWithOverride)); + console2.log(""); + + // (2) Deploy grants (must be performed by authority) + + console2.log("Creating Safe txs for grants:"); + GnosisTransaction[] memory safeTxs = _generateGrantSafeTxs(config, grants); + + vm.stopBroadcast(); + + return ( + config.controllerWithoutOverride, + config.controllerWithOverride, + grants, + safeTxs + ); + } + + function _generateGrantSafeTxs( + AbstractBeta.Config memory config, + AbstractBeta.GrantInfo[] memory grants + ) internal returns (GnosisTransaction[] memory safeTxs) { + safeTxs = new GnosisTransaction[](grants.length); + + for (uint256 i = 0; i < grants.length; i++) { + AbstractBeta.GrantInfo memory grant = grants[i]; + + metavestController controller = (grant.controllerType == AbstractBeta.ControllerType.WithoutOverride) + ? config.controllerWithoutOverride + : config.controllerWithOverride; + + safeTxs[i] = GnosisTransaction({ + to: address(controller), + value: 0, + data: abi.encodeWithSignature( + "createMetavest(uint8,address,address,(uint256,uint128,uint128,uint160,uint48,uint160,uint48,address),(uint256,bool,bool,address[])[],uint256,address,uint256,uint256)", + metavestController.metavestType.RestrictedTokenAward, + grant.grantee, + address(0), // no preference + BaseAllocation.Allocation({ + tokenContract: config.vestingToken, + tokenStreamTotal: grant.amount, + vestingCliffCredit: config.vestingAndUnlockCliff, + unlockingCliffCredit: config.vestingAndUnlockCliff, + vestingRate: config.vestingAndUnlockRate, + vestingStartTime: config.vestingAndUnlockStartTime, + unlockRate: config.vestingAndUnlockRate, + unlockStartTime: config.vestingAndUnlockStartTime + }), + new BaseAllocation.Milestone[](0), + config.exercisePrice, + address(config.paymentToken), + config.shortStopDuration, + 0 // no-op: _longStopDate + ) + }); + + console2.log(" #%d:", i + 1); + console2.log(" grantee: %s", grant.grantee); + console2.log(" amount: %d", grant.amount); + console2.log(" controllerType: %d", uint8(grant.controllerType)); + console2.log(""); + } + } +} \ No newline at end of file diff --git a/scripts/deploy.mock-erc20.s.sol b/scripts/deploy.mock-erc20.s.sol new file mode 100644 index 0000000..f17f9e6 --- /dev/null +++ b/scripts/deploy.mock-erc20.s.sol @@ -0,0 +1,35 @@ +import {Script, console2} from "forge-std/Script.sol"; +import {MockERC20} from "../test/mocks/MockERC20.sol"; + +contract DeployMockErc20Script is Script { + function run() public { + + string memory saltStr = "MetaLexMetaVest.Abstract.mockPaymentToken.dev.0"; + bytes32 salt = keccak256(bytes(saltStr)); + + string memory tokenName = "Payment Token"; + string memory tokenSymbol = "PAY"; + uint8 tokenDecimals = 6; + + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + console2.log(""); + console2.log("=== DeployMockErc20Script ==="); + console2.log("saltStr: %s", saltStr); + console2.log("deployer: %s", deployer); + console2.log(""); + + vm.startBroadcast(deployerPrivateKey); + + MockERC20 mockToken = new MockERC20{salt: salt}(tokenName, tokenSymbol, tokenDecimals); + console2.log("deployed mock token: %s", address(mockToken)); + + vm.stopBroadcast(); + + console2.log("Deployed addresses:"); + console2.log(" mock token: %s", address(mockToken)); + console2.log(" decimals: %d", mockToken.decimals()); + console2.log(""); + } +} \ No newline at end of file diff --git a/scripts/lib/AbstractBeta.sol b/scripts/lib/AbstractBeta.sol new file mode 100644 index 0000000..1aa2bbe --- /dev/null +++ b/scripts/lib/AbstractBeta.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.28; + +import {Vm} from "forge-std/Vm.sol"; +import {CommonBase} from "forge-std/Base.sol"; +import {console2} from "forge-std/Console2.sol"; +import {BaseAllocation} from "../../src/BaseAllocation.sol"; +import {metavestController} from "../../src/MetaVesTController.sol"; + +library AbstractBeta { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + error UnexpectedControllerType(uint256 controllerType); + + enum ControllerType { + WithoutOverride, + WithOverride + } + + struct Config { + + // External dependencies + + address vestingToken; + address paymentToken; + + // Authority + + address dao; + address authority; + address escrowMultisig; + + uint48 vestingAndUnlockStartTime; + uint160 vestingAndUnlockRate; + uint128 vestingAndUnlockCliff; + uint256 exercisePrice; + uint256 shortStopDuration; + + // Grants (without override) (grantee can specify their desired recipient addresses) + metavestController controllerWithoutOverride; + + // Grants (with override) (authority overrides all grantees' recipient address) + metavestController controllerWithOverride; + } + + struct GrantInfo { + address grantee; + uint256 amount; + ControllerType controllerType; + address metavest; + } + + function getDefault() internal view returns(Config memory) { + return Config({ + + // External dependencies + + vestingToken: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD: Abstract token + paymentToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // TODO TBD: USDC? + + // Authority + + dao: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD + authority: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD + escrowMultisig: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD + + // Sat Jan 1 00:00:00 UTC 2028 + // Will update the start times once finalized + vestingAndUnlockStartTime: 1830297600, // TODO TBD + vestingAndUnlockRate: 1, // TODO TBD + vestingAndUnlockCliff: 0, // TODO TBD + exercisePrice: 1e6, // TODO TBD + shortStopDuration: 0, // TODO TBD + + // Grants (without override) (grantee can specify their desired recipient addresses) + controllerWithoutOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF), // TODO TBD + + // Grants (with override) (authority overrides all grantees' recipient address) + controllerWithOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF) // TODO TBD + }); + } + + function loadGrants() internal view returns(GrantInfo[] memory grants) { + uint256 numGrantees = vm.envOr("NUM_GRANTEES", uint256(0)); + console2.log("Loading number of grantees: %d", numGrantees); + + grants = new GrantInfo[](numGrantees); + for (uint i = 0; i < numGrantees ; i++) { + grants[i] = GrantInfo({ + grantee: address(uint160(vm.envUint(string(abi.encodePacked("GRANTEE_ADDR_", vm.toString(i)))))), + amount: vm.envUint(string(abi.encodePacked("GRANTEE_AMOUNT_", vm.toString(i)))), + controllerType: ControllerType(vm.envUint(string(abi.encodePacked("GRANTEE_CONTROLLER_TYPE_", vm.toString(i))))), + metavest: address(0) + }); + + if (grants[i].controllerType > ControllerType.WithOverride) { + revert UnexpectedControllerType(uint256(grants[i].controllerType)); + } + } + } + + function parseAllocation(Config memory config, GrantInfo memory grant) internal view returns(BaseAllocation.Allocation memory) { + return BaseAllocation.Allocation({ + tokenContract: address(config.vestingToken), + tokenStreamTotal: grant.amount, + vestingCliffCredit: config.vestingAndUnlockCliff, + unlockingCliffCredit: config.vestingAndUnlockCliff, + vestingRate: config.vestingAndUnlockRate, + vestingStartTime: config.vestingAndUnlockStartTime, + unlockRate: config.vestingAndUnlockRate, + unlockStartTime: config.vestingAndUnlockStartTime + }); + } +} diff --git a/scripts/lib/AbstractBetaSepolia.sol b/scripts/lib/AbstractBetaSepolia.sol new file mode 100644 index 0000000..48e54c6 --- /dev/null +++ b/scripts/lib/AbstractBetaSepolia.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.28; + +import {Vm} from "forge-std/Vm.sol"; +import {CommonBase} from "forge-std/Base.sol"; +import {console2} from "forge-std/Console2.sol"; +import {BaseAllocation} from "../../src/BaseAllocation.sol"; +import {metavestController} from "../../src/MetaVesTController.sol"; +import {AbstractBeta} from "./AbstractBeta.sol"; + +library AbstractBetaSepolia { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function getDefault() internal view returns(AbstractBeta.Config memory) { + return AbstractBeta.Config({ + + // External dependencies + + vestingToken: 0xB9E5Ae881f36083cB914205F19EAa265D76eeF53, // mock vesting token + paymentToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // mock payment token + + // Authority + + dao: 0x8E9603BcB5D974Ed9C870510F3665F67CE5c5bDe, // dev wallet + authority: 0x8E9603BcB5D974Ed9C870510F3665F67CE5c5bDe, // dev wallet + escrowMultisig: 0x8E9603BcB5D974Ed9C870510F3665F67CE5c5bDe, // dev wallet + + // Sat Jan 1 00:00:00 UTC 2028 + // Will update the start times once finalized + vestingAndUnlockStartTime: 1830297600, + vestingAndUnlockRate: 1, + vestingAndUnlockCliff: 0, + exercisePrice: 10e6, + shortStopDuration: 0, + + // Grants (without override) (grantee can specify their desired recipient addresses) + controllerWithoutOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF), // TODO TBD + + // Grants (with override) (authority overrides all grantees' recipient address) + controllerWithOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF) // TODO TBD + }); + } +} diff --git a/scripts/lib/safe.sol b/scripts/lib/safe.sol new file mode 100644 index 0000000..be09f19 --- /dev/null +++ b/scripts/lib/safe.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +interface IGnosisSafe { + function getThreshold() external view returns (uint256); + + function isOwner(address owner) external view returns (bool); + + function getOwners() external view returns (address[] memory); + + function isModuleEnabled(address module) external view returns (bool); + + function setGuard(address guard) external; + + function addOwnerWithThreshold(address owner, uint256 threshold) external; + + function execTransaction( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes memory signatures + ) external payable returns (bool success); + + function encodeTransactionData( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes memory); + + function nonce() external view returns (uint256); + + function setup( + address[] calldata _owners, + uint256 _threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; +} + +struct GnosisTransaction { + address to; + uint256 value; + bytes data; +} + +interface IMultiSendCallOnly { + function multiSend(bytes memory transactions) external payable; +} + +interface ISafeProxyFactory { + function createProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce) external returns (address proxy); +} diff --git a/test/AbstractBeta.t.sol b/test/AbstractBeta.t.sol new file mode 100644 index 0000000..23877ff --- /dev/null +++ b/test/AbstractBeta.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; +import {BaseAllocation} from "../src/BaseAllocation.sol"; +import {RestrictedTokenAward} from "../src/RestrictedTokenAllocation.sol"; +import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; +import {Test, console2} from "forge-std/Test.sol"; +import {TokenOptionFactory} from "../src/TokenOptionFactory.sol"; +import {VestingAllocationFactory} from "../src/VestingAllocationFactory.sol"; +import {metavestController} from "../src/MetaVesTController.sol"; +import {DeployAbstractBetaScript} from "../scripts/deploy.abstract-beta.s.sol"; +import {AbstractBetaSepolia} from "../scripts/lib/AbstractBetaSepolia.sol"; +import {AbstractBeta} from "../scripts/lib/AbstractBeta.sol"; +import {GnosisTransaction} from "../scripts/lib/safe.sol"; + +contract AbstractBetaTest is Test { + string saltStr = "AbstractBetaTest"; + bytes32 salt = keccak256(bytes(saltStr)); + + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer; + + AbstractBeta.Config config = AbstractBetaSepolia.getDefault(); + AbstractBeta.GrantInfo[] grants; + + /// @notice Assumes Sepolia testnet + function setUp() public { + (deployer, deployerPrivateKey) = makeAddrAndKey("deployer"); + + // Deploy controllers and prepare txs for grants + AbstractBeta.GrantInfo[] memory loadedGrants; + GnosisTransaction[] memory safeTxs; + ( + config.controllerWithoutOverride, + config.controllerWithOverride, + loadedGrants, + safeTxs + ) = (new DeployAbstractBetaScript()).runWithArgs( + saltStr, + deployerPrivateKey, + config + ); + + // Simulate authority creating the grants + vm.startPrank(config.authority); + deal(address(config.vestingToken), config.authority, 100_000_000_000 ether); + ERC20(config.vestingToken).approve(address(config.controllerWithoutOverride), 100_000_000_000 ether); + ERC20(config.vestingToken).approve(address(config.controllerWithOverride), 100_000_000_000 ether); + console2.log("Deploying grants:"); + for (uint256 i = 0; i < safeTxs.length; i++) { + (bool success, bytes memory ret) = safeTxs[i].to.call{value: safeTxs[i].value}(safeTxs[i].data); + assertTrue(success, string(abi.encodePacked("call #", vm.toString(i), " failed: ", vm.toString(ret)))); + loadedGrants[i].metavest = abi.decode(ret, (address)); + grants.push(loadedGrants[i]); // Save it to storage + console2.log(" #%s: %s", vm.toString(i), loadedGrants[i].metavest); + } + console2.log(""); + vm.stopPrank(); + } + + function test_sanityCheck() public { + // Verify grant parameters + for (uint256 i = 0; i < grants.length; i++) { + RestrictedTokenAward vault = RestrictedTokenAward(grants[i].metavest); + + metavestController controller = (grants[i].controllerType == AbstractBeta.ControllerType.WithoutOverride) + ? config.controllerWithoutOverride + : config.controllerWithOverride; + + assertEq(vault.controller(), address(controller), string(abi.encodePacked("unexpected controller for grant #", vm.toString(i)))); + + ( + uint256 tokenStreamTotal, + uint128 vestingCliffCredit, + uint128 unlockingCliffCredit, + uint160 vestingRate, + uint48 vestingStartTime, + uint160 unlockRate, + uint48 unlockStartTime, + address tokenContract + ) = vault.allocation(); + assertEq(tokenStreamTotal, grants[i].amount, string(abi.encodePacked("unexpected tokenStreamTotal for grant #", vm.toString(i)))); + assertEq(vestingCliffCredit, config.vestingAndUnlockCliff, string(abi.encodePacked("unexpected vestingCliffCredit for grant #", vm.toString(i)))); + assertEq(unlockingCliffCredit, config.vestingAndUnlockCliff, string(abi.encodePacked("unexpected unlockingCliffCredit for grant #", vm.toString(i)))); + assertEq(vestingRate, config.vestingAndUnlockRate, string(abi.encodePacked("unexpected vestingRate for grant #", vm.toString(i)))); + assertEq(unlockRate, config.vestingAndUnlockRate, string(abi.encodePacked("unexpected unlockRate for grant #", vm.toString(i)))); + assertEq(vestingStartTime, config.vestingAndUnlockStartTime, string(abi.encodePacked("unexpected vestingStartTime for grant #", vm.toString(i)))); + assertEq(unlockStartTime, config.vestingAndUnlockStartTime, string(abi.encodePacked("unexpected unlockStartTime for grant #", vm.toString(i)))); + assertEq(tokenContract, config.vestingToken, string(abi.encodePacked("unexpected vestingToken for grant #", vm.toString(i)))); + assertEq(vault.paymentToken(), config.paymentToken, string(abi.encodePacked("unexpected paymentToken for grant #", vm.toString(i)))); + } + } +} \ No newline at end of file From f3ae47d68dd8feeac06d363b9e2038b087ba1709 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 18 Dec 2025 16:37:16 -0800 Subject: [PATCH 10/15] test: deploy on sepolia --- scripts/lib/AbstractBeta.sol | 6 ++ test/AbstractBeta.t.sol | 117 +++++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/scripts/lib/AbstractBeta.sol b/scripts/lib/AbstractBeta.sol index 1aa2bbe..29888f2 100644 --- a/scripts/lib/AbstractBeta.sol +++ b/scripts/lib/AbstractBeta.sol @@ -111,4 +111,10 @@ library AbstractBeta { unlockStartTime: config.vestingAndUnlockStartTime }); } + + function getController(Config memory config, ControllerType controllerType) external view returns(metavestController) { + return (controllerType == AbstractBeta.ControllerType.WithoutOverride) + ? config.controllerWithoutOverride + : config.controllerWithOverride; + } } diff --git a/test/AbstractBeta.t.sol b/test/AbstractBeta.t.sol index 23877ff..1aaf2e8 100644 --- a/test/AbstractBeta.t.sol +++ b/test/AbstractBeta.t.sol @@ -23,6 +23,9 @@ contract AbstractBetaTest is Test { AbstractBeta.Config config = AbstractBetaSepolia.getDefault(); AbstractBeta.GrantInfo[] grants; + string setName = "grants"; +// string setNameVestingStartTime = "updateMetavestVestingStartTime"; +// string setNameUnlockStartTime = "updateMetavestUnlockStartTime"; /// @notice Assumes Sepolia testnet function setUp() public { @@ -47,6 +50,15 @@ contract AbstractBetaTest is Test { deal(address(config.vestingToken), config.authority, 100_000_000_000 ether); ERC20(config.vestingToken).approve(address(config.controllerWithoutOverride), 100_000_000_000 ether); ERC20(config.vestingToken).approve(address(config.controllerWithOverride), 100_000_000_000 ether); + + // TODO this should be part of Safe txs, too + config.controllerWithoutOverride.createSet(setName); + config.controllerWithOverride.createSet(setName); +// config.controllerWithoutOverride.createSet(setNameVestingStartTime); +// config.controllerWithOverride.createSet(setNameVestingStartTime); +// config.controllerWithoutOverride.createSet(setNameUnlockStartTime); +// config.controllerWithOverride.createSet(setNameUnlockStartTime); + console2.log("Deploying grants:"); for (uint256 i = 0; i < safeTxs.length; i++) { (bool success, bytes memory ret) = safeTxs[i].to.call{value: safeTxs[i].value}(safeTxs[i].data); @@ -54,6 +66,11 @@ contract AbstractBetaTest is Test { loadedGrants[i].metavest = abi.decode(ret, (address)); grants.push(loadedGrants[i]); // Save it to storage console2.log(" #%s: %s", vm.toString(i), loadedGrants[i].metavest); + + // TODO this should be part of Safe txs, too + metavestController(safeTxs[i].to).addMetaVestToSet(setName, loadedGrants[i].metavest); +// metavestController(safeTxs[i].to).addMetaVestToSet(setNameVestingStartTime, loadedGrants[i].metavest); +// metavestController(safeTxs[i].to).addMetaVestToSet(setNameUnlockStartTime, loadedGrants[i].metavest); } console2.log(""); vm.stopPrank(); @@ -64,9 +81,7 @@ contract AbstractBetaTest is Test { for (uint256 i = 0; i < grants.length; i++) { RestrictedTokenAward vault = RestrictedTokenAward(grants[i].metavest); - metavestController controller = (grants[i].controllerType == AbstractBeta.ControllerType.WithoutOverride) - ? config.controllerWithoutOverride - : config.controllerWithOverride; + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); assertEq(vault.controller(), address(controller), string(abi.encodePacked("unexpected controller for grant #", vm.toString(i)))); @@ -91,4 +106,98 @@ contract AbstractBetaTest is Test { assertEq(vault.paymentToken(), config.paymentToken, string(abi.encodePacked("unexpected paymentToken for grant #", vm.toString(i)))); } } -} \ No newline at end of file + + /// @notice Authority should be able to update the start times + function test_updateStartTimes() public { + uint48 now = uint48(block.timestamp); + + // 60 days later + vm.warp(now + 60 days); + + // (1) Propose amendment for updating vestingStartTime + + vm.startPrank(config.authority); + config.controllerWithoutOverride.proposeMajorityMetavestAmendment( + setName, + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector( + metavestController.updateMetavestVestingStartTime.selector, + address(0), // no-op + now + 90 days + ) + ); + config.controllerWithOverride.proposeMajorityMetavestAmendment( + setName, + metavestController.updateMetavestVestingStartTime.selector, + abi.encodeWithSelector( + metavestController.updateMetavestVestingStartTime.selector, + address(0), // no-op + now + 90 days + ) + ); + vm.stopPrank(); + + // Approve amendment + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + vm.prank(grants[i].grantee); + controller.voteOnMetavestAmendment(grants[i].metavest, setName, metavestController.updateMetavestVestingStartTime.selector, true); + } + + // Execute amendment + vm.startPrank(config.authority); + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + controller.updateMetavestVestingStartTime(grants[i].metavest, now + 90 days); + + { + (,,,, uint48 vestingStartTime,, uint48 unlockStartTime,) = RestrictedTokenAward(grants[0].metavest).allocation(); + assertEq(vestingStartTime, now + 90 days, string(abi.encodePacked("unexpected vestingStartTime after amendment for grant #", vm.toString(i)))); + } + } + vm.stopPrank(); + + // (2) Propose amendment for updating unlockStartTime + + vm.startPrank(config.authority); + config.controllerWithoutOverride.proposeMajorityMetavestAmendment( + setName, + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector( + metavestController.updateMetavestUnlockStartTime.selector, + address(0), // no-op + now + 90 days + ) + ); + config.controllerWithOverride.proposeMajorityMetavestAmendment( + setName, + metavestController.updateMetavestUnlockStartTime.selector, + abi.encodeWithSelector( + metavestController.updateMetavestUnlockStartTime.selector, + address(0), // no-op + now + 90 days + ) + ); + vm.stopPrank(); + + // Approve amendment + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + vm.prank(grants[i].grantee); + controller.voteOnMetavestAmendment(grants[i].metavest, setName, metavestController.updateMetavestUnlockStartTime.selector, true); + } + + // Execute amendment + vm.startPrank(config.authority); + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + controller.updateMetavestUnlockStartTime(grants[i].metavest, now + 90 days); + + { + (,,,, uint48 vestingStartTime,, uint48 unlockStartTime,) = RestrictedTokenAward(grants[0].metavest).allocation(); + assertEq(unlockStartTime, now + 90 days, string(abi.encodePacked("unexpected unlockStartTime after amendment for grant #", vm.toString(i)))); + } + } + vm.stopPrank(); + } +} From 54430490d237b030432665c1aeba3dec7ea694c8 Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 18 Dec 2025 21:54:38 -0800 Subject: [PATCH 11/15] test: minor refactoring --- test/AbstractBeta.t.sol | 44 ++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/test/AbstractBeta.t.sol b/test/AbstractBeta.t.sol index 1aaf2e8..e6e4401 100644 --- a/test/AbstractBeta.t.sol +++ b/test/AbstractBeta.t.sol @@ -23,9 +23,6 @@ contract AbstractBetaTest is Test { AbstractBeta.Config config = AbstractBetaSepolia.getDefault(); AbstractBeta.GrantInfo[] grants; - string setName = "grants"; -// string setNameVestingStartTime = "updateMetavestVestingStartTime"; -// string setNameUnlockStartTime = "updateMetavestUnlockStartTime"; /// @notice Assumes Sepolia testnet function setUp() public { @@ -51,14 +48,6 @@ contract AbstractBetaTest is Test { ERC20(config.vestingToken).approve(address(config.controllerWithoutOverride), 100_000_000_000 ether); ERC20(config.vestingToken).approve(address(config.controllerWithOverride), 100_000_000_000 ether); - // TODO this should be part of Safe txs, too - config.controllerWithoutOverride.createSet(setName); - config.controllerWithOverride.createSet(setName); -// config.controllerWithoutOverride.createSet(setNameVestingStartTime); -// config.controllerWithOverride.createSet(setNameVestingStartTime); -// config.controllerWithoutOverride.createSet(setNameUnlockStartTime); -// config.controllerWithOverride.createSet(setNameUnlockStartTime); - console2.log("Deploying grants:"); for (uint256 i = 0; i < safeTxs.length; i++) { (bool success, bytes memory ret) = safeTxs[i].to.call{value: safeTxs[i].value}(safeTxs[i].data); @@ -66,11 +55,6 @@ contract AbstractBetaTest is Test { loadedGrants[i].metavest = abi.decode(ret, (address)); grants.push(loadedGrants[i]); // Save it to storage console2.log(" #%s: %s", vm.toString(i), loadedGrants[i].metavest); - - // TODO this should be part of Safe txs, too - metavestController(safeTxs[i].to).addMetaVestToSet(setName, loadedGrants[i].metavest); -// metavestController(safeTxs[i].to).addMetaVestToSet(setNameVestingStartTime, loadedGrants[i].metavest); -// metavestController(safeTxs[i].to).addMetaVestToSet(setNameUnlockStartTime, loadedGrants[i].metavest); } console2.log(""); vm.stopPrank(); @@ -114,7 +98,31 @@ contract AbstractBetaTest is Test { // 60 days later vm.warp(now + 60 days); - // (1) Propose amendment for updating vestingStartTime + // (1) Add all vaults to sets + + string memory setName = "grants"; +// string setNameVestingStartTime = "updateMetavestVestingStartTime"; +// string setNameUnlockStartTime = "updateMetavestUnlockStartTime"; + + vm.startPrank(config.authority); + + config.controllerWithoutOverride.createSet(setName); + config.controllerWithOverride.createSet(setName); +// config.controllerWithoutOverride.createSet(setNameVestingStartTime); +// config.controllerWithOverride.createSet(setNameVestingStartTime); +// config.controllerWithoutOverride.createSet(setNameUnlockStartTime); +// config.controllerWithOverride.createSet(setNameUnlockStartTime); + + for (uint256 i = 0; i < grants.length; i++) { + metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); + controller.addMetaVestToSet(setName, grants[i].metavest); +// metavestController(safeTxs[i].to).addMetaVestToSet(setNameVestingStartTime, loadedGrants[i].metavest); +// metavestController(safeTxs[i].to).addMetaVestToSet(setNameUnlockStartTime, loadedGrants[i].metavest); + } + + vm.stopPrank(); + + // (2a) Propose amendment for updating vestingStartTime vm.startPrank(config.authority); config.controllerWithoutOverride.proposeMajorityMetavestAmendment( @@ -157,7 +165,7 @@ contract AbstractBetaTest is Test { } vm.stopPrank(); - // (2) Propose amendment for updating unlockStartTime + // (2b) Propose amendment for updating unlockStartTime vm.startPrank(config.authority); config.controllerWithoutOverride.proposeMajorityMetavestAmendment( From c53442a94506b971811a0f1184282baaa775882a Mon Sep 17 00:00:00 2001 From: detoo Date: Thu, 18 Dec 2025 22:08:28 -0800 Subject: [PATCH 12/15] test: test withdrawal and minor refactoring --- scripts/lib/AbstractBetaSepolia.sol | 2 +- test/AbstractBeta.t.sol | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/lib/AbstractBetaSepolia.sol b/scripts/lib/AbstractBetaSepolia.sol index 48e54c6..8f844b0 100644 --- a/scripts/lib/AbstractBetaSepolia.sol +++ b/scripts/lib/AbstractBetaSepolia.sol @@ -28,7 +28,7 @@ library AbstractBetaSepolia { // Sat Jan 1 00:00:00 UTC 2028 // Will update the start times once finalized vestingAndUnlockStartTime: 1830297600, - vestingAndUnlockRate: 1, + vestingAndUnlockRate: 100 ether, vestingAndUnlockCliff: 0, exercisePrice: 10e6, shortStopDuration: 0, diff --git a/test/AbstractBeta.t.sol b/test/AbstractBeta.t.sol index e6e4401..4015658 100644 --- a/test/AbstractBeta.t.sol +++ b/test/AbstractBeta.t.sol @@ -207,5 +207,21 @@ contract AbstractBetaTest is Test { } } vm.stopPrank(); + + // Simulate and verify withdrawal on new schedules + + vm.warp(now + 90 days + 200); // enough time to withdraw all + + for (uint256 i = 0; i < grants.length; i++) { + ERC20 vestingToken = ERC20(config.vestingToken); + RestrictedTokenAward vault = RestrictedTokenAward(grants[i].metavest); + address recipient = vault.getRecipient(); + uint256 vestingTokenBalanceBefore = vestingToken.balanceOf(recipient); + + vm.startPrank(grants[i].grantee); + vault.withdraw(grants[i].amount); + assertEq(vestingToken.balanceOf(recipient) - vestingTokenBalanceBefore, grants[i].amount, "unexpected vesting token amount after withdrawal"); + vm.stopPrank(); + } } } From 9716057dabcabdd3ca447d56dcb0566e2d8f1993 Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 22 Dec 2025 15:10:07 -0800 Subject: [PATCH 13/15] wip: feat: more flexible grantee parameters --- .gitignore | 2 +- README.md | 4 ++ env.production.abstract-beta.template | 34 +++++++++++ scripts/deploy.abstract-beta.s.sol | 25 +++++--- scripts/lib/AbstractBeta.sol | 32 ++++++---- scripts/lib/AbstractBetaSepolia.sol | 4 +- test/AbstractBeta.t.sol | 88 +++++++-------------------- 7 files changed, 98 insertions(+), 91 deletions(-) create mode 100644 env.production.abstract-beta.template diff --git a/.gitignore b/.gitignore index 8345343..38af9a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ cache/ out/ -.env +.env* test-command.txt broadcast/ .DS_Store diff --git a/README.md b/README.md index 7f1ab80..978d333 100644 --- a/README.md +++ b/README.md @@ -179,3 +179,7 @@ To set up the project locally, follow these steps: ```base forge build --optimize --optimizer-runs 200 --use solc:0.8.28 --sizes ``` + +## Deployment + +[//]: # (TODO Explain env vars) diff --git a/env.production.abstract-beta.template b/env.production.abstract-beta.template new file mode 100644 index 0000000..f36cb84 --- /dev/null +++ b/env.production.abstract-beta.template @@ -0,0 +1,34 @@ +# TODO (1) fill in your deployer private key +DEPLOYER_PRIVATE_KEY= + +# TODO (2) Update number of grantees +NUM_GRANTEES=2 + +# GRANTEE_CONTROLLER_TYPE: +# - 0: without recipient override (grantee can specify their desired recipient addresses) +# - 1: with recipient override (authority overrides all grantees' recipient address) + +# Instead of the commonly-used "hire date", MetaVesT's vesting start time is defined as "when the cliff happens". +# Ex. 10000 token (18-decimals) grants vesting over 4 years, hired on 2025/01/01 with a 6-month vesting cliff, unlock over 1 year (unlock start time TBD) +# Parameters may look like the example below: + +GRANTEE_ADDR_0=0x48d206948C366396a86A449DdD085FDbfC280B4b # grantee EOA (grantee must use this account to send txs to the grant smart contract) +GRANTEE_AMOUNT_0=10000000000000000000000 # 10000 token +GRANTEE_VESTING_START_TIME_0=1751328000 # 2025/07/01 = 6 months after hired date +GRANTEE_VESTING_CLIFF_CREDIT_0=1250000000000000000000 # (10000 * 6 / 48) * 1e18 +GRANTEE_VESTING_RATE_0=79274479959411 # 10000e18 // (4 * 365 * 24 * 3600) +GRANTEE_UNLOCKING_CLIFF_CREDIT_0=0 +GRANTEE_UNLOCK_RATE_0=317097919837645 # 10000e18 // (1 * 365 * 24 * 3600) +GRANTEE_CONTROLLER_TYPE_0=0 + +# Ex2. 20000 token (18-decimals) grants vesting over 4 years, hired on 2024/10/01 with a 6-month vesting cliff, unlock over 1 year (unlock start time TBD) +GRANTEE_ADDR_1=0x5ff4e90Efa2B88cf3cA92D63d244a78a88219Abf # grantee EOA (grantee must use this account to send txs to the grant smart contract) +GRANTEE_AMOUNT_1=20000000000000000000000 # 20000 token (18-decimals) +GRANTEE_VESTING_START_TIME_1=1743465600 # 2025/04/01 = 6 months after hired date +GRANTEE_VESTING_CLIFF_CREDIT_1=2500000000000000000000 # (20000 * 6 / 48) * 1e18 +GRANTEE_VESTING_RATE_1=158548959918822 # 20000e18 // (4 * 365 * 24 * 3600) +GRANTEE_UNLOCKING_CLIFF_CREDIT_1=0 +GRANTEE_UNLOCK_RATE_1=634195839675291 # 20000e18 // (1 * 365 * 24 * 3600) +GRANTEE_CONTROLLER_TYPE_1=1 + +# TODO (3) Update the above grantee parameters as needed diff --git a/scripts/deploy.abstract-beta.s.sol b/scripts/deploy.abstract-beta.s.sol index 63c45b4..4070a23 100644 --- a/scripts/deploy.abstract-beta.s.sol +++ b/scripts/deploy.abstract-beta.s.sol @@ -13,9 +13,15 @@ import {BaseAllocation} from "../src/BaseAllocation.sol"; contract DeployAbstractBetaScript is Script { function run() public { runWithArgs( - "MetaLexMetaVest.Abstract.v0.1.0", + // Ethereum mainnet + "MetaLexMetaVest.Abstract.v1.0.0", vm.envUint("DEPLOYER_PRIVATE_KEY"), AbstractBetaSepolia.getDefault() + + // Sepolia +// "MetaLexMetaVest.Abstract.v0.1.0", +// vm.envUint("DEPLOYER_PRIVATE_KEY"), +// AbstractBetaSepolia.getDefault() ); } @@ -110,12 +116,12 @@ contract DeployAbstractBetaScript is Script { BaseAllocation.Allocation({ tokenContract: config.vestingToken, tokenStreamTotal: grant.amount, - vestingCliffCredit: config.vestingAndUnlockCliff, - unlockingCliffCredit: config.vestingAndUnlockCliff, - vestingRate: config.vestingAndUnlockRate, - vestingStartTime: config.vestingAndUnlockStartTime, - unlockRate: config.vestingAndUnlockRate, - unlockStartTime: config.vestingAndUnlockStartTime + vestingCliffCredit: grant.vestingCliffCredit, + unlockingCliffCredit: grant.unlockingCliffCredit, + vestingRate: grant.vestingRate, + vestingStartTime: grant.vestingStartTime, + unlockRate: grant.unlockRate, + unlockStartTime: config.unlockStartTime }), new BaseAllocation.Milestone[](0), config.exercisePrice, @@ -128,6 +134,11 @@ contract DeployAbstractBetaScript is Script { console2.log(" #%d:", i + 1); console2.log(" grantee: %s", grant.grantee); console2.log(" amount: %d", grant.amount); + console2.log(" vestingStartTime: %d", grant.vestingStartTime); + console2.log(" vestingCliffCredit: %d", grant.vestingCliffCredit); + console2.log(" vestingRate: %d", grant.vestingRate); + console2.log(" unlockingCliffCredit: %d", grant.unlockingCliffCredit); + console2.log(" unlockRate: %d", grant.unlockRate); console2.log(" controllerType: %d", uint8(grant.controllerType)); console2.log(""); } diff --git a/scripts/lib/AbstractBeta.sol b/scripts/lib/AbstractBeta.sol index 29888f2..f9eebf4 100644 --- a/scripts/lib/AbstractBeta.sol +++ b/scripts/lib/AbstractBeta.sol @@ -30,9 +30,7 @@ library AbstractBeta { address authority; address escrowMultisig; - uint48 vestingAndUnlockStartTime; - uint160 vestingAndUnlockRate; - uint128 vestingAndUnlockCliff; + uint48 unlockStartTime; uint256 exercisePrice; uint256 shortStopDuration; @@ -46,6 +44,12 @@ library AbstractBeta { struct GrantInfo { address grantee; uint256 amount; + uint128 vestingCliffCredit; + uint128 unlockingCliffCredit; + uint160 vestingRate; + uint48 vestingStartTime; + uint160 unlockRate; + // Note unlockStartTime, exercisePrice and shortStopDuration are universal and not grant-specific ControllerType controllerType; address metavest; } @@ -65,10 +69,7 @@ library AbstractBeta { escrowMultisig: 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF, // TODO TBD // Sat Jan 1 00:00:00 UTC 2028 - // Will update the start times once finalized - vestingAndUnlockStartTime: 1830297600, // TODO TBD - vestingAndUnlockRate: 1, // TODO TBD - vestingAndUnlockCliff: 0, // TODO TBD + unlockStartTime: 1830297600, // TODO TBD exercisePrice: 1e6, // TODO TBD shortStopDuration: 0, // TODO TBD @@ -89,6 +90,11 @@ library AbstractBeta { grants[i] = GrantInfo({ grantee: address(uint160(vm.envUint(string(abi.encodePacked("GRANTEE_ADDR_", vm.toString(i)))))), amount: vm.envUint(string(abi.encodePacked("GRANTEE_AMOUNT_", vm.toString(i)))), + vestingStartTime: uint48(vm.envUint(string(abi.encodePacked("GRANTEE_VESTING_START_TIME_", vm.toString(i))))), + vestingCliffCredit: uint128(vm.envUint(string(abi.encodePacked("GRANTEE_VESTING_CLIFF_CREDIT_", vm.toString(i))))), + vestingRate: uint160(vm.envUint(string(abi.encodePacked("GRANTEE_VESTING_RATE_", vm.toString(i))))), + unlockingCliffCredit: uint128(vm.envUint(string(abi.encodePacked("GRANTEE_UNLOCKING_CLIFF_CREDIT_", vm.toString(i))))), + unlockRate: uint160(vm.envUint(string(abi.encodePacked("GRANTEE_UNLOCK_RATE_", vm.toString(i))))), controllerType: ControllerType(vm.envUint(string(abi.encodePacked("GRANTEE_CONTROLLER_TYPE_", vm.toString(i))))), metavest: address(0) }); @@ -103,12 +109,12 @@ library AbstractBeta { return BaseAllocation.Allocation({ tokenContract: address(config.vestingToken), tokenStreamTotal: grant.amount, - vestingCliffCredit: config.vestingAndUnlockCliff, - unlockingCliffCredit: config.vestingAndUnlockCliff, - vestingRate: config.vestingAndUnlockRate, - vestingStartTime: config.vestingAndUnlockStartTime, - unlockRate: config.vestingAndUnlockRate, - unlockStartTime: config.vestingAndUnlockStartTime + vestingCliffCredit: grant.vestingCliffCredit, + unlockingCliffCredit: grant.unlockingCliffCredit, + vestingRate: grant.vestingRate, + vestingStartTime: grant.vestingStartTime, + unlockRate: grant.unlockRate, + unlockStartTime: config.unlockStartTime }); } diff --git a/scripts/lib/AbstractBetaSepolia.sol b/scripts/lib/AbstractBetaSepolia.sol index 8f844b0..6943a8c 100644 --- a/scripts/lib/AbstractBetaSepolia.sol +++ b/scripts/lib/AbstractBetaSepolia.sol @@ -27,9 +27,7 @@ library AbstractBetaSepolia { // Sat Jan 1 00:00:00 UTC 2028 // Will update the start times once finalized - vestingAndUnlockStartTime: 1830297600, - vestingAndUnlockRate: 100 ether, - vestingAndUnlockCliff: 0, + unlockStartTime: 1830297600, exercisePrice: 10e6, shortStopDuration: 0, diff --git a/test/AbstractBeta.t.sol b/test/AbstractBeta.t.sol index 4015658..286e6d4 100644 --- a/test/AbstractBeta.t.sol +++ b/test/AbstractBeta.t.sol @@ -13,6 +13,7 @@ import {DeployAbstractBetaScript} from "../scripts/deploy.abstract-beta.s.sol"; import {AbstractBetaSepolia} from "../scripts/lib/AbstractBetaSepolia.sol"; import {AbstractBeta} from "../scripts/lib/AbstractBeta.sol"; import {GnosisTransaction} from "../scripts/lib/safe.sol"; +import {MockERC20} from "../test/mocks/MockERC20.sol"; contract AbstractBetaTest is Test { string saltStr = "AbstractBetaTest"; @@ -21,13 +22,20 @@ contract AbstractBetaTest is Test { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); address deployer; - AbstractBeta.Config config = AbstractBetaSepolia.getDefault(); + // Test against mainnet configs + AbstractBeta.Config config = AbstractBeta.getDefault(); AbstractBeta.GrantInfo[] grants; /// @notice Assumes Sepolia testnet function setUp() public { (deployer, deployerPrivateKey) = makeAddrAndKey("deployer"); + // Simulate the authority deploy their vesting token + vm.startPrank(config.authority); + config.vestingToken = address(new MockERC20("Vesting Token", "VEST", 18)); + MockERC20(config.vestingToken).mint(config.authority, 100_000_000_000 ether); + vm.stopPrank(); + // Deploy controllers and prepare txs for grants AbstractBeta.GrantInfo[] memory loadedGrants; GnosisTransaction[] memory safeTxs; @@ -44,7 +52,6 @@ contract AbstractBetaTest is Test { // Simulate authority creating the grants vm.startPrank(config.authority); - deal(address(config.vestingToken), config.authority, 100_000_000_000 ether); ERC20(config.vestingToken).approve(address(config.controllerWithoutOverride), 100_000_000_000 ether); ERC20(config.vestingToken).approve(address(config.controllerWithOverride), 100_000_000_000 ether); @@ -80,92 +87,39 @@ contract AbstractBetaTest is Test { address tokenContract ) = vault.allocation(); assertEq(tokenStreamTotal, grants[i].amount, string(abi.encodePacked("unexpected tokenStreamTotal for grant #", vm.toString(i)))); - assertEq(vestingCliffCredit, config.vestingAndUnlockCliff, string(abi.encodePacked("unexpected vestingCliffCredit for grant #", vm.toString(i)))); - assertEq(unlockingCliffCredit, config.vestingAndUnlockCliff, string(abi.encodePacked("unexpected unlockingCliffCredit for grant #", vm.toString(i)))); - assertEq(vestingRate, config.vestingAndUnlockRate, string(abi.encodePacked("unexpected vestingRate for grant #", vm.toString(i)))); - assertEq(unlockRate, config.vestingAndUnlockRate, string(abi.encodePacked("unexpected unlockRate for grant #", vm.toString(i)))); - assertEq(vestingStartTime, config.vestingAndUnlockStartTime, string(abi.encodePacked("unexpected vestingStartTime for grant #", vm.toString(i)))); - assertEq(unlockStartTime, config.vestingAndUnlockStartTime, string(abi.encodePacked("unexpected unlockStartTime for grant #", vm.toString(i)))); + assertEq(vestingCliffCredit, grants[i].vestingCliffCredit, string(abi.encodePacked("unexpected vestingCliffCredit for grant #", vm.toString(i)))); + assertEq(unlockingCliffCredit, grants[i].unlockingCliffCredit, string(abi.encodePacked("unexpected unlockingCliffCredit for grant #", vm.toString(i)))); + assertEq(vestingRate, grants[i].vestingRate, string(abi.encodePacked("unexpected vestingRate for grant #", vm.toString(i)))); + assertEq(unlockRate, grants[i].unlockRate, string(abi.encodePacked("unexpected unlockRate for grant #", vm.toString(i)))); + assertEq(vestingStartTime, grants[i].vestingStartTime, string(abi.encodePacked("unexpected vestingStartTime for grant #", vm.toString(i)))); + assertEq(unlockStartTime, config.unlockStartTime, string(abi.encodePacked("unexpected unlockStartTime for grant #", vm.toString(i)))); assertEq(tokenContract, config.vestingToken, string(abi.encodePacked("unexpected vestingToken for grant #", vm.toString(i)))); assertEq(vault.paymentToken(), config.paymentToken, string(abi.encodePacked("unexpected paymentToken for grant #", vm.toString(i)))); } } - /// @notice Authority should be able to update the start times - function test_updateStartTimes() public { - uint48 now = uint48(block.timestamp); - - // 60 days later - vm.warp(now + 60 days); + /// @notice Authority should be able to update the unlock start times later + function test_updateUnlockStartTimes() public { + uint48 now = 1772323200; // 2026/03/01 + vm.warp(now); // (1) Add all vaults to sets string memory setName = "grants"; -// string setNameVestingStartTime = "updateMetavestVestingStartTime"; -// string setNameUnlockStartTime = "updateMetavestUnlockStartTime"; vm.startPrank(config.authority); config.controllerWithoutOverride.createSet(setName); config.controllerWithOverride.createSet(setName); -// config.controllerWithoutOverride.createSet(setNameVestingStartTime); -// config.controllerWithOverride.createSet(setNameVestingStartTime); -// config.controllerWithoutOverride.createSet(setNameUnlockStartTime); -// config.controllerWithOverride.createSet(setNameUnlockStartTime); for (uint256 i = 0; i < grants.length; i++) { metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); controller.addMetaVestToSet(setName, grants[i].metavest); -// metavestController(safeTxs[i].to).addMetaVestToSet(setNameVestingStartTime, loadedGrants[i].metavest); -// metavestController(safeTxs[i].to).addMetaVestToSet(setNameUnlockStartTime, loadedGrants[i].metavest); } vm.stopPrank(); - // (2a) Propose amendment for updating vestingStartTime - - vm.startPrank(config.authority); - config.controllerWithoutOverride.proposeMajorityMetavestAmendment( - setName, - metavestController.updateMetavestVestingStartTime.selector, - abi.encodeWithSelector( - metavestController.updateMetavestVestingStartTime.selector, - address(0), // no-op - now + 90 days - ) - ); - config.controllerWithOverride.proposeMajorityMetavestAmendment( - setName, - metavestController.updateMetavestVestingStartTime.selector, - abi.encodeWithSelector( - metavestController.updateMetavestVestingStartTime.selector, - address(0), // no-op - now + 90 days - ) - ); - vm.stopPrank(); - - // Approve amendment - for (uint256 i = 0; i < grants.length; i++) { - metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); - vm.prank(grants[i].grantee); - controller.voteOnMetavestAmendment(grants[i].metavest, setName, metavestController.updateMetavestVestingStartTime.selector, true); - } - - // Execute amendment - vm.startPrank(config.authority); - for (uint256 i = 0; i < grants.length; i++) { - metavestController controller = AbstractBeta.getController(config, grants[i].controllerType); - controller.updateMetavestVestingStartTime(grants[i].metavest, now + 90 days); - - { - (,,,, uint48 vestingStartTime,, uint48 unlockStartTime,) = RestrictedTokenAward(grants[0].metavest).allocation(); - assertEq(vestingStartTime, now + 90 days, string(abi.encodePacked("unexpected vestingStartTime after amendment for grant #", vm.toString(i)))); - } - } - vm.stopPrank(); - - // (2b) Propose amendment for updating unlockStartTime + // (2) Propose amendment for updating unlockStartTime vm.startPrank(config.authority); config.controllerWithoutOverride.proposeMajorityMetavestAmendment( @@ -210,7 +164,7 @@ contract AbstractBetaTest is Test { // Simulate and verify withdrawal on new schedules - vm.warp(now + 90 days + 200); // enough time to withdraw all + vm.warp(now + 365 days * 4); // enough time to withdraw all for (uint256 i = 0; i < grants.length; i++) { ERC20 vestingToken = ERC20(config.vestingToken); From df46cf804ca28f5a899d992907432607f02fd8fb Mon Sep 17 00:00:00 2001 From: detoo Date: Mon, 22 Dec 2025 15:45:02 -0800 Subject: [PATCH 14/15] feat: more flexible grantee parameters. Export json file for Safe txs. Add README --- README.md | 21 ++++- foundry.toml | 5 ++ scripts/deploy.abstract-beta.s.sol | 139 +++++++++++++++++++++-------- scripts/lib/SafeUtils.sol | 71 +++++++++++++++ test/AbstractBeta.t.sol | 24 +++-- 5 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 foundry.toml create mode 100644 scripts/lib/SafeUtils.sol diff --git a/README.md b/README.md index 978d333..3682d88 100644 --- a/README.md +++ b/README.md @@ -182,4 +182,23 @@ To set up the project locally, follow these steps: ## Deployment -[//]: # (TODO Explain env vars) +```bash +# Setup env var for deployment +cp env.production.abstract-beta.template .env +# Modify .env and update all relevant values + +# To dry-run the deploy scripts (to Ethereum mainnet) +forge script scripts/deploy.abstract-beta.s.sol --use solc:0.8.28 --optimize --optimizer-runs 200 --rpc-url https://your/rpc/endpt + +# To deploy to Ethereum mainnet (and verify on Etherscan) +forge script scripts/deploy.abstract-beta.s.sol --use solc:0.8.28 --optimize --optimizer-runs 200 --rpc-url https://your/rpc/endpt --broadcast --verify --verifier etherscan --etherscan-api-key $ETHERSCAN_API_KEY + +# The scripts will save the Safe txs to out/safeTxs.json, which you can import to the Transaction Builder in Safe web UI for execution +# It should include txs for: +# 1. Approve MetavestController (without recipient overrides) for escrowing the vesting token +# 2. Approve MetavestController (with recipient overrides) for escrowing the vesting token +# 3~. Create Metavest for each grantee (one transaction each) +# +# Always double check the content before signing: +cat out/safeTxs.json +``` diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..9e859bf --- /dev/null +++ b/foundry.toml @@ -0,0 +1,5 @@ +[profile.default] +fs_permissions = [ + { access = "read-write", path = "out" }, + { access = "read-write", path = "res" }, +] diff --git a/scripts/deploy.abstract-beta.s.sol b/scripts/deploy.abstract-beta.s.sol index 4070a23..924c045 100644 --- a/scripts/deploy.abstract-beta.s.sol +++ b/scripts/deploy.abstract-beta.s.sol @@ -1,5 +1,7 @@ +import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; import {AbstractBetaSepolia} from "./lib/AbstractBetaSepolia.sol"; import {AbstractBeta} from "./lib/AbstractBeta.sol"; +import {SafeUtils} from "./lib/SafeUtils.sol"; import {MockERC20} from "../test/mocks/MockERC20.sol"; import {GnosisTransaction} from "./lib/safe.sol"; import {RestrictedTokenFactory} from "../src/RestrictedTokenFactory.sol"; @@ -33,7 +35,9 @@ contract DeployAbstractBetaScript is Script { metavestController controllerWithoutOverride, metavestController controllerWithOverride, AbstractBeta.GrantInfo[] memory, - GnosisTransaction[] memory + GnosisTransaction[] memory provisionSafeTxs, + GnosisTransaction[] memory grantSafeTxs, + GnosisTransaction[] memory allSafeTxs ) { bytes32 salt = keccak256(bytes(saltStr)); address deployer = vm.addr(deployerPrivateKey); @@ -50,45 +54,106 @@ contract DeployAbstractBetaScript is Script { // (1) Deploy factories and controllers - VestingAllocationFactory vestingAllocationFactory = new VestingAllocationFactory{salt: salt}(); - TokenOptionFactory tokenOptionFactory = new TokenOptionFactory{salt: salt}(); - RestrictedTokenFactory restrictedTokenFactory = new RestrictedTokenFactory{salt: salt}(); - - config.controllerWithoutOverride = new metavestController{salt: bytes32(uint256(salt) + 0)}( - config.authority, // _authority - config.dao, // _dao - address(0), // _recipientOverride - address(vestingAllocationFactory), - address(tokenOptionFactory), - address(restrictedTokenFactory) - ); - - config.controllerWithOverride = new metavestController{salt: bytes32(uint256(salt) + 1)}( - config.authority, // _authority - config.dao, // _dao - config.escrowMultisig, // _recipientOverride - address(vestingAllocationFactory), - address(tokenOptionFactory), - address(restrictedTokenFactory) - ); + { + VestingAllocationFactory vestingAllocationFactory = new VestingAllocationFactory{salt: salt}(); + TokenOptionFactory tokenOptionFactory = new TokenOptionFactory{salt: salt}(); + RestrictedTokenFactory restrictedTokenFactory = new RestrictedTokenFactory{salt: salt}(); + + config.controllerWithoutOverride = new metavestController{salt: bytes32(uint256(salt) + 0)}( + config.authority, // _authority + config.dao, // _dao + address(0), // _recipientOverride + address(vestingAllocationFactory), + address(tokenOptionFactory), + address(restrictedTokenFactory) + ); + + config.controllerWithOverride = new metavestController{salt: bytes32(uint256(salt) + 1)}( + config.authority, // _authority + config.dao, // _dao + config.escrowMultisig, // _recipientOverride + address(vestingAllocationFactory), + address(tokenOptionFactory), + address(restrictedTokenFactory) + ); + + console2.log("Deployed controllers:"); + console2.log(" controllerWithoutOverride: ", address(config.controllerWithoutOverride)); + console2.log(" controllerWithOverride: ", address(config.controllerWithOverride)); + console2.log(""); + } - console2.log("Deployed controllers:"); - console2.log(" controllerWithoutOverride: ", address(config.controllerWithoutOverride)); - console2.log(" controllerWithOverride: ", address(config.controllerWithOverride)); - console2.log(""); + vm.stopBroadcast(); - // (2) Deploy grants (must be performed by authority) + // (2a) Prepare Safe txs (vesting token approval & grants creation) - console2.log("Creating Safe txs for grants:"); - GnosisTransaction[] memory safeTxs = _generateGrantSafeTxs(config, grants); + // Calculate total vesting token needed for all grants + uint256 totalVestingTokenAmountWithoutOverride = 0; + uint256 totalVestingTokenAmountWithOverride = 0; + for (uint256 i = 0; i < grants.length; i++) { + if (grants[i].controllerType == AbstractBeta.ControllerType.WithoutOverride) { + totalVestingTokenAmountWithoutOverride += grants[i].amount; + } else { + totalVestingTokenAmountWithOverride += grants[i].amount; + } + } - vm.stopBroadcast(); + console2.log("Preparing Safe tx for approving vesting tokens..."); + provisionSafeTxs = new GnosisTransaction[](2); + provisionSafeTxs[0] = GnosisTransaction({ + to: config.vestingToken, + value: 0, + data: abi.encodeWithSelector( + ERC20.approve.selector, + address(config.controllerWithoutOverride), + totalVestingTokenAmountWithoutOverride + ) + }); + provisionSafeTxs[1] = GnosisTransaction({ + to: config.vestingToken, + value: 0, + data: abi.encodeWithSelector( + ERC20.approve.selector, + address(config.controllerWithOverride), + totalVestingTokenAmountWithOverride + ) + }); + + console2.log("Preparing Safe txs for grants creation:"); + grantSafeTxs = _generateGrantSafeTxs(config, grants); + + allSafeTxs = new GnosisTransaction[](provisionSafeTxs.length + grantSafeTxs.length); + + // (2b) Create Safe txs JSON file + + { + uint256 safeTxIdx = 0; + for (uint256 i = 0; i < provisionSafeTxs.length; i++) { + allSafeTxs[safeTxIdx++] = provisionSafeTxs[i]; + } + for (uint256 i = 0; i < grantSafeTxs.length; i++) { + allSafeTxs[safeTxIdx++] = grantSafeTxs[i]; + } + + string memory safeTxJson = SafeUtils.formatSafeTxJson(allSafeTxs); + + console2.log("Safe tx JSON (can be imported to Safe Transaction Builder):"); + console2.log("==== JSON data start ===="); + console2.log(safeTxJson); + console2.log("==== JSON data end ===="); + + string memory safeTxJsonPath = "./out/safeTxs.json"; + vm.writeJson(safeTxJson, safeTxJsonPath); + console2.log("JSON file written to: %s", safeTxJsonPath); + } return ( config.controllerWithoutOverride, config.controllerWithOverride, grants, - safeTxs + provisionSafeTxs, + grantSafeTxs, + allSafeTxs ); } @@ -101,9 +166,7 @@ contract DeployAbstractBetaScript is Script { for (uint256 i = 0; i < grants.length; i++) { AbstractBeta.GrantInfo memory grant = grants[i]; - metavestController controller = (grant.controllerType == AbstractBeta.ControllerType.WithoutOverride) - ? config.controllerWithoutOverride - : config.controllerWithOverride; + metavestController controller = _getController(grant, config); safeTxs[i] = GnosisTransaction({ to: address(controller), @@ -143,4 +206,10 @@ contract DeployAbstractBetaScript is Script { console2.log(""); } } -} \ No newline at end of file + + function _getController(AbstractBeta.GrantInfo memory grant, AbstractBeta.Config memory config) internal returns (metavestController) { + return (grant.controllerType == AbstractBeta.ControllerType.WithoutOverride) + ? config.controllerWithoutOverride + : config.controllerWithOverride; + } +} diff --git a/scripts/lib/SafeUtils.sol b/scripts/lib/SafeUtils.sol new file mode 100644 index 0000000..def476b --- /dev/null +++ b/scripts/lib/SafeUtils.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Vm, console2} from "forge-std/Test.sol"; +import {GnosisTransaction} from "./safe.sol"; + +// Access hidden cheatcodes +interface EnhancedVm is Vm { + function serializeJsonType(string calldata typeDescription, bytes memory value) external pure returns (string memory json); +} + +library SafeUtils { + EnhancedVm constant vm = EnhancedVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + struct SafeTxImport { + string version; + string chainId; + uint256 createdAt; + SafeTxMeta meta; + SafeTx[] transactions; + } + + struct SafeTxMeta { + string name; + string description; + string txBuilderVersion; + string createdFromSafeAddress; + string createdFromOwnerAddress; + string checksum; + } + + struct SafeTx { + address to; + string value; + bytes data; + } + + function formatSafeTxJson(GnosisTransaction[] memory safeTxs) internal returns (string memory) { + SafeTx[] memory convertedSafeTxs = new SafeTx[](safeTxs.length); + for (uint256 i = 0; i < safeTxs.length; i++) { + convertedSafeTxs[i] = SafeTx({ + to: safeTxs[i].to, + value: vm.toString(safeTxs[i].value), + data: safeTxs[i].data + }); + } + + return vm.serializeJsonType( + // it is important to include the input argument names as the utility will use them + "SafeTxImport(string version,string chainId,uint256 createdAt,SafeTxMeta meta,SafeTx[] transactions)SafeTxMeta(string name,string description,string txBuilderVersion,string createdFromSafeAddress,string createdFromOwnerAddress,string checksum)SafeTx(address to,string value,bytes data)", + abi.encode(SafeTxImport({ + version: "1.0", + chainId: "1", + createdAt: block.timestamp * 1000, + meta: SafeTxMeta({ + name: "Transactions Batch", + description: "", + txBuilderVersion: "", + createdFromSafeAddress: "", + createdFromOwnerAddress: "", + checksum: "" + }), + transactions: convertedSafeTxs + })) + ); + } + + function parseSafeTxJson(string memory json) internal returns (SafeTxImport memory) { + return abi.decode(vm.parseJson(json), (SafeTxImport)); + } +} diff --git a/test/AbstractBeta.t.sol b/test/AbstractBeta.t.sol index 286e6d4..04b886f 100644 --- a/test/AbstractBeta.t.sol +++ b/test/AbstractBeta.t.sol @@ -35,15 +35,20 @@ contract AbstractBetaTest is Test { config.vestingToken = address(new MockERC20("Vesting Token", "VEST", 18)); MockERC20(config.vestingToken).mint(config.authority, 100_000_000_000 ether); vm.stopPrank(); + console2.log("Vesting token deployed: %s", config.vestingToken); // Deploy controllers and prepare txs for grants AbstractBeta.GrantInfo[] memory loadedGrants; - GnosisTransaction[] memory safeTxs; + GnosisTransaction[] memory provisionSafeTxs; + GnosisTransaction[] memory grantSafeTxs; + GnosisTransaction[] memory allSafeTxs; ( config.controllerWithoutOverride, config.controllerWithOverride, loadedGrants, - safeTxs + provisionSafeTxs, + grantSafeTxs, + allSafeTxs ) = (new DeployAbstractBetaScript()).runWithArgs( saltStr, deployerPrivateKey, @@ -55,14 +60,21 @@ contract AbstractBetaTest is Test { ERC20(config.vestingToken).approve(address(config.controllerWithoutOverride), 100_000_000_000 ether); ERC20(config.vestingToken).approve(address(config.controllerWithOverride), 100_000_000_000 ether); + console2.log("Provisioning Safe funds..."); + for (uint256 i = 0; i < provisionSafeTxs.length; i++) { + (bool success, bytes memory ret) = provisionSafeTxs[i].to.call{value: provisionSafeTxs[i].value}(provisionSafeTxs[i].data); + assertTrue(success, string(abi.encodePacked("call #", vm.toString(i + 1), " failed: ", vm.toString(ret)))); + } + console2.log("Deploying grants:"); - for (uint256 i = 0; i < safeTxs.length; i++) { - (bool success, bytes memory ret) = safeTxs[i].to.call{value: safeTxs[i].value}(safeTxs[i].data); - assertTrue(success, string(abi.encodePacked("call #", vm.toString(i), " failed: ", vm.toString(ret)))); + for (uint256 i = 0; i < grantSafeTxs.length; i++) { + (bool success, bytes memory ret) = grantSafeTxs[i].to.call{value: grantSafeTxs[i].value}(grantSafeTxs[i].data); + assertTrue(success, string(abi.encodePacked("call #", vm.toString(i + 1), " failed: ", vm.toString(ret)))); loadedGrants[i].metavest = abi.decode(ret, (address)); grants.push(loadedGrants[i]); // Save it to storage - console2.log(" #%s: %s", vm.toString(i), loadedGrants[i].metavest); + console2.log(" #%s: %s", vm.toString(i + 1), loadedGrants[i].metavest); } + console2.log(""); vm.stopPrank(); } From 28f05363a9e7044e2b6e9dae707bc4fb8f9d4906 Mon Sep 17 00:00:00 2001 From: detoo Date: Tue, 23 Dec 2025 14:04:10 -0800 Subject: [PATCH 15/15] chore: deploy to sepolia --- res/safeTxs-development.abstract-beta.json | 35 ++++++++++++++++++++++ scripts/deploy.abstract-beta.s.sol | 2 ++ scripts/lib/AbstractBetaSepolia.sol | 16 +++++----- 3 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 res/safeTxs-development.abstract-beta.json diff --git a/res/safeTxs-development.abstract-beta.json b/res/safeTxs-development.abstract-beta.json new file mode 100644 index 0000000..5885859 --- /dev/null +++ b/res/safeTxs-development.abstract-beta.json @@ -0,0 +1,35 @@ +{ + "meta": { + "name": "Transactions Batch", + "description": "", + "createdFromSafeAddress": "", + "txBuilderVersion": "", + "createdFromOwnerAddress": "", + "checksum": "" + }, + "createdAt": 1766526996000, + "transactions": [ + { + "value": "0", + "data": "0x095ea7b3000000000000000000000000cef4761cc320fdc28034f00b754e8b608028f42000000000000000000000000000000000000000000000021e19e0c9bab2400000", + "to": "0xA581b1b0D31B0528C20801E56EeEaF0834a8C907" + }, + { + "to": "0xA581b1b0D31B0528C20801E56EeEaF0834a8C907", + "data": "0x095ea7b3000000000000000000000000387116083c8788426fc91d6689972e2ad6d5451200000000000000000000000000000000000000000000043c33c1937564800000", + "value": "0" + }, + { + "value": "0", + "data": "0x99ec93bb000000000000000000000000000000000000000000000000000000000000000200000000000000000000000048d206948c366396a86a449ddd085fdbfc280b4b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000021e19e0c9bab2400000000000000000000000000000000000000000000000000043c33c1937564800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048198737bd730000000000000000000000000000000000000000000000000000000068632500000000000000000000000000000000000000000000000000000120661cdef5cd000000000000000000000000000000000000000000000000000000006d182000000000000000000000000000a581b1b0d31b0528c20801e56eeeaf0834a8c907000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000b9e5ae881f36083cb914205f19eaa265d76eef53000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "to": "0xCEf4761CC320Fdc28034f00B754E8b608028f420" + }, + { + "data": "0x99ec93bb00000000000000000000000000000000000000000000000000000000000000020000000000000000000000005ff4e90efa2b88cf3ca92d63d244a78a88219abf000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000043c33c19375648000000000000000000000000000000000000000000000000000878678326eac9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000090330e6f7ae60000000000000000000000000000000000000000000000000000000067eb2c80000000000000000000000000000000000000000000000000000240cc39bdeb9b000000000000000000000000000000000000000000000000000000006d182000000000000000000000000000a581b1b0d31b0528c20801e56eeeaf0834a8c907000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000f4240000000000000000000000000b9e5ae881f36083cb914205f19eaa265d76eef53000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "to": "0x387116083c8788426Fc91d6689972e2ad6d54512", + "value": "0" + } + ], + "version": "1.0", + "chainId": "1" +} \ No newline at end of file diff --git a/scripts/deploy.abstract-beta.s.sol b/scripts/deploy.abstract-beta.s.sol index 924c045..dff517a 100644 --- a/scripts/deploy.abstract-beta.s.sol +++ b/scripts/deploy.abstract-beta.s.sol @@ -46,6 +46,8 @@ contract DeployAbstractBetaScript is Script { console2.log("=== DeployAbstractBetaControllersScript ==="); console2.log("saltStr: %s", saltStr); console2.log("deployer: %s", deployer); + console2.log("vesting token: %s", config.vestingToken); + console2.log("payment token: %s", config.paymentToken); console2.log(""); AbstractBeta.GrantInfo[] memory grants = AbstractBeta.loadGrants(); diff --git a/scripts/lib/AbstractBetaSepolia.sol b/scripts/lib/AbstractBetaSepolia.sol index 6943a8c..563e1b0 100644 --- a/scripts/lib/AbstractBetaSepolia.sol +++ b/scripts/lib/AbstractBetaSepolia.sol @@ -16,26 +16,26 @@ library AbstractBetaSepolia { // External dependencies - vestingToken: 0xB9E5Ae881f36083cB914205F19EAa265D76eeF53, // mock vesting token - paymentToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, // mock payment token + vestingToken: 0xA581b1b0D31B0528C20801E56EeEaF0834a8C907, // mock vesting token + paymentToken: 0xB9E5Ae881f36083cB914205F19EAa265D76eeF53, // mock payment token // Authority - dao: 0x8E9603BcB5D974Ed9C870510F3665F67CE5c5bDe, // dev wallet - authority: 0x8E9603BcB5D974Ed9C870510F3665F67CE5c5bDe, // dev wallet - escrowMultisig: 0x8E9603BcB5D974Ed9C870510F3665F67CE5c5bDe, // dev wallet + dao: 0x4F22ba82a6B71F7305d1be7Ae7323811f9D555Ab, // dev Safe + authority: 0x4F22ba82a6B71F7305d1be7Ae7323811f9D555Ab, // dev Safe + escrowMultisig: 0x4F22ba82a6B71F7305d1be7Ae7323811f9D555Ab, // dev Safe // Sat Jan 1 00:00:00 UTC 2028 // Will update the start times once finalized unlockStartTime: 1830297600, - exercisePrice: 10e6, + exercisePrice: 1e6, shortStopDuration: 0, // Grants (without override) (grantee can specify their desired recipient addresses) - controllerWithoutOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF), // TODO TBD + controllerWithoutOverride: metavestController(0xCEf4761CC320Fdc28034f00B754E8b608028f420), // Grants (with override) (authority overrides all grantees' recipient address) - controllerWithOverride: metavestController(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF) // TODO TBD + controllerWithOverride: metavestController(0x387116083c8788426Fc91d6689972e2ad6d54512) }); } }