From 1429c911230c4573b1286d6ccd3db25b4ef645c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Wed, 11 Dec 2024 16:58:14 +0100 Subject: [PATCH 1/6] Lock Manager test definition --- test/LockManager.t.sol | 307 ++++++++++++++++++++++++++++++++++++++++ test/LockManager.t.yaml | 134 ++++++++++++++++++ test/LockToVote.t.sol | 1 - 3 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 test/LockManager.t.sol create mode 100644 test/LockManager.t.yaml diff --git a/test/LockManager.t.sol b/test/LockManager.t.sol new file mode 100644 index 0000000..34df691 --- /dev/null +++ b/test/LockManager.t.sol @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import {AragonTest} from "./util/AragonTest.sol"; + +contract LockManagerTest is AragonTest { + modifier whenCallingUpdateSettings() { + _; + } + + function test_RevertWhen_UpdateSettingsWithoutThePermission() + external + whenCallingUpdateSettings + { + // It should revert + vm.skip(true); + } + + function test_WhenUpdateSettingsWithThePermission() + external + whenCallingUpdateSettings + { + // It should update the mode + vm.skip(true); + } + + function test_WhenCallingGetSettings() external whenCallingUpdateSettings { + // It Should return the right value + vm.skip(true); + } + + function test_WhenCallingSupportsInterface() external { + // It does not support the empty interface + // It supports IERC165Upgradeable + // It supports ILockManager + vm.skip(true); + } + + function test_WhenLockingTokens() external { + // It Should allow any token holder to lock + // It Should use the full allowance + vm.skip(true); + } + + modifier whenLockingAndOrVoting() { + _; + } + + function test_GivenInvalidPlugin() external whenLockingAndOrVoting { + // It Locking and voting should revert + // It Voting should revert + vm.skip(true); + } + + modifier givenValidLockToVotePlugin() { + _; + } + + function test_RevertWhen_NoTokenBalance() + external + whenLockingAndOrVoting + givenValidLockToVotePlugin + { + // It Should revert + vm.skip(true); + } + + function test_RevertWhen_NoTokenAllowance() + external + whenLockingAndOrVoting + givenValidLockToVotePlugin + { + // It Should revert + vm.skip(true); + } + + function test_WhenInvalidOrInactiveProposal() + external + whenLockingAndOrVoting + givenValidLockToVotePlugin + { + // It Locking and voting should revert + // It Voting should revert + vm.skip(true); + } + + modifier whenValidProposal() { + _; + } + + function test_WhenAlreadyVoted() + external + whenLockingAndOrVoting + givenValidLockToVotePlugin + whenValidProposal + { + // It Should update the voting balance and the proposal tally + // It Should increase the voting power by the full allowance + vm.skip(true); + } + + function test_WhenNotVotedYet() + external + whenLockingAndOrVoting + givenValidLockToVotePlugin + whenValidProposal + { + // It Should allow any token holder to vote + // It Should use the full allowance to vote + vm.skip(true); + } + + function test_WhenCallingGetTokens() + external + whenLockingAndOrVoting + givenValidLockToVotePlugin + whenValidProposal + { + // It Should return the token addresses where votes have been cast + vm.skip(true); + } + + function test_GivenCallingGetLocks() + external + whenLockingAndOrVoting + givenValidLockToVotePlugin + whenValidProposal + { + // It Should return the active proposals with 1+ locks + vm.skip(true); + } + + modifier givenStrictModeIsSet() { + _; + } + + modifier givenDidntLockAnythingStrict() { + _; + } + + function test_WhenTryingToUnlock1Strict() + external + givenStrictModeIsSet + givenDidntLockAnythingStrict + { + // It Should do nothing + vm.skip(true); + } + + modifier givenLockedButDidntVoteAnywhereStrict() { + _; + } + + function test_WhenTryingToUnlock2Strict() + external + givenStrictModeIsSet + givenLockedButDidntVoteAnywhereStrict + { + // It Should unlock and refund the full amount right away + vm.skip(true); + } + + modifier givenLockedButVotedOnEndedOrExecutedProposalsStrict() { + _; + } + + function test_WhenTryingToUnlock3Strict() + external + givenStrictModeIsSet + givenLockedButVotedOnEndedOrExecutedProposalsStrict + { + // It Should unlock and refund the full amount right away + vm.skip(true); + } + + modifier givenLockedAnvVotedOnCurrentlyActiveProposalsStrict() { + _; + } + + function test_RevertWhen_TryingToUnlock4Strict() + external + givenStrictModeIsSet + givenLockedAnvVotedOnCurrentlyActiveProposalsStrict + { + // It Should revert + vm.skip(true); + } + + modifier givenFlexibleModeIsSet() { + _; + } + + modifier givenDidntLockAnythingFlexible() { + _; + } + + function test_WhenTryingToUnlock1Flexible() + external + givenFlexibleModeIsSet + givenDidntLockAnythingFlexible + { + // It Should do nothing + vm.skip(true); + } + + modifier givenLockedButDidntVoteAnywhereFlexible() { + _; + } + + function test_WhenTryingToUnlock2Flexible() + external + givenFlexibleModeIsSet + givenLockedButDidntVoteAnywhereFlexible + { + // It Should unlock and refund the full amount right away + vm.skip(true); + } + + modifier givenLockedButVotedOnEndedOrExecutedProposalsFlexible() { + _; + } + + function test_WhenTryingToUnlock3Flexible() + external + givenFlexibleModeIsSet + givenLockedButVotedOnEndedOrExecutedProposalsFlexible + { + // It Should unlock and refund the full amount right away + vm.skip(true); + } + + modifier givenLockedAnvVotedOnCurrentlyActiveProposalsFlexible() { + _; + } + + function test_WhenTryingToUnlock4Flexible() + external + givenFlexibleModeIsSet + givenLockedAnvVotedOnCurrentlyActiveProposalsFlexible + { + // It Should deallocate the existing voting power from active proposals + // It Should unlock and refund the full amount + vm.skip(true); + } + + modifier givenAProposalHasEnded() { + _; + } + + modifier givenBeforeReleaseLockIsCalled() { + _; + } + + modifier givenProposalVoterCallsUnlockNoReleaseLock() { + _; + } + + function test_WhenExecutedProposal() + external + givenAProposalHasEnded + givenBeforeReleaseLockIsCalled + givenProposalVoterCallsUnlockNoReleaseLock + { + // It Should allow voters from that proposal to unlock right away + vm.skip(true); + } + + function test_WhenDefeatedProposal() + external + givenAProposalHasEnded + givenBeforeReleaseLockIsCalled + givenProposalVoterCallsUnlockNoReleaseLock + { + // It Should allow voters from that proposal to unlock right away + vm.skip(true); + } + + function test_RevertWhen_ActiveProposal() + external + givenAProposalHasEnded + givenBeforeReleaseLockIsCalled + givenProposalVoterCallsUnlockNoReleaseLock + { + // It Should revert + vm.skip(true); + } + + modifier whenAfterReleaseLockIsCalled() { + _; + } + + function test_WhenProposalVoterCallsUnlockReleased() + external + givenAProposalHasEnded + whenAfterReleaseLockIsCalled + { + // It Should allow voters from that proposal to unlock right away + // It Should revert on voters who have any other unreleased proposal votes + vm.skip(true); + } + + function test_GivenPermissions() external { + // It Should revert if releaseLock is called by an incompatible plugin + // It Should revert if updateSettings is called by an address without the permission + vm.skip(true); + } +} diff --git a/test/LockManager.t.yaml b/test/LockManager.t.yaml new file mode 100644 index 0000000..cdc8f46 --- /dev/null +++ b/test/LockManager.t.yaml @@ -0,0 +1,134 @@ +LockManagerTest: + - when: calling updateSettings + and: + - when: updateSettings without the permission + then: + - it: should revert + - when: updateSettings with the permission + then: + - it: should update the mode + - when: Calling getSettings + then: + - it: Should return the right value + + - when: calling supportsInterface + then: + - it: does not support the empty interface + - it: supports IERC165Upgradeable + - it: supports ILockManager + + - when: Locking tokens + then: + - it: Should allow any token holder to lock + - it: Should use the full allowance + + - when: Locking and / or voting + and: + - given: Invalid plugin + then: + - it: Locking and voting should revert + - it: Voting should revert + - given: Valid lock to vote plugin + then: + - when: No token balance + then: + - it: Should revert + - when: No token allowance + then: + - it: Should revert + - when: Invalid or inactive proposal + then: + - it: Locking and voting should revert + - it: Voting should revert + - when: Valid proposal + and: + - when: Already voted + then: + - it: Should update the voting balance and the proposal tally + - it: Should increase the voting power by the full allowance + - when: Not voted yet + then: + - it: Should allow any token holder to vote + - it: Should use the full allowance to vote + + - when: Calling getTokens + then: + - it: Should return the token addresses where votes have been cast + - given: Calling getLocks + then: + - it: Should return the active proposals with 1+ locks + + - given: Strict mode is set + and: + - given: Didn't lock anything [strict] + and: + - when: Trying to unlock [1 strict] + then: + - it: Should do nothing + - given: Locked but didn't vote anywhere [strict] + then: + - when: Trying to unlock [2 strict] + then: + - it: Should unlock and refund the full amount right away + - given: Locked but voted on ended or executed proposals [strict] + then: + - when: Trying to unlock [3 strict] + then: + - it: Should unlock and refund the full amount right away + - given: Locked anv voted on currently active proposals [strict] + then: + - when: Trying to unlock [4 strict] + then: + - it: Should revert + + - given: Flexible mode is set + and: + - given: Didn't lock anything [flexible] + and: + - when: Trying to unlock [1 flexible] + then: + - it: Should do nothing + - given: Locked but didn't vote anywhere [flexible] + then: + - when: Trying to unlock [2 flexible] + then: + - it: Should unlock and refund the full amount right away + - given: Locked but voted on ended or executed proposals [flexible] + then: + - when: Trying to unlock [3 flexible] + then: + - it: Should unlock and refund the full amount right away + - given: Locked anv voted on currently active proposals [flexible] + then: + - when: Trying to unlock [4 flexible] + then: + - it: Should deallocate the existing voting power from active proposals + - it: Should unlock and refund the full amount + + - given: A proposal has ended + and: + - given: Before releaseLock is called + then: + - given: Proposal voter calls unlock [no releaseLock] + and: + - when: Executed proposal + then: + # Slightly redundant, because execute() will trigger releaseLock() + - it: Should allow voters from that proposal to unlock right away + - when: Defeated proposal + then: + - it: Should allow voters from that proposal to unlock right away + - when: Active proposal + then: + - it: Should revert + - when: After releaseLock is called + then: + - when: Proposal voter calls unlock [released] + then: + - it: Should allow voters from that proposal to unlock right away + - it: Should revert on voters who have any other unreleased proposal votes + + - given: Permissions + then: + - it: Should revert if releaseLock is called by an incompatible plugin + - it: Should revert if updateSettings is called by an address without the permission diff --git a/test/LockToVote.t.sol b/test/LockToVote.t.sol index 7a833af..003f0e7 100644 --- a/test/LockToVote.t.sol +++ b/test/LockToVote.t.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.17; -import {Test} from "forge-std/Test.sol"; import {AragonTest} from "./util/AragonTest.sol"; contract LockToVoteTest is AragonTest { From b3c1c4c8a62c09ba67626cf646f9cc372350d220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Wed, 11 Dec 2024 18:18:34 +0100 Subject: [PATCH 2/6] Scaffolding the LockManager functions --- src/LockManager.sol | 54 +++++++++++++++++++++++++++++++++++++--- src/LockToVotePlugin.sol | 5 ++-- test/LockManager.t.sol | 4 +++ test/LockManager.t.yaml | 3 +++ test/LockToVote.t.sol | 17 +++++++------ test/LockToVote.t.yaml | 14 +++++------ 6 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/LockManager.sol b/src/LockManager.sol index 0447779..bcda6c4 100644 --- a/src/LockManager.sol +++ b/src/LockManager.sol @@ -1,10 +1,58 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {DaoAuthorizable} from "@aragon/osx/core/plugin/dao-authorizable/DaoAuthorizable.sol"; import {ILockManager} from "./interfaces/ILockManager.sol"; +import {ILockToVote} from "./interfaces/ILockToVote.sol"; +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; -contract LockManager is ILockManager { - struct PluginSettings { - uint64 proposlDuration; +contract LockManager is ILockManager, DaoAuthorizable { + enum LockMode { + STRICT, + FLEXIBLE + } + struct Settings { + LockMode lockMode; + } + + Settings public settings; + + error InvalidLockMode(); + + constructor(IDAO _dao, Settings memory _settings) DaoAuthorizable(_dao) { + if ( + _settings.lockMode != LockMode.STRICT && + _settings.lockMode != LockMode.FLEXIBLE + ) { + revert InvalidLockMode(); + } + + settings.lockMode = _settings.lockMode; + } + + /// @inheritdoc ILockManager + function lock() public { + // + } + + /// @inheritdoc ILockManager + function lockAndVote(ILockToVote plugin, uint256 proposalId) public { + // + } + + /// @inheritdoc ILockManager + function vote(ILockToVote plugin, uint256 proposalId) public { + // + } + + /// @inheritdoc ILockManager + function unlock() public { + // + } + + /// @inheritdoc ILockManager + function releaseLock(uint256 proposalId) public { + // } } diff --git a/src/LockToVotePlugin.sol b/src/LockToVotePlugin.sol index e6ed951..ba7398a 100644 --- a/src/LockToVotePlugin.sol +++ b/src/LockToVotePlugin.sol @@ -3,8 +3,9 @@ pragma solidity ^0.8.13; import {ILockToVote} from "./interfaces/ILockToVote.sol"; -contract LockToVotePlugin is ILockToVote { +contract LockToVotePlugin { + // is ILockToVote struct PluginSettings { - uint64 proposlDuration; + uint64 proposalDuration; } } diff --git a/test/LockManager.t.sol b/test/LockManager.t.sol index 34df691..527b99f 100644 --- a/test/LockManager.t.sol +++ b/test/LockManager.t.sol @@ -4,6 +4,10 @@ pragma solidity 0.8.17; import {AragonTest} from "./util/AragonTest.sol"; contract LockManagerTest is AragonTest { + function test_WhenDeployingTheContract() external { + // It Registers the DAO address + } + modifier whenCallingUpdateSettings() { _; } diff --git a/test/LockManager.t.yaml b/test/LockManager.t.yaml index cdc8f46..6d48f21 100644 --- a/test/LockManager.t.yaml +++ b/test/LockManager.t.yaml @@ -1,4 +1,7 @@ LockManagerTest: + - when: Deploying the contract + then: + - it: Registers the DAO address - when: calling updateSettings and: - when: updateSettings without the permission diff --git a/test/LockToVote.t.sol b/test/LockToVote.t.sol index 003f0e7..eabd8a9 100644 --- a/test/LockToVote.t.sol +++ b/test/LockToVote.t.sol @@ -5,21 +5,22 @@ import {AragonTest} from "./util/AragonTest.sol"; contract LockToVoteTest is AragonTest { function test_WhenDeployingTheContract() external { - // It should initialize normally - vm.skip(true); - } - - function test_GivenADeployedContract() external { - // It should refuse to initialize again + // It should disable the initializers vm.skip(true); } - modifier givenANewInstance() { + modifier givenANewProxy() { _; } - function test_GivenCallingInitialize() external givenANewInstance { + function test_WhenCallingInitialize() external givenANewProxy { // It should set the DAO address + // It should initialize normally + vm.skip(true); + } + + function test_GivenADeployedContract() external { + // It should refuse to initialize again vm.skip(true); } diff --git a/test/LockToVote.t.yaml b/test/LockToVote.t.yaml index 5a1a332..9362bed 100644 --- a/test/LockToVote.t.yaml +++ b/test/LockToVote.t.yaml @@ -2,16 +2,16 @@ LockToVoteTest: # contract lifecycle - when: deploying the contract then: - - it: should initialize normally - - given: a deployed contract + - it: should disable the initializers + - given: A new proxy then: - - it: should refuse to initialize again - - given: a new instance - and: - - given: calling initialize + - when: calling initialize and: - it: should set the DAO address - + - it: should initialize normally + - given: a deployed contract + then: + - it: should refuse to initialize again - when: calling updateSettings and: - when: updateSettings without the permission From 417191cf1dba1e01186e051fb9a8df403850ddf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Fri, 13 Dec 2024 11:59:23 +0100 Subject: [PATCH 3/6] Lock Manager unlock primitives WIP --- src/LockManager.sol | 139 +++++++++++++++++++++++++++++--- src/interfaces/ILockManager.sol | 6 +- src/interfaces/ILockToVote.sol | 32 +++++--- 3 files changed, 149 insertions(+), 28 deletions(-) diff --git a/src/LockManager.sol b/src/LockManager.sol index bcda6c4..a11cda2 100644 --- a/src/LockManager.sol +++ b/src/LockManager.sol @@ -5,54 +5,167 @@ import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; import {DaoAuthorizable} from "@aragon/osx/core/plugin/dao-authorizable/DaoAuthorizable.sol"; import {ILockManager} from "./interfaces/ILockManager.sol"; import {ILockToVote} from "./interfaces/ILockToVote.sol"; -import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract LockManager is ILockManager, DaoAuthorizable { - enum LockMode { + /// @notice Defines whether locked funds can be unlocked at any time or not + enum UnlockMode { STRICT, - FLEXIBLE + EARLY } + + /// @notice The struct containing the LockManager helper settings struct Settings { - LockMode lockMode; + /// @param lockMode The mode defining whether funds can be unlocked at any time or not + UnlockMode unlockMode; + /// @param plugin The address of the lock to vote plugin to use + ILockToVote plugin; + /// @param token The address of the token contract + IERC20 token; } + /// @notice The current LockManager settings Settings public settings; - error InvalidLockMode(); + /// @notice Keeps track of the amount of tokens locked by address + mapping(address => uint256) lockedBalance; + + /// @notice Keeps a list of the known active proposal ID's + /// @dev Executed proposals will be actively reported, but defeated proposals will need to be garbage collected over time. + uint256[] knownProposalIds; + + /// @notice Thrown when trying to assign an invalid lock mode + error InvalidUnlockMode(); + + /// @notice Raised when the caller holds no tokens or didn't lock any tokens + error NoBalance(); + + /// @notice Raised when attempting to unlock while active votes are cast in strict mode + error LocksStillActive(); constructor(IDAO _dao, Settings memory _settings) DaoAuthorizable(_dao) { if ( - _settings.lockMode != LockMode.STRICT && - _settings.lockMode != LockMode.FLEXIBLE + _settings.unlockMode != UnlockMode.STRICT && + _settings.unlockMode != UnlockMode.EARLY ) { - revert InvalidLockMode(); + revert InvalidUnlockMode(); } - settings.lockMode = _settings.lockMode; + settings.unlockMode = _settings.unlockMode; + settings.plugin = _settings.plugin; + settings.token = _settings.token; } /// @inheritdoc ILockManager function lock() public { + // Register the token if not present // } /// @inheritdoc ILockManager - function lockAndVote(ILockToVote plugin, uint256 proposalId) public { + function lockAndVote(uint256 proposalId) public { // } /// @inheritdoc ILockManager - function vote(ILockToVote plugin, uint256 proposalId) public { - // + function vote(uint256 proposalId) public { + uint256 newVotingPower = lockedBalance[msg.sender]; + settings.plugin.vote(proposalId, msg.sender, newVotingPower); } /// @inheritdoc ILockManager function unlock() public { - // + if (lockedBalance[msg.sender] == 0) { + revert NoBalance(); + } + + if (settings.unlockMode == UnlockMode.STRICT) { + if (hasActiveLocks()) revert LocksStillActive(); + } else { + withdrawActiveVotingPower(); + } + + // All votes clear + + // Refund + uint256 refundBalance = lockedBalance[msg.sender]; + lockedBalance[msg.sender] = 0; + + settings.token.transfer(msg.sender, refundBalance); } /// @inheritdoc ILockManager function releaseLock(uint256 proposalId) public { // } + + // Internal + + function hasActiveLocks() internal returns (bool) { + uint256 _proposalCount = knownProposalIds.length; + for (uint256 _i; _i < _proposalCount; ) { + (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); + if (!open) { + cleanKnownProposalId(_i); + + // Are we at the last item? + /// @dev Comparing to `_proposalCount` instead of `_proposalCount - 1`, because the array is now shorter + if (_i == _proposalCount) { + return false; + } + + // Recheck the same index (now, another proposal) + continue; + } + + if (settings.plugin.hasVoted(msg.sender)) { + return true; + } + + unchecked { + _i++; + } + } + } + + function withdrawActiveVotingPower() internal { + uint256 _proposalCount = knownProposalIds.length; + for (uint256 _i; _i < _proposalCount; ) { + (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); + if (!open) { + cleanKnownProposalId(_i); + + // Are we at the last item? + /// @dev Comparing to `_proposalCount` instead of `_proposalCount - 1`, because the array is now shorter + if (_i == _proposalCount) { + return; + } + + // Recheck the same index (now, another proposal) + continue; + } + + uint votedBalance = settings.plugin.votedBalance(msg.sender); + if (votedBalance > 0) { + settings.plugin.clearVote(knownProposalIds[_i], msg.sender); + } + + unchecked { + _i++; + } + } + } + + /// @dev Cleaning up ended proposals, otherwise they would pile up and make unlocks more and more gas costly over time + function cleanKnownProposalId(uint _arrayIndex) internal { + // Swap the current item with the last, if needed + if (_arrayIndex < knownProposalIds.length - 1) { + knownProposalIds[_arrayIndex] = knownProposalIds[ + knownProposalIds.length - 1 + ]; + } + + // Trim the array's last item + knownProposalIds.length -= 1; + } } diff --git a/src/interfaces/ILockManager.sol b/src/interfaces/ILockManager.sol index 5035c32..ebf789f 100644 --- a/src/interfaces/ILockManager.sol +++ b/src/interfaces/ILockManager.sol @@ -12,14 +12,12 @@ interface ILockManager { function lock() external; /// @notice Locks the balance currently allowed by msg.sender on this contract and registers a vote on the target plugin - /// @param plugin The address of the lock to vote plugin where the lock will be used /// @param proposalId The ID of the proposal where the vote will be registered - function lockAndVote(ILockToVote plugin, uint256 proposalId) external; + function lockAndVote(uint256 proposalId) external; /// @notice Uses the locked balance to place a vote on the given proposal for the given plugin - /// @param plugin The address of the lock to vote plugin where the locked balance will be used /// @param proposalId The ID of the proposal where the vote will be registered - function vote(ILockToVote plugin, uint256 proposalId) external; + function vote(uint256 proposalId) external; /// @notice If the mode allows it, releases all active locks placed on active proposals and transfers msg.sender's locked balance back. Depending on the current mode, it withdraws only if no locks are being used in active proposals. function unlock() external; diff --git a/src/interfaces/ILockToVote.sol b/src/interfaces/ILockToVote.sol index 12fc6a0..3a50430 100644 --- a/src/interfaces/ILockToVote.sol +++ b/src/interfaces/ILockToVote.sol @@ -35,28 +35,38 @@ interface ILockToVote { /// - was executed, or /// - the voter doesn't have any tokens locked. /// @param proposalId The proposal Id. - /// @param account The account address to be checked. + /// @param voter The account address to be checked. /// @return Returns true if the account is allowed to vote. /// @dev The function assumes that the queried proposal exists. - function canVeto( + function canVote( uint256 proposalId, - address account + address voter ) external view returns (bool); /// @notice Registers an approval vote for the given proposal. /// @param proposalId The ID of the proposal to vote on. - function vote(uint256 proposalId) external; + /// @param voter The address of the account whose vote will be registered + /// @param newVotingPower The new balance that should be allocated to the voter. It can only be bigger. + /// @dev newVotingPower updates any prior voting power, it does not add to the existing amount. + function vote( + uint256 proposalId, + address voter, + uint newVotingPower + ) external; - function clearVote(uint256 proposalId) external; + /// @notice Reverts the existing voter's vote, if any. + /// @param proposalId The ID of the proposal. + /// @param voter The voter's address. + function clearVote(uint256 proposalId, address voter) external; /// @notice Returns whether the account has voted for the proposal. /// @param proposalId The ID of the proposal. - /// @param account The account address to be checked. - /// @return The whether the given account has voted for the given proposal to pass. - function hasVoted( + /// @param voter The account address to be checked. + /// @return The amount of balance that has been allocated for to the proposal by the given account. + function votedBalance( uint256 proposalId, - address account - ) external view returns (bool); + address voter + ) external view returns (uint256); /// @notice Checks if the amount of locked votes for the given proposal is greater than the approval threshold. /// @param proposalId The ID of the proposal. @@ -74,7 +84,7 @@ interface ILockToVote { /// @param proposalId The ID of the proposal to execute. function execute(uint256 proposalId) external; - /// @notice If the given proposal is no longer active, it notifies the manager so that the active locks no longer track it. + /// @notice If the given proposal is no longer active, it allows to notify the manager. /// @param proposalId The ID of the proposal to clean up for. function releaseLock(uint256 proposalId) external; } From 221323d7cd72483b3273143d11ef1bec157c8b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Fri, 13 Dec 2024 17:43:08 +0100 Subject: [PATCH 4/6] WIP --- src/LockManager.sol | 135 ++++++++++++++++++++++++-------- src/LockToVotePlugin.sol | 3 +- src/interfaces/ILockManager.sol | 18 ++++- src/interfaces/ILockToVote.sol | 18 +---- 4 files changed, 122 insertions(+), 52 deletions(-) diff --git a/src/LockManager.sol b/src/LockManager.sol index a11cda2..387dd64 100644 --- a/src/LockManager.sol +++ b/src/LockManager.sol @@ -28,15 +28,24 @@ contract LockManager is ILockManager, DaoAuthorizable { Settings public settings; /// @notice Keeps track of the amount of tokens locked by address - mapping(address => uint256) lockedBalance; + mapping(address => uint256) lockedBalances; /// @notice Keeps a list of the known active proposal ID's /// @dev Executed proposals will be actively reported, but defeated proposals will need to be garbage collected over time. uint256[] knownProposalIds; + /// @notice Emitted when a token holder locks funds into the manager contract + event BalanceLocked(address voter, uint256 amount); + + /// @notice Emitted when a token holder unlocks funds from the manager contract + event BalanceUnlocked(address voter, uint256 amount); + /// @notice Thrown when trying to assign an invalid lock mode error InvalidUnlockMode(); + /// @notice Thrown when the address calling proposalEnded() is not the plugin's + error InvalidPluginAddress(); + /// @notice Raised when the caller holds no tokens or didn't lock any tokens error NoBalance(); @@ -58,24 +67,37 @@ contract LockManager is ILockManager, DaoAuthorizable { /// @inheritdoc ILockManager function lock() public { - // Register the token if not present - // + _lock(); + } + + /// @inheritdoc ILockManager + function lockAndVote(uint256 _proposalId) public { + _lock(); + + _vote(_proposalId); } /// @inheritdoc ILockManager - function lockAndVote(uint256 proposalId) public { - // + function canVote( + uint256 _proposalId, + address _voter + ) external view returns (bool) { + uint256 availableBalance = lockedBalances[_voter] - + settings.plugin.usedVotingPower(_proposalId, _voter); + if (availableBalance == 0) return false; + + (bool open, bool executed, ) = settings.plugin.getProposal(_proposalId); + return !executed && open; } /// @inheritdoc ILockManager - function vote(uint256 proposalId) public { - uint256 newVotingPower = lockedBalance[msg.sender]; - settings.plugin.vote(proposalId, msg.sender, newVotingPower); + function vote(uint256 _proposalId) public { + _vote(_proposalId); } /// @inheritdoc ILockManager function unlock() public { - if (lockedBalance[msg.sender] == 0) { + if (lockedBalances[msg.sender] == 0) { revert NoBalance(); } @@ -87,30 +109,69 @@ contract LockManager is ILockManager, DaoAuthorizable { // All votes clear - // Refund - uint256 refundBalance = lockedBalance[msg.sender]; - lockedBalance[msg.sender] = 0; + uint256 _refundableBalance = lockedBalances[msg.sender]; + lockedBalances[msg.sender] = 0; - settings.token.transfer(msg.sender, refundBalance); + // Refund + settings.token.transfer(msg.sender, _refundableBalance); + emit BalanceUnlocked(msg.sender, _refundableBalance); } /// @inheritdoc ILockManager - function releaseLock(uint256 proposalId) public { - // + function proposalEnded(uint256 _proposalId) public { + if (msg.sender != address(settings.plugin)) { + revert InvalidPluginAddress(); + } + + for (uint _i; _i < knownProposalIds.length; ) { + if (knownProposalIds[_i] == _proposalId) { + removeKnownProposalId(_i); + return; + } + } } // Internal + function _lock() internal { + uint256 _allowance = settings.token.allowance( + msg.sender, + address(this) + ); + if (_allowance == 0) { + revert NoBalance(); + } + + settings.token.transferFrom(msg.sender, address(this), _allowance); + lockedBalances[msg.sender] += _allowance; + emit BalanceLocked(msg.sender, _allowance); + } + + function _vote(uint256 _proposalId) internal { + uint256 _newVotingPower = lockedBalances[msg.sender]; + if (_newVotingPower == 0) { + revert NoBalance(); + } else if ( + _newVotingPower == + settings.plugin.usedVotingPower(_proposalId, msg.sender) + ) { + return; + } + + settings.plugin.vote(_proposalId, msg.sender, _newVotingPower); + } + function hasActiveLocks() internal returns (bool) { uint256 _proposalCount = knownProposalIds.length; for (uint256 _i; _i < _proposalCount; ) { - (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); + // (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); + bool open = true; if (!open) { - cleanKnownProposalId(_i); + removeKnownProposalId(_i); + _proposalCount = knownProposalIds.length; // Are we at the last item? - /// @dev Comparing to `_proposalCount` instead of `_proposalCount - 1`, because the array is now shorter - if (_i == _proposalCount) { + if (_i == _proposalCount - 1) { return false; } @@ -118,7 +179,12 @@ contract LockManager is ILockManager, DaoAuthorizable { continue; } - if (settings.plugin.hasVoted(msg.sender)) { + if ( + settings.plugin.usedVotingPower( + knownProposalIds[_i], + msg.sender + ) > 0 + ) { return true; } @@ -131,13 +197,14 @@ contract LockManager is ILockManager, DaoAuthorizable { function withdrawActiveVotingPower() internal { uint256 _proposalCount = knownProposalIds.length; for (uint256 _i; _i < _proposalCount; ) { - (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); + // (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); + bool open = true; if (!open) { - cleanKnownProposalId(_i); + removeKnownProposalId(_i); + _proposalCount = knownProposalIds.length; // Are we at the last item? - /// @dev Comparing to `_proposalCount` instead of `_proposalCount - 1`, because the array is now shorter - if (_i == _proposalCount) { + if (_i == _proposalCount - 1) { return; } @@ -145,8 +212,12 @@ contract LockManager is ILockManager, DaoAuthorizable { continue; } - uint votedBalance = settings.plugin.votedBalance(msg.sender); - if (votedBalance > 0) { + if ( + settings.plugin.usedVotingPower( + knownProposalIds[_i], + msg.sender + ) > 0 + ) { settings.plugin.clearVote(knownProposalIds[_i], msg.sender); } @@ -157,15 +228,15 @@ contract LockManager is ILockManager, DaoAuthorizable { } /// @dev Cleaning up ended proposals, otherwise they would pile up and make unlocks more and more gas costly over time - function cleanKnownProposalId(uint _arrayIndex) internal { + function removeKnownProposalId(uint _arrayIndex) internal { + uint _lastItemIdx = knownProposalIds.length - 1; + // Swap the current item with the last, if needed - if (_arrayIndex < knownProposalIds.length - 1) { - knownProposalIds[_arrayIndex] = knownProposalIds[ - knownProposalIds.length - 1 - ]; + if (_arrayIndex < _lastItemIdx) { + knownProposalIds[_arrayIndex] = knownProposalIds[_lastItemIdx]; } // Trim the array's last item - knownProposalIds.length -= 1; + knownProposalIds.pop(); } } diff --git a/src/LockToVotePlugin.sol b/src/LockToVotePlugin.sol index ba7398a..ae31016 100644 --- a/src/LockToVotePlugin.sol +++ b/src/LockToVotePlugin.sol @@ -3,8 +3,7 @@ pragma solidity ^0.8.13; import {ILockToVote} from "./interfaces/ILockToVote.sol"; -contract LockToVotePlugin { - // is ILockToVote +contract LockToVotePlugin is ILockToVote { struct PluginSettings { uint64 proposalDuration; } diff --git a/src/interfaces/ILockManager.sol b/src/interfaces/ILockManager.sol index ebf789f..10f37a6 100644 --- a/src/interfaces/ILockManager.sol +++ b/src/interfaces/ILockManager.sol @@ -19,10 +19,24 @@ interface ILockManager { /// @param proposalId The ID of the proposal where the vote will be registered function vote(uint256 proposalId) external; + /// @notice Checks if an account can participate on a proposal. This can be because the proposal + /// - has not started, + /// - has ended, + /// - was executed, or + /// - the voter doesn't have any tokens locked. + /// @param proposalId The proposal Id. + /// @param voter The account address to be checked. + /// @return Returns true if the account is allowed to vote. + /// @dev The function assumes that the queried proposal exists. + function canVote( + uint256 proposalId, + address voter + ) external view returns (bool); + /// @notice If the mode allows it, releases all active locks placed on active proposals and transfers msg.sender's locked balance back. Depending on the current mode, it withdraws only if no locks are being used in active proposals. function unlock() external; - /// @notice Called by a lock to vote plugin whenever a proposal is executed. It instructs the manager to remove the proposal from the list of active proposal locks. + /// @notice Called by the lock to vote plugin whenever a proposal is executed (or ended). It instructs the manager to remove the proposal from the list of active proposal locks. /// @param proposalId The ID of the proposal that msg.sender is reporting as done. - function releaseLock(uint256 proposalId) external; + function proposalEnded(uint256 proposalId) external; } diff --git a/src/interfaces/ILockToVote.sol b/src/interfaces/ILockToVote.sol index 3a50430..8936bc6 100644 --- a/src/interfaces/ILockToVote.sol +++ b/src/interfaces/ILockToVote.sol @@ -29,20 +29,6 @@ interface ILockToVote { uint64 duration ) external returns (uint256 proposalId); - /// @notice Checks if an account can participate on a proposal. This can be because the proposal - /// - has not started, - /// - has ended, - /// - was executed, or - /// - the voter doesn't have any tokens locked. - /// @param proposalId The proposal Id. - /// @param voter The account address to be checked. - /// @return Returns true if the account is allowed to vote. - /// @dev The function assumes that the queried proposal exists. - function canVote( - uint256 proposalId, - address voter - ) external view returns (bool); - /// @notice Registers an approval vote for the given proposal. /// @param proposalId The ID of the proposal to vote on. /// @param voter The address of the account whose vote will be registered @@ -62,8 +48,8 @@ interface ILockToVote { /// @notice Returns whether the account has voted for the proposal. /// @param proposalId The ID of the proposal. /// @param voter The account address to be checked. - /// @return The amount of balance that has been allocated for to the proposal by the given account. - function votedBalance( + /// @return The amount of balance that has been allocated to the proposal by the given account. + function usedVotingPower( uint256 proposalId, address voter ) external view returns (uint256); From 1a2bfafebeaecea1ac758309083c71acc3dfb378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Mon, 16 Dec 2024 10:38:10 +0100 Subject: [PATCH 5/6] forge install: osx-commons --- .gitmodules | 3 +++ lib/osx-commons | 1 + 2 files changed, 4 insertions(+) create mode 160000 lib/osx-commons diff --git a/.gitmodules b/.gitmodules index 9836bdb..57dc7b1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/ens-contracts"] path = lib/ens-contracts url = https://github.com/ensdomains/ens-contracts +[submodule "lib/osx-commons"] + path = lib/osx-commons + url = https://github.com/aragon/osx-commons diff --git a/lib/osx-commons b/lib/osx-commons new file mode 160000 index 0000000..c66603f --- /dev/null +++ b/lib/osx-commons @@ -0,0 +1 @@ +Subproject commit c66603f2db78333d85d6fc5751f4ccc73f704aef From ac6e3eb5ac5fa9b2dae27f3e519086e952ab0050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Tue, 17 Dec 2024 12:10:17 +0100 Subject: [PATCH 6/6] Voting plugin work in progress --- lib/osx | 2 +- remappings.txt | 1 + src/LockManager.sol | 97 ++++++-------- src/LockToVotePlugin.sol | 226 +++++++++++++++++++++++++++++++- src/interfaces/ILockManager.sol | 21 +++ src/interfaces/ILockToVote.sol | 82 ++++++++---- 6 files changed, 335 insertions(+), 94 deletions(-) diff --git a/lib/osx b/lib/osx index e90ea8f..e0ba7b6 160000 --- a/lib/osx +++ b/lib/osx @@ -1 +1 @@ -Subproject commit e90ea8f5cd6b98cbba16db07ab7bc0cdbf517f3e +Subproject commit e0ba7b60b08fa1665ecac92dc12ea89e4245e7dc diff --git a/remappings.txt b/remappings.txt index 926df45..ae2ae97 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,5 +1,6 @@ @openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ @aragon/osx/=lib/osx/packages/contracts/src/ +@aragon/osx-commons-contracts/=lib/osx-commons/contracts/src/ @ensdomains/ens-contracts/=lib/ens-contracts/ forge-std/=lib/forge-std/src/ diff --git a/src/LockManager.sol b/src/LockManager.sol index 387dd64..50dc2cc 100644 --- a/src/LockManager.sol +++ b/src/LockManager.sol @@ -1,31 +1,21 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.13; +import {ILockManager, LockManagerSettings, UnlockMode} from "./interfaces/ILockManager.sol"; import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; import {DaoAuthorizable} from "@aragon/osx/core/plugin/dao-authorizable/DaoAuthorizable.sol"; -import {ILockManager} from "./interfaces/ILockManager.sol"; import {ILockToVote} from "./interfaces/ILockToVote.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract LockManager is ILockManager, DaoAuthorizable { - /// @notice Defines whether locked funds can be unlocked at any time or not - enum UnlockMode { - STRICT, - EARLY - } + /// @notice The current LockManager settings + LockManagerSettings public settings; - /// @notice The struct containing the LockManager helper settings - struct Settings { - /// @param lockMode The mode defining whether funds can be unlocked at any time or not - UnlockMode unlockMode; - /// @param plugin The address of the lock to vote plugin to use - ILockToVote plugin; - /// @param token The address of the token contract - IERC20 token; - } + /// @notice The address of the lock to vote plugin to use + ILockToVote public immutable plugin; - /// @notice The current LockManager settings - Settings public settings; + /// @notice The address of the token contract + IERC20 public immutable token; /// @notice Keeps track of the amount of tokens locked by address mapping(address => uint256) lockedBalances; @@ -52,7 +42,12 @@ contract LockManager is ILockManager, DaoAuthorizable { /// @notice Raised when attempting to unlock while active votes are cast in strict mode error LocksStillActive(); - constructor(IDAO _dao, Settings memory _settings) DaoAuthorizable(_dao) { + constructor( + IDAO _dao, + LockManagerSettings memory _settings, + ILockToVote _plugin, + IERC20 _token + ) DaoAuthorizable(_dao) { if ( _settings.unlockMode != UnlockMode.STRICT && _settings.unlockMode != UnlockMode.EARLY @@ -61,8 +56,8 @@ contract LockManager is ILockManager, DaoAuthorizable { } settings.unlockMode = _settings.unlockMode; - settings.plugin = _settings.plugin; - settings.token = _settings.token; + plugin = _plugin; + token = _token; } /// @inheritdoc ILockManager @@ -83,10 +78,10 @@ contract LockManager is ILockManager, DaoAuthorizable { address _voter ) external view returns (bool) { uint256 availableBalance = lockedBalances[_voter] - - settings.plugin.usedVotingPower(_proposalId, _voter); + plugin.usedVotingPower(_proposalId, _voter); if (availableBalance == 0) return false; - (bool open, bool executed, ) = settings.plugin.getProposal(_proposalId); + (bool open, bool executed, ) = plugin.getProposal(_proposalId); return !executed && open; } @@ -102,9 +97,9 @@ contract LockManager is ILockManager, DaoAuthorizable { } if (settings.unlockMode == UnlockMode.STRICT) { - if (hasActiveLocks()) revert LocksStillActive(); + if (_hasActiveLocks()) revert LocksStillActive(); } else { - withdrawActiveVotingPower(); + _withdrawActiveVotingPower(); } // All votes clear @@ -112,20 +107,20 @@ contract LockManager is ILockManager, DaoAuthorizable { uint256 _refundableBalance = lockedBalances[msg.sender]; lockedBalances[msg.sender] = 0; - // Refund - settings.token.transfer(msg.sender, _refundableBalance); + // Withdraw + token.transfer(msg.sender, _refundableBalance); emit BalanceUnlocked(msg.sender, _refundableBalance); } /// @inheritdoc ILockManager function proposalEnded(uint256 _proposalId) public { - if (msg.sender != address(settings.plugin)) { + if (msg.sender != address(plugin)) { revert InvalidPluginAddress(); } for (uint _i; _i < knownProposalIds.length; ) { if (knownProposalIds[_i] == _proposalId) { - removeKnownProposalId(_i); + _removeKnownProposalId(_i); return; } } @@ -134,15 +129,12 @@ contract LockManager is ILockManager, DaoAuthorizable { // Internal function _lock() internal { - uint256 _allowance = settings.token.allowance( - msg.sender, - address(this) - ); + uint256 _allowance = token.allowance(msg.sender, address(this)); if (_allowance == 0) { revert NoBalance(); } - settings.token.transferFrom(msg.sender, address(this), _allowance); + token.transferFrom(msg.sender, address(this), _allowance); lockedBalances[msg.sender] += _allowance; emit BalanceLocked(msg.sender, _allowance); } @@ -152,22 +144,20 @@ contract LockManager is ILockManager, DaoAuthorizable { if (_newVotingPower == 0) { revert NoBalance(); } else if ( - _newVotingPower == - settings.plugin.usedVotingPower(_proposalId, msg.sender) + _newVotingPower == plugin.usedVotingPower(_proposalId, msg.sender) ) { return; } - settings.plugin.vote(_proposalId, msg.sender, _newVotingPower); + plugin.vote(_proposalId, msg.sender, _newVotingPower); } - function hasActiveLocks() internal returns (bool) { + function _hasActiveLocks() internal returns (bool) { uint256 _proposalCount = knownProposalIds.length; for (uint256 _i; _i < _proposalCount; ) { - // (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); - bool open = true; + (bool open, ) = plugin.getProposal(knownProposalIds[_i]); if (!open) { - removeKnownProposalId(_i); + _removeKnownProposalId(_i); _proposalCount = knownProposalIds.length; // Are we at the last item? @@ -179,12 +169,7 @@ contract LockManager is ILockManager, DaoAuthorizable { continue; } - if ( - settings.plugin.usedVotingPower( - knownProposalIds[_i], - msg.sender - ) > 0 - ) { + if (plugin.usedVotingPower(knownProposalIds[_i], msg.sender) > 0) { return true; } @@ -194,13 +179,12 @@ contract LockManager is ILockManager, DaoAuthorizable { } } - function withdrawActiveVotingPower() internal { + function _withdrawActiveVotingPower() internal { uint256 _proposalCount = knownProposalIds.length; for (uint256 _i; _i < _proposalCount; ) { - // (bool open, ) = settings.plugin.getProposal(knownProposalIds[_i]); - bool open = true; + (bool open, ) = plugin.getProposal(knownProposalIds[_i]); if (!open) { - removeKnownProposalId(_i); + _removeKnownProposalId(_i); _proposalCount = knownProposalIds.length; // Are we at the last item? @@ -212,13 +196,8 @@ contract LockManager is ILockManager, DaoAuthorizable { continue; } - if ( - settings.plugin.usedVotingPower( - knownProposalIds[_i], - msg.sender - ) > 0 - ) { - settings.plugin.clearVote(knownProposalIds[_i], msg.sender); + if (plugin.usedVotingPower(knownProposalIds[_i], msg.sender) > 0) { + plugin.clearVote(knownProposalIds[_i], msg.sender); } unchecked { @@ -228,7 +207,7 @@ contract LockManager is ILockManager, DaoAuthorizable { } /// @dev Cleaning up ended proposals, otherwise they would pile up and make unlocks more and more gas costly over time - function removeKnownProposalId(uint _arrayIndex) internal { + function _removeKnownProposalId(uint _arrayIndex) internal { uint _lastItemIdx = knownProposalIds.length - 1; // Swap the current item with the last, if needed diff --git a/src/LockToVotePlugin.sol b/src/LockToVotePlugin.sol index ae31016..8f0282c 100644 --- a/src/LockToVotePlugin.sol +++ b/src/LockToVotePlugin.sol @@ -1,10 +1,226 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.13; -import {ILockToVote} from "./interfaces/ILockToVote.sol"; +import {ILockManager} from "./interfaces/ILockManager.sol"; +import {ILockToVote, LockToVoteSettings, Proposal} from "./interfaces/ILockToVote.sol"; +import {IDAO} from "@aragon/osx-commons-contracts/dao/IDAO.sol"; +import {ProposalUpgradeable} from "@aragon/osx-commons-contracts/plugin/extensions/proposal/ProposalUpgradeable.sol"; +import {IMembership} from "@aragon/osx-commons-contracts/plugin/extensions/membership/IMembership.sol"; +import {IProposal} from "@aragon/osx-commons-contracts/plugin/extensions/proposal/IProposal.sol"; +import {Action} from "@aragon/osx-commons-contracts/executors/IExecutor.sol"; +import {IPlugin} from "@aragon/osx-commons-contracts/plugin/IPlugin.sol"; +import {PluginUUPSUpgradeable} from "@aragon/osx-commons-contracts/plugin/PluginUUPSUpgradeable.sol"; +import {MetadataExtensionUpgradeable} from "@aragon/osx-commons-contracts/utils/metadata/MetadataExtensionUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {SafeCastUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; -contract LockToVotePlugin is ILockToVote { - struct PluginSettings { - uint64 proposalDuration; +contract LockToVotePlugin is + ILockToVote, + PluginUUPSUpgradeable, + ProposalUpgradeable, + MetadataExtensionUpgradeable, + IMembership +{ + using SafeCastUpgradeable for uint256; + + LockToVoteSettings public settings; + + ILockManager public lockManager; + + mapping(uint256 => Proposal) proposals; + + /// @notice The ID of the permission required to call the `updateVotingSettings` function. + bytes32 public constant UPDATE_VOTING_SETTINGS_PERMISSION_ID = + keccak256("UPDATE_VOTING_SETTINGS_PERMISSION"); + + /// @notice The ID of the permission required to call the `createProposal` functions. + bytes32 public constant CREATE_PROPOSAL_PERMISSION_ID = + keccak256("CREATE_PROPOSAL_PERMISSION"); + + /// @notice The ID of the permission required to call the `execute` function. + bytes32 public constant EXECUTE_PROPOSAL_PERMISSION_ID = + keccak256("EXECUTE_PROPOSAL_PERMISSION"); + + /// @notice Initializes the component. + /// @dev This method is required to support [ERC-1822](https://eips.ethereum.org/EIPS/eip-1822). + /// @param _dao The IDAO interface of the associated DAO. + /// @param _pluginSettings The voting settings. + /// @param _targetConfig Configuration for the execution target, specifying the target address and operation type + /// (either `Call` or `DelegateCall`). Defined by `TargetConfig` in the `IPlugin` interface, + /// part of the `osx-commons-contracts` package, added in build 3. + /// @param _pluginMetadata The plugin specific information encoded in bytes. + /// This can also be an ipfs cid encoded in bytes. + function initialize( + IDAO _dao, + ILockManager _lockManager, + LockToVoteSettings calldata _pluginSettings, + IPlugin.TargetConfig calldata _targetConfig, + bytes calldata _pluginMetadata + ) external onlyCallAtInitialization reinitializer(1) { + __PluginUUPSUpgradeable_init(_dao); + _updatePluginSettings(_pluginSettings); + _setTargetConfig(_targetConfig); + _setMetadata(_pluginMetadata); + + lockManager = _lockManager; + + emit MembershipContractAnnounced({ + definingContract: address(_lockManager.token()) + }); + } + + /// @notice Checks if this or the parent contract supports an interface by its ID. + /// @param _interfaceId The ID of the interface. + /// @return Returns `true` if the interface is supported. + function supportsInterface( + bytes4 _interfaceId + ) + public + view + virtual + override( + MetadataExtensionUpgradeable, + PluginUUPSUpgradeable, + ProposalUpgradeable + ) + returns (bool) + { + return + _interfaceId == type(IMembership).interfaceId || + _interfaceId == type(ILockToVote).interfaceId || + super.supportsInterface(_interfaceId); + } + + /// @inheritdoc IProposal + /// @dev Requires the `CREATE_PROPOSAL_PERMISSION_ID` permission. + function createProposal( + bytes calldata _metadata, + Action[] calldata _actions, + uint64 _startDate, + uint64 _endDate, + bytes memory _data + ) + external + auth(CREATE_PROPOSAL_PERMISSION_ID) + returns (uint256 proposalId) + { + uint256 _allowFailureMap; + + if (_data.length != 0) { + (_allowFailureMap) = abi.decode(_data, (uint256)); + } + + if (lockManager.token().totalSupply() == 0) { + revert NoVotingPower(); + } + + (_startDate, _endDate) = _validateProposalDates(_startDate, _endDate); + + proposalId = _createProposalId( + keccak256(abi.encode(_actions, _metadata)) + ); + + // Store proposal related information + Proposal storage proposal_ = proposals[proposalId]; + + if (proposal_.parameters.startDate != 0) { + revert ProposalAlreadyExists(proposalId); + } + + proposal_.parameters.startDate = _startDate; + proposal_.parameters.endDate = _endDate; + proposal_.parameters.minApprovalRatio = settings.minApprovalRatio; + + proposal_.targetConfig = getTargetConfig(); + + // Reduce costs + if (_allowFailureMap != 0) { + proposal_.allowFailureMap = _allowFailureMap; + } + + for (uint256 i; i < _actions.length; ) { + proposal_.actions.push(_actions[i]); + unchecked { + ++i; + } + } + + emit ProposalCreated( + proposalId, + _msgSender(), + _startDate, + _endDate, + _metadata, + _actions, + _allowFailureMap + ); + } + + /// @inheritdoc IProposal + function customProposalParamsABI() + external + pure + override + returns (string memory) + { + return "(uint256 allowFailureMap)"; + } + + function votingToken() external view returns (IERC20) { + return lockManager.token(); + } + + // Internal helpers + + /// @notice Validates and returns the proposal dates. + /// @param _start The start date of the proposal. + /// If 0, the current timestamp is used and the vote starts immediately. + /// @param _end The end date of the proposal. If 0, `_start + minDuration` is used. + /// @return startDate The validated start date of the proposal. + /// @return endDate The validated end date of the proposal. + function _validateProposalDates( + uint64 _start, + uint64 _end + ) internal view virtual returns (uint64 startDate, uint64 endDate) { + uint64 currentTimestamp = block.timestamp.toUint64(); + + if (_start == 0) { + startDate = currentTimestamp; + } else { + startDate = _start; + + if (startDate < currentTimestamp) { + revert DateOutOfBounds({ + limit: currentTimestamp, + actual: startDate + }); + } + } + + // Since `minDuration` is limited to 1 year, + // `startDate + minDuration` can only overflow if the `startDate` is after `type(uint64).max - minDuration`. + // In this case, the proposal creation will revert and another date can be picked. + uint64 earliestEndDate = startDate + settings.minProposalDuration; + + if (_end == 0) { + endDate = earliestEndDate; + } else { + endDate = _end; + + if (endDate < earliestEndDate) { + revert DateOutOfBounds({ + limit: earliestEndDate, + actual: endDate + }); + } + } + } + + function _updatePluginSettings( + LockToVoteSettings memory _newSettings + ) internal { + settings.minApprovalRatio = _newSettings.minApprovalRatio; + settings.minProposalDuration = _newSettings.minProposalDuration; } } diff --git a/src/interfaces/ILockManager.sol b/src/interfaces/ILockManager.sol index 10f37a6..98002b3 100644 --- a/src/interfaces/ILockManager.sol +++ b/src/interfaces/ILockManager.sol @@ -3,11 +3,32 @@ pragma solidity ^0.8.17; import {ILockToVote} from "./ILockToVote.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Defines whether locked funds can be unlocked at any time or not +enum UnlockMode { + STRICT, + EARLY +} + +/// @notice The struct containing the LockManager helper settings +struct LockManagerSettings { + /// @param lockMode The mode defining whether funds can be unlocked at any time or not + UnlockMode unlockMode; +} /// @title ILockManager /// @author Aragon X /// @notice Helper contract acting as the vault for locked tokens used to vote on multiple plugins and proposals. interface ILockManager { + /// @notice Returns the address of the voting plugin. + /// @return The LockToVote plugin address. + function plugin() external view returns (ILockToVote); + + /// @notice Returns the address of the token contract used to determine the voting power. + /// @return The token used for voting. + function token() external view returns (IERC20); + /// @notice Locks the balance currently allowed by msg.sender on this contract function lock() external; diff --git a/src/interfaces/ILockToVote.sol b/src/interfaces/ILockToVote.sol index 8936bc6..c9049b2 100644 --- a/src/interfaces/ILockToVote.sol +++ b/src/interfaces/ILockToVote.sol @@ -2,32 +2,61 @@ pragma solidity ^0.8.17; -import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; -import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; +import {IDAO} from "@aragon/osx-commons-contracts/dao/IDAO.sol"; +import {Action} from "@aragon/osx-commons-contracts/executors/IExecutor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PluginUUPSUpgradeable} from "@aragon/osx-commons-contracts/plugin/PluginUUPSUpgradeable.sol"; +import {IPlugin} from "@aragon/osx-commons-contracts/plugin/IPlugin.sol"; + +/// @notice A container for proposal-related information. +/// @param executed Whether the proposal is executed or not. +/// @param parameters The proposal parameters at the time of the proposal creation. +/// @param approvalTally The vote tally of the proposal. +/// @param votes The voting power cast by each voter. +/// @param actions The actions to be executed when the proposal passes. +/// @param allowFailureMap A bitmap allowing the proposal to succeed, even if individual actions might revert. +/// If the bit at index `i` is 1, the proposal succeeds even if the `i`th action reverts. +/// A failure map value of 0 requires every action to not revert. +/// @param targetConfig Configuration for the execution target, specifying the target address and operation type +/// (either `Call` or `DelegateCall`). Defined by `TargetConfig` in the `IPlugin` interface, +/// part of the `osx-commons-contracts` package, added in build 3. +struct Proposal { + bool executed; + ProposalParameters parameters; + uint256 approvalTally; + mapping(address => uint256) votes; + Action[] actions; + uint256 allowFailureMap; + IPlugin.TargetConfig targetConfig; +} + +/// @notice A container for the proposal parameters at the time of proposal creation. +/// @param minApprovalRatio The approval threshold above which the proposal becomes executable. +/// The value has to be in the interval [0, 10^6] defined by `RATIO_BASE = 10**6`. +/// @param startDate The start date of the proposal vote. +/// @param endDate The end date of the proposal vote. +struct ProposalParameters { + uint32 minApprovalRatio; + uint64 startDate; + uint64 endDate; +} + +/// @notice A container for the voting settings that will be applied as parameters on proposal creation. +/// @param minApprovalRatio The support threshold value. +/// Its value has to be in the interval [0, 10^6] defined by `RATIO_BASE = 10**6`. +/// @param minProposalDuration The minimum duration of the proposal voting stage in seconds. +struct LockToVoteSettings { + uint32 minApprovalRatio; + uint64 minProposalDuration; +} /// @title ILockToVote /// @author Aragon X /// @notice interface ILockToVote { - /// @notice getter function for the voting token. - /// @dev public function also useful for registering interfaceId and for distinguishing from majority voting interface. + /// @notice Returns the address of the token contract used to determine the voting power. /// @return The token used for voting. - function votingToken() external view returns (IVotesUpgradeable); - - /// @notice Returns the approvalRatio parameter stored in the plugin settings. - /// @return The approval ratio parameter, as a fraction of 1_000_000. - function minApprovalRatio() external view returns (uint32); - - /// @notice Creates a new proposal. - /// @param metadata The metadata of the proposal. - /// @param actions The actions that will be executed after the proposal passes. - /// @param duration The amount of seconds to allow for token holders to vote. NOTE: If the supplied value is zero, the proposal will be treated as an emergency one. - /// @return proposalId The ID of the proposal. - function createProposal( - bytes calldata metadata, - IDAO.Action[] calldata actions, - uint64 duration - ) external returns (uint256 proposalId); + function votingToken() external view returns (IERC20); /// @notice Registers an approval vote for the given proposal. /// @param proposalId The ID of the proposal to vote on. @@ -61,16 +90,11 @@ interface ILockToVote { uint256 proposalId ) external view returns (bool); - /// @notice Checks if a proposal can be executed. - /// @param proposalId The ID of the proposal to be checked. - /// @return True if the proposal can be executed, false otherwise. - function canExecute(uint256 proposalId) external view returns (bool); - - /// @notice Executes the given proposal. - /// @param proposalId The ID of the proposal to execute. - function execute(uint256 proposalId) external; - /// @notice If the given proposal is no longer active, it allows to notify the manager. /// @param proposalId The ID of the proposal to clean up for. function releaseLock(uint256 proposalId) external; + + error NoVotingPower(); + error ProposalAlreadyExists(uint256 proposalId); + error DateOutOfBounds(uint256 limit, uint256 actual); }