diff --git a/.gitmodules b/.gitmodules index 6b40dcf4b..576adff19 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,6 +19,9 @@ [submodule "lib/permit2"] path = lib/permit2 url = https://github.com/Uniswap/permit2 +[submodule "lib/hedgey-vesting"] + path = lib/hedgey-vesting + url = https://github.com/hedgey-finance/Locked_VestingTokenPlans [submodule "lib/hats-protocol"] path = lib/hats-protocol url = https://github.com/Hats-Protocol/hats-protocol diff --git a/contracts/core/libraries/Transfer.sol b/contracts/core/libraries/Transfer.sol index 34c8a6431..0f50f6b25 100644 --- a/contracts/core/libraries/Transfer.sol +++ b/contracts/core/libraries/Transfer.sol @@ -40,7 +40,11 @@ contract Transfer is Native { /// @param _token The address of the token /// @param _transferData TransferData[] /// @return Whether the transfer was successful or not - function _transferAmountsFrom(address _token, TransferData[] memory _transferData) internal returns (bool) { + function _transferAmountsFrom(address _token, TransferData[] memory _transferData) + internal + virtual + returns (bool) + { uint256 msgValue = msg.value; for (uint256 i; i < _transferData.length;) { @@ -67,7 +71,7 @@ contract Transfer is Native { /// @param _token The address of the token /// @param _transferData Individual TransferData /// @return Whether the transfer was successful or not - function _transferAmountFrom(address _token, TransferData memory _transferData) internal returns (bool) { + function _transferAmountFrom(address _token, TransferData memory _transferData) internal virtual returns (bool) { uint256 amount = _transferData.amount; if (_token == NATIVE) { // Native Token @@ -84,7 +88,7 @@ contract Transfer is Native { /// @param _token The token to transfer /// @param _to The address to transfer to /// @param _amount The amount to transfer - function _transferAmount(address _token, address _to, uint256 _amount) internal { + function _transferAmount(address _token, address _to, uint256 _amount) internal virtual { if (_token == NATIVE) { SafeTransferLib.safeTransferETH(_to, _amount); } else { diff --git a/contracts/strategies/_poc/hedgey/HedgeyRFPCommitteeStrategy.sol b/contracts/strategies/_poc/hedgey/HedgeyRFPCommitteeStrategy.sol new file mode 100644 index 000000000..7075b92ca --- /dev/null +++ b/contracts/strategies/_poc/hedgey/HedgeyRFPCommitteeStrategy.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.19; + +// Core Contracts +import {RFPCommitteeStrategy} from "../../rfp-committee/RFPCommitteeStrategy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +// Internal Libraries +import {Metadata} from "../../../core/libraries/Metadata.sol"; + +interface ITokenVestingPlans { + function createPlan( + address recipient, + address token, + uint256 amount, + uint256 start, + uint256 cliff, + uint256 rate, + uint256 period, + address vestingAdmin, + bool adminTransferOBO + ) external returns (uint256 newPlanId); +} + +contract HedgeyRFPCommitteeStrategy is RFPCommitteeStrategy { + /// ================================ + /// ========== Storage ============= + /// ================================ + + mapping(address => uint256) internal _recipientLockupTerm; + + struct HedgeyInitializeParamsCommittee { + bool adminTransferOBO; + address hedgeyContract; + address adminAddress; + InitializeParamsCommittee params; + } + + bool public adminTransferOBO; + address public hedgeyContract; + address public adminAddress; + + /// =============================== + /// ========== Events ============= + /// =============================== + + event AdminAddressUpdated(address adminAddress, address sender); + event AdminTransferOBOUpdated(bool adminTransferOBO, address sender); + + /// =============================== + /// ======== Constructor ========== + /// =============================== + + constructor(address _allo, string memory _name) RFPCommitteeStrategy(_allo, _name) {} + + /// =============================== + /// ========= Initialize ========== + /// =============================== + + function initialize(uint256 _poolId, bytes memory _data) external override { + (HedgeyInitializeParamsCommittee memory hedgeyInitializeParamsCommittee) = + abi.decode(_data, (HedgeyInitializeParamsCommittee)); + __HedgeyRPFCommiteeStrategy_init(_poolId, hedgeyInitializeParamsCommittee); + } + + function __HedgeyRPFCommiteeStrategy_init( + uint256 _poolId, + HedgeyInitializeParamsCommittee memory _hedgeyInitializeParamsCommittee + ) internal { + // Initialize the RPFCommiteeStrategy + __RPFCommiteeStrategy_init(_poolId, _hedgeyInitializeParamsCommittee.params); + + // Set the strategy specific variables + adminTransferOBO = _hedgeyInitializeParamsCommittee.adminTransferOBO; + hedgeyContract = _hedgeyInitializeParamsCommittee.hedgeyContract; + adminAddress = _hedgeyInitializeParamsCommittee.adminAddress; + } + + /// =============================== + /// ======= External/Custom ======= + /// =============================== + + /// @notice Update the default Admin wallet used when creating Hedgey plans + /// @param _adminAddress The admin wallet to use + function setAdminAddress(address _adminAddress) external onlyPoolManager(msg.sender) { + adminAddress = _adminAddress; + emit AdminAddressUpdated(_adminAddress, msg.sender); + } + + /// @notice Update the default Admin wallet used when creating Hedgey plans + /// @param _adminTransferOBO Set if the admin is allowed to transfer on behalf of the recipient + function setAdminTransferOBO(bool _adminTransferOBO) external onlyPoolManager(msg.sender) { + adminTransferOBO = _adminTransferOBO; + emit AdminTransferOBOUpdated(_adminTransferOBO, msg.sender); + } + + /// @notice Get the lockup term for a recipient + /// @param _recipient The recipient to get the lockup term for + function getRecipientLockupTerm(address _recipient) external view returns (uint256) { + return _recipientLockupTerm[_recipient]; + } + + /// @notice Withdraw the tokens from the pool + /// @dev Callable by the pool manager + /// @param _token The token to withdraw + function withdraw(address _token) external virtual override onlyPoolManager(msg.sender) onlyInactivePool { + uint256 amount = _getBalance(_token, address(this)); + + // Transfer the tokens to the 'msg.sender' (pool manager calling function) + super._transferAmount(_token, msg.sender, amount); + } + + /// ==================================== + /// ============ Internal ============== + /// ==================================== + + function _transferAmount(address _token, address _recipient, uint256 _amount) internal override { + IERC20(_token).approve(hedgeyContract, _amount); + + uint256 rate = _amount / _recipientLockupTerm[_recipient]; + ITokenVestingPlans(hedgeyContract).createPlan( + _recipient, + _token, + _amount, + block.timestamp, + 0, // No cliff + rate, + 1, // Linear period + adminAddress, + adminTransferOBO + ); + } + + /// ==================================== + /// ============== Hooks =============== + /// ==================================== + + function _afterRegisterRecipient(bytes memory _data, address) internal override { + uint256 lockupTerm; + address recipientAddress; + (, recipientAddress,,, lockupTerm) = abi.decode(_data, (address, address, uint256, Metadata, uint256)); + + _recipientLockupTerm[recipientAddress] = lockupTerm; + } +} diff --git a/contracts/strategies/rfp-committee/RFPCommitteeStrategy.sol b/contracts/strategies/rfp-committee/RFPCommitteeStrategy.sol index a8868ebfb..c2d79f82f 100644 --- a/contracts/strategies/rfp-committee/RFPCommitteeStrategy.sol +++ b/contracts/strategies/rfp-committee/RFPCommitteeStrategy.sol @@ -71,7 +71,7 @@ contract RFPCommitteeStrategy is RFPSimpleStrategy { /// @param _poolId ID of the pool /// @param _data The data to be decoded /// @custom:data (uint256 voteThreshold, InitializeParams params) - function initialize(uint256 _poolId, bytes memory _data) external override { + function initialize(uint256 _poolId, bytes memory _data) external virtual override { (InitializeParamsCommittee memory initializeParamsCommittee) = abi.decode(_data, (InitializeParamsCommittee)); __RPFCommiteeStrategy_init(_poolId, initializeParamsCommittee); emit Initialized(_poolId, _data); diff --git a/contracts/strategies/rfp-simple/RFPSimpleStrategy.sol b/contracts/strategies/rfp-simple/RFPSimpleStrategy.sol index 75220c3b0..b94a9e641 100644 --- a/contracts/strategies/rfp-simple/RFPSimpleStrategy.sol +++ b/contracts/strategies/rfp-simple/RFPSimpleStrategy.sol @@ -306,7 +306,7 @@ contract RFPSimpleStrategy is BaseStrategy, ReentrancyGuard { /// @notice Withdraw the tokens from the pool /// @dev Callable by the pool manager /// @param _token The token to withdraw - function withdraw(address _token) external onlyPoolManager(msg.sender) onlyInactivePool { + function withdraw(address _token) external virtual onlyPoolManager(msg.sender) onlyInactivePool { uint256 amount = _getBalance(_token, address(this)); // Transfer the tokens to the 'msg.sender' (pool manager calling function) diff --git a/lib/hedgey-vesting b/lib/hedgey-vesting new file mode 160000 index 000000000..083fa57b0 --- /dev/null +++ b/lib/hedgey-vesting @@ -0,0 +1 @@ +Subproject commit 083fa57b0efa3c49640820a58b5ba7d0d941aa7a diff --git a/remappings.txt b/remappings.txt index 1bb1641c7..0340fa5de 100644 --- a/remappings.txt +++ b/remappings.txt @@ -12,4 +12,5 @@ hats-protocol/=lib/hats-protocol/src/ @superfluid-finance/=lib/superfluid-protocol-monorepo/packages/ solady/=lib/solady/src/ @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ -lib/ERC1155/=lib/hats-protocol/lib/ERC1155/ \ No newline at end of file +lib/ERC1155/=lib/hats-protocol/lib/ERC1155/ +hedgey-vesting/=lib/hedgey-vesting/contracts/ \ No newline at end of file diff --git a/test/foundry/strategies/hedgey/HedgeyRFPCommitteeStrategy.t.sol b/test/foundry/strategies/hedgey/HedgeyRFPCommitteeStrategy.t.sol new file mode 100644 index 000000000..ccc11a198 --- /dev/null +++ b/test/foundry/strategies/hedgey/HedgeyRFPCommitteeStrategy.t.sol @@ -0,0 +1,370 @@ +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; + +// Interfaces +import {IStrategy} from "../../../../contracts/core/interfaces/IStrategy.sol"; +// Core contracts +import {HedgeyRFPCommitteeStrategy} from "../../../../contracts/strategies/_poc/hedgey/HedgeyRFPCommitteeStrategy.sol"; +import {RFPSimpleStrategy} from "../../../../contracts/strategies/rfp-simple/RFPSimpleStrategy.sol"; +// Internal libraries +import {Errors} from "../../../../contracts/core/libraries/Errors.sol"; +import {Metadata} from "../../../../contracts/core/libraries/Metadata.sol"; +// Test libraries +import {AlloSetup} from "../../shared/AlloSetup.sol"; +import {RegistrySetupFull} from "../../shared/RegistrySetup.sol"; +import {EventSetup} from "../../shared/EventSetup.sol"; +import {HedgeySetup} from "./HedgeySetup.sol"; +import {MockERC20} from "../../../utils/MockERC20.sol"; + +contract HedgeyRFPCommitteeStrategyTest is Test, RegistrySetupFull, AlloSetup, HedgeySetup, EventSetup, Errors { + // Events + event Voted(address indexed recipientId, address voter); + event PoolFunded(uint256 indexed poolId, uint256 amount, uint256 fee); + event PlanCreated( + uint256 indexed id, + address indexed recipient, + address indexed token, + uint256 amount, + uint256 start, + uint256 cliff, + uint256 end, + uint256 rate, + uint256 period, + address vestingAdmin, + bool adminTransferOBO + ); + event AdminAddressUpdated(address adminAddress, address sender); + event AdminTransferOBOUpdated(bool adminTransferOBO, address sender); + + bool public useRegistryAnchor; + bool public metadataRequired; + + address[] public allowedTokens; + + HedgeyRFPCommitteeStrategy public strategy; + + MockERC20 public token; + uint256 mintAmount = 1000000 * 10 ** 18; + + Metadata public poolMetadata; + + uint256 public poolId; + + uint256 public maxBid; + + uint256 public voteThreshold; + + // Hedgey Specific + bool public adminTransferOBO; + address public hedgeyContract; + address public adminAddress; + + uint256 public constant ONE_MONTH_SECONDS = 2628000; + + struct TestStruct { + uint256 a; + uint256 b; + uint256 c; + bool d; + } + + function setUp() public { + __RegistrySetupFull(); + __AlloSetup(address(registry())); + __HedgeySetup(); + + token = new MockERC20(); + token.mint(local(), mintAmount); + token.mint(allo_owner(), mintAmount); + token.mint(pool_admin(), mintAmount); + token.approve(address(allo()), mintAmount); + + vm.prank(pool_admin()); + token.approve(address(allo()), mintAmount); + + useRegistryAnchor = false; + metadataRequired = true; + + maxBid = 1e18; + + voteThreshold = 2; + + poolMetadata = Metadata({protocol: 1, pointer: "PoolMetadata"}); + + strategy = new HedgeyRFPCommitteeStrategy(address(allo()), "HedgeyRFPCommitteeStrategy"); + + adminTransferOBO = true; + hedgeyContract = address(vesting()); + adminAddress = address(pool_admin()); + + vm.prank(pool_admin()); + poolId = allo().createPoolWithCustomStrategy( + poolProfile_id(), + address(strategy), + abi.encode( + adminTransferOBO, + hedgeyContract, + adminAddress, + voteThreshold, + maxBid, + useRegistryAnchor, + metadataRequired + ), + address(token), + 0, + poolMetadata, + pool_managers() + ); + } + + function test_deployment() public { + HedgeyRFPCommitteeStrategy testStrategy = + new HedgeyRFPCommitteeStrategy(address(allo()), "HedgeyRFPCommitteeStrategy"); + assertEq(address(testStrategy.getAllo()), address(allo())); + assertEq(testStrategy.getStrategyId(), keccak256(abi.encode("HedgeyRFPCommitteeStrategy"))); + } + + function test_initialize() public { + HedgeyRFPCommitteeStrategy testStrategy = + new HedgeyRFPCommitteeStrategy(address(allo()), "HedgeyRFPCommitteeStrategy"); + vm.prank(address(allo())); + testStrategy.initialize( + 1337, + abi.encode( + adminTransferOBO, + hedgeyContract, + adminAddress, + voteThreshold, + maxBid, + useRegistryAnchor, + metadataRequired + ) + ); + assertEq(testStrategy.getPoolId(), 1337); + assertEq(testStrategy.useRegistryAnchor(), useRegistryAnchor); + assertEq(testStrategy.metadataRequired(), metadataRequired); + assertEq(testStrategy.maxBid(), maxBid); + assertEq(testStrategy.voteThreshold(), voteThreshold); + + assertEq(testStrategy.adminTransferOBO(), adminTransferOBO); + assertEq(testStrategy.hedgeyContract(), hedgeyContract); + assertEq(testStrategy.adminAddress(), adminAddress); + } + + function testRevert_initialize_ALREADY_INITIALIZED() public { + HedgeyRFPCommitteeStrategy testStrategy = + new HedgeyRFPCommitteeStrategy(address(allo()), "HedgeyRFPCommitteeStrategy"); + vm.startPrank(address(allo())); + testStrategy.initialize( + 1337, + abi.encode( + adminTransferOBO, + hedgeyContract, + adminAddress, + voteThreshold, + maxBid, + useRegistryAnchor, + metadataRequired + ) + ); + + vm.expectRevert(ALREADY_INITIALIZED.selector); + testStrategy.initialize( + 1337, + abi.encode( + adminTransferOBO, + hedgeyContract, + adminAddress, + voteThreshold, + maxBid, + useRegistryAnchor, + metadataRequired + ) + ); + } + + function testRevert_initialize_UNAUTHORIZED() public { + HedgeyRFPCommitteeStrategy testStrategy = + new HedgeyRFPCommitteeStrategy(address(allo()), "HedgeyRFPCommitteeStrategy"); + vm.expectRevert(UNAUTHORIZED.selector); + testStrategy.initialize( + 1337, + abi.encode( + adminTransferOBO, + hedgeyContract, + adminAddress, + voteThreshold, + maxBid, + useRegistryAnchor, + metadataRequired + ) + ); + } + + function test_allocate() public { + address recipientId = __register_setMilestones_allocate(); + IStrategy.Status recipientStatus = strategy.getRecipientStatus(recipientId); + assertEq(uint8(recipientStatus), uint8(IStrategy.Status.Accepted)); + } + + function test_allocate_reallocating() public { + address recipientId = __register_recipient(); + address recipientId2 = __register_recipient2(); + + __setMilestones(); + + assertEq(strategy.votes(recipientId), 0); + assertEq(strategy.votes(recipientId2), 0); + + vm.prank(address(allo())); + strategy.allocate(abi.encode(recipientId), address(pool_admin())); + + assertEq(strategy.votes(recipientId), 1); + assertEq(strategy.votes(recipientId2), 0); + assertEq(strategy.votedFor(address(pool_admin())), recipientId); + + vm.prank(address(allo())); + strategy.allocate(abi.encode(recipientId2), address(pool_admin())); + + assertEq(strategy.votes(recipientId), 0); + assertEq(strategy.votes(recipientId2), 1); + assertEq(strategy.votedFor(address(pool_admin())), recipientId2); + } + + function testRevert_allocate_UNAUTHORIZED() public { + vm.expectRevert(UNAUTHORIZED.selector); + vm.prank(makeAddr("not_pool_manager")); + strategy.allocate(abi.encode(recipientAddress()), recipient()); + } + + function testRevert_allocate_RECIPIENT_ALREADY_ACCEPTED() public { + __register_setMilestones_allocate(); + vm.prank(address(allo())); + vm.expectRevert(RECIPIENT_ALREADY_ACCEPTED.selector); + strategy.allocate(abi.encode(randomAddress()), address(pool_admin())); + } + + function test_distribute() public { + _register_allocate_submit_distribute(); + assertEq(uint8(strategy.getMilestoneStatus(0)), uint8(IStrategy.Status.Accepted)); + } + + function test_change_admin_address() public { + vm.prank(address(pool_admin())); + vm.expectEmit(true, true, false, false); + emit AdminAddressUpdated(address(pool_manager1()), address(pool_admin())); + + strategy.setAdminAddress(address(pool_manager1())); + assertEq(strategy.adminAddress(), address(pool_manager1())); + } + + function test_change_admin_transfer_obo() public { + vm.prank(address(pool_admin())); + vm.expectEmit(true, true, false, false); + emit AdminTransferOBOUpdated(false, address(pool_admin())); + + strategy.setAdminTransferOBO(false); + assertEq(strategy.adminTransferOBO(), false); + } + + function test_withdraw() public { + allo().fundPool(poolId, 1e18); + vm.startPrank(pool_admin()); + strategy.setPoolActive(false); + strategy.withdraw(address(token)); + assertEq(address(allo()).balance, 0); + } + + function __register_recipient() internal returns (address recipientId) { + address sender = recipient(); + Metadata memory metadata = Metadata({protocol: 1, pointer: "metadata"}); + bytes memory data = abi.encode(address(0), recipientAddress(), 1e18, metadata, ONE_MONTH_SECONDS); + vm.prank(address(allo())); + recipientId = strategy.registerRecipient(data, sender); + + assertEq(strategy.getRecipientLockupTerm(recipientAddress()), ONE_MONTH_SECONDS); + } + + function __register_recipient2() internal returns (address recipientId) { + address sender = makeAddr("recipient2"); + Metadata memory metadata = Metadata({protocol: 1, pointer: "metadata"}); + + bytes memory data = abi.encode(address(0), recipientAddress(), 1e18, metadata, ONE_MONTH_SECONDS * 2); + vm.prank(address(allo())); + recipientId = strategy.registerRecipient(data, sender); + + assertEq(strategy.getRecipientLockupTerm(recipientAddress()), ONE_MONTH_SECONDS * 2); + } + + function __setMilestones() internal { + HedgeyRFPCommitteeStrategy.Milestone[] memory milestones = new HedgeyRFPCommitteeStrategy.Milestone[](1); + HedgeyRFPCommitteeStrategy.Milestone memory milestone = RFPSimpleStrategy.Milestone({ + metadata: Metadata({protocol: 1, pointer: "metadata"}), + amountPercentage: 1e18, + milestoneStatus: IStrategy.Status.Pending + }); + + milestones[0] = milestone; + + vm.prank(address(pool_admin())); + strategy.setMilestones(milestones); + } + + function __register_setMilestones_allocate() internal returns (address recipientId) { + recipientId = __register_recipient(); + __setMilestones(); + + vm.expectEmit(); + emit Voted(recipientId, address(pool_admin())); + + vm.prank(address(allo())); + strategy.allocate(abi.encode(recipientId), address(pool_admin())); + + vm.expectEmit(); + emit Voted(recipientId, address(pool_manager1())); + vm.expectEmit(); + emit Allocated(recipientId, 1e18, address(token), address(0)); + + vm.prank(address(allo())); + strategy.allocate(abi.encode(recipientId), address(pool_manager1())); + } + + function __register_setMilestones_allocate_submitUpcomingMilestone() internal returns (address recipientId) { + recipientId = __register_setMilestones_allocate(); + vm.expectEmit(); + emit MilstoneSubmitted(0); + vm.prank(recipient()); + strategy.submitUpcomingMilestone(Metadata({protocol: 1, pointer: "metadata"})); + } + + function _register_allocate_submit_distribute() internal returns (address recipientId) { + recipientId = __register_setMilestones_allocate_submitUpcomingMilestone(); + + vm.expectEmit(true, false, false, true); + emit PoolFunded(poolId, 9.9e19, 1e18); + + allo().fundPool(poolId, 10 * 10e18); + + vm.expectEmit(true, true, true, false); + emit PlanCreated( + 1, + recipientAddress(), + address(token), + 1e18, + block.timestamp, + 0, + block.timestamp + ONE_MONTH_SECONDS + 1, + 1, + 1e18 / ONE_MONTH_SECONDS, + address(pool_admin()), + adminTransferOBO + ); + + vm.expectEmit(true, false, false, true); + emit Distributed(recipientId, recipientAddress(), 1e18, pool_admin()); + + vm.prank(address(allo())); + strategy.distribute(new address[](0), "", pool_admin()); + } +} diff --git a/test/foundry/strategies/hedgey/HedgeySetup.sol b/test/foundry/strategies/hedgey/HedgeySetup.sol new file mode 100644 index 000000000..709191f61 --- /dev/null +++ b/test/foundry/strategies/hedgey/HedgeySetup.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; + +// Core contracts +import {TokenVestingPlans} from "hedgey-vesting/VestingPlans/TokenVestingPlans.sol"; +import {BatchPlanner} from "hedgey-vesting/Periphery/BatchPlanner.sol"; +import {Accounts} from "../../shared/Accounts.sol"; + +contract HedgeySetup is Test, Accounts { + TokenVestingPlans internal _vesting_; + BatchPlanner internal _batchPlanner_; + + function __HedgeySetup() internal { + vm.startPrank(allo_owner()); + + _vesting_ = new TokenVestingPlans("TokenVestingPlans", "TVP"); + _batchPlanner_ = new BatchPlanner(); + + vm.stopPrank(); + } + + function vesting() public view returns (TokenVestingPlans) { + return _vesting_; + } + + function batchPlanner() public view returns (BatchPlanner) { + return _batchPlanner_; + } +}